Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 25 additions & 9 deletions homeassistant/components/remote_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,14 +85,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."""
Expand All @@ -107,6 +101,28 @@ 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

self.coordinator.config_entry.async_create_task(
self.hass, self._async_handle_coordinator_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()
Comment on lines +105 to +124

async def async_update(self) -> None:
"""Refresh the coordinator and materialized timeline."""
await super().async_update()
if self.coordinator.last_update_success:
await self._async_update_timeline()
Comment on lines +105 to +134


def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
Expand Down
52 changes: 52 additions & 0 deletions tests/components/remote_calendar/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,58 @@ async def test_upcoming_event(
}


@pytest.mark.freeze_time(datetime(2026, 5, 18, 6))
@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,
Expand Down