diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 10e1bb44295b9..94928bbdde038 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -7,7 +7,7 @@ from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -58,6 +58,7 @@ def __init__( self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id self._timeline: Timeline | None = None + self._manual_update_in_progress = False @property def event(self) -> CalendarEvent | None: @@ -85,14 +86,8 @@ def events_in_range() -> list[CalendarEvent]: return await self.hass.async_add_executor_job(events_in_range) - async def async_update(self) -> None: - """Refresh the timeline. - - This is called when the coordinator updates. Creating the timeline may - require walking through the entire calendar and handling recurring - events, so it is done as a separate task without blocking the event loop. - """ - await super().async_update() + async def _async_update_timeline(self) -> None: + """Refresh the materialized timeline.""" def _get_timeline() -> Timeline | None: """Return a materialized timeline with upcoming events.""" @@ -107,6 +102,39 @@ def _get_timeline() -> Timeline | None: self._timeline = await self.hass.async_add_executor_job(_get_timeline) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.last_update_success: + self.async_write_ha_state() + return + + if self._manual_update_in_progress: + return + + self.coordinator.config_entry.async_create_task( + self.hass, + self._async_handle_coordinator_update(), + name="remote calendar timeline update", + ) + + async def _async_handle_coordinator_update(self) -> None: + """Refresh the timeline and write state.""" + await self._async_update_timeline() + self.async_write_ha_state() + + async def async_update(self) -> None: + """Refresh the coordinator and materialized timeline.""" + self._manual_update_in_progress = True + try: + await super().async_update() + finally: + self._manual_update_in_progress = False + if self.coordinator.last_update_success: + await self._async_update_timeline() + if self.entity_id is not None: + self.async_write_ha_state() + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index ea52d961414ba..20d6595d4ef5e 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -296,6 +296,58 @@ async def test_upcoming_event( } +@pytest.mark.freeze_time("2026-05-18 06:00:00+00:00") +@respx.mock +async def test_coordinator_refresh_updates_upcoming_event_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test a coordinator refresh updates the materialized upcoming event.""" + original_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260518T064000 + DTEND:20260518T065500 + END:VEVENT + END:VCALENDAR + """ + ) + updated_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260518T080000 + DTEND:20260518T081500 + END:VEVENT + END:VCALENDAR + """ + ) + respx.get(CALENDER_URL).mock( + side_effect=[ + Response(status_code=200, text=original_calendar), + Response(status_code=200, text=original_calendar), + Response(status_code=200, text=updated_calendar), + ] + ) + await setup_integration(hass, config_entry) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes["start_time"] == "2026-05-18 06:40:00" + + await config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes["start_time"] == "2026-05-18 08:00:00" + + @respx.mock async def test_recurring_event( get_events: GetEventsFn,