diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index b450b7adc68b..7699b0693790 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -18,6 +18,7 @@ from .const import ( STORAGE_KEY, STORAGE_VERSION, ) +from .coordinator import IcloudCalendarCoordinator from .media_source import async_setup_mediasource, async_setup_photo_cache from .services import async_setup_services @@ -62,6 +63,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo await hass.async_add_executor_job(account.setup) + # Refreshed before the platforms are forwarded so the calendars are known + # by the time the calendar platform sets up. This deliberately does not use + # async_config_entry_first_refresh: an account that fails to authenticate + # still loads and starts a reauth flow, and a calendar outage should not + # take device tracking down with it. Calendars that are missing from the + # first refresh appear on a later one through the coordinator listener. + account.calendar_coordinator = IcloudCalendarCoordinator(hass, entry) + await account.calendar_coordinator.async_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await async_setup_photo_cache(hass, account) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 8c04d071d402..a760e6c32e3c 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -56,6 +56,7 @@ from .const import ( ) if TYPE_CHECKING: + from .coordinator import IcloudCalendarCoordinator from .media_source import PhotoCache _LOGGER = logging.getLogger(__name__) @@ -98,6 +99,9 @@ class IcloudAccount: self._unsub_fetch: CALLBACK_TYPE | None = None self.listeners: list[CALLBACK_TYPE] = [] + # Built in async_setup_entry, before the platforms are forwarded. + self.calendar_coordinator: IcloudCalendarCoordinator | None = None + self.photo_cache: PhotoCache | None = None def setup(self) -> None: diff --git a/homeassistant/components/icloud/calendar.py b/homeassistant/components/icloud/calendar.py new file mode 100644 index 000000000000..7b7564180166 --- /dev/null +++ b/homeassistant/components/icloud/calendar.py @@ -0,0 +1,120 @@ +"""Support for iCloud Calendars.""" + +from datetime import datetime +from typing import override + +from pyicloud.exceptions import PyiCloudException + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .account import IcloudConfigEntry +from .const import DOMAIN +from .coordinator import IcloudCalendarCoordinator, IcloudCalendarData, localize + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IcloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the iCloud calendars.""" + coordinator = entry.runtime_data.calendar_coordinator + assert coordinator is not None + + known: set[str] = set() + + @callback + def _add_new_calendars() -> None: + """Add entities for calendars that appeared since the last poll.""" + if not (new := set(coordinator.data or {}) - known): + return + known.update(new) + async_add_entities( + IcloudCalendarEntity(coordinator, entry, guid) for guid in new + ) + + _add_new_calendars() + entry.async_on_unload(coordinator.async_add_listener(_add_new_calendars)) + + +class IcloudCalendarEntity( + CoordinatorEntity[IcloudCalendarCoordinator], CalendarEntity +): + """A calendar from iCloud.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IcloudCalendarCoordinator, + entry: IcloudConfigEntry, + guid: str, + ) -> None: + """Initialize the calendar.""" + super().__init__(coordinator) + self._guid = guid + self._attr_unique_id = f"{entry.unique_id}_{guid}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}_account")}, + manufacturer="Apple", + name=entry.title, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def _calendar(self) -> IcloudCalendarData | None: + """Return the cached calendar, or None once it is gone from iCloud.""" + return self.coordinator.data.get(self._guid) + + @property + @override + def available(self) -> bool: + """Return True if the calendar still exists in iCloud.""" + return super().available and self._calendar is not None + + @property + @override + def name(self) -> str | None: + """Return the name of the calendar.""" + if (calendar := self._calendar) is not None: + return calendar.name + return None + + @property + @override + def event(self) -> CalendarEvent | None: + """Return the event in progress, or the next one to start.""" + if (calendar := self._calendar) is None: + return None + + now = dt_util.now() + upcoming: CalendarEvent | None = None + for event in calendar.events: + if localize(event.end) <= now: + continue + if localize(event.start) <= now: + return event + if upcoming is None or localize(event.start) < localize(upcoming.start): + upcoming = event + + return upcoming + + @override + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return the events in an arbitrary range.""" + try: + events = await hass.async_add_executor_job( + self.coordinator.fetch_events, start_date, end_date, [self._guid] + ) + except PyiCloudException as err: + raise HomeAssistantError(f"Error fetching events: {err}") from err + + return events.get(self._guid, []) diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index 72b1d496121b..f651b41b258f 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -18,7 +18,7 @@ DEFAULT_GPS_ACCURACY_THRESHOLD = 500 # meters STORAGE_KEY = DOMAIN STORAGE_VERSION = 2 -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR] # pyicloud.AppleDevice status DEVICE_BATTERY_LEVEL = "batteryLevel" diff --git a/homeassistant/components/icloud/coordinator.py b/homeassistant/components/icloud/coordinator.py new file mode 100644 index 000000000000..1f4d11dff3ae --- /dev/null +++ b/homeassistant/components/icloud/coordinator.py @@ -0,0 +1,198 @@ +"""Coordinator for iCloud Calendars.""" + +from dataclasses import dataclass +from datetime import date, datetime, timedelta, tzinfo +import logging +from typing import override + +from pyicloud.exceptions import PyiCloudException +from pyicloud.services.calendar import CalendarService, EventObject + +from homeassistant.components.calendar import CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .account import IcloudConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=15) + +# How much of the calendar to keep cached for the entity's current/next event. +# `async_get_events` queries iCloud directly for anything outside this window. +LOOKBACK = timedelta(days=1) +LOOKAHEAD = timedelta(days=30) + + +@dataclass(slots=True) +class IcloudCalendarData: + """A calendar and the events cached for it.""" + + name: str + events: list[CalendarEvent] + + +def localize(value: date | datetime) -> datetime: + """Return a comparable, timezone-aware datetime for a date or datetime.""" + if isinstance(value, datetime): + return dt_util.as_local(value) + return dt_util.start_of_local_day(value) + + +class IcloudCalendarCoordinator(DataUpdateCoordinator[dict[str, IcloudCalendarData]]): + """Keep a rolling window of events cached for the current/next lookup.""" + + def __init__(self, hass: HomeAssistant, entry: IcloudConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.account = entry.runtime_data + + @property + def _calendars(self) -> CalendarService: + """Return the calendar service of the authenticated account.""" + if (api := self.account.api) is None: + raise ConfigEntryAuthFailed("iCloud account is not authenticated") + return api.calendar + + def fetch_events( + self, start: datetime, end: datetime, guids: list[str] | None = None + ) -> dict[str, list[CalendarEvent]]: + """Return events per calendar between two points. Runs in the executor.""" + service = self._calendars + + # An explicit empty list means no calendars, not every calendar. + if guids is not None and not guids: + return {} + + wanted = set(guids) if guids is not None else None + result: dict[str, list[CalendarEvent]] = {guid: [] for guid in (wanted or ())} + + # pyicloud sends both bounds as plain dates, so iCloud answers with the + # whole of each boundary day whatever times were asked for. Keep only + # the events that really overlap the window. + window_start = localize(start) + window_end = localize(end) + + for event in service.get_events(from_dt=start, to_dt=end, as_objs=True): + if wanted is not None and event.pguid not in wanted: + continue + if (parsed := _as_calendar_event(event)) is None: + continue + if ( + localize(parsed.start) >= window_end + or localize(parsed.end) <= window_start + ): + continue + result.setdefault(event.pguid, []).append(parsed) + + for events in result.values(): + events.sort(key=lambda event: localize(event.start)) + return result + + def _fetch(self) -> dict[str, IcloudCalendarData]: + """Fetch the calendars and their events. Runs in the executor.""" + names = { + calendar.guid: calendar.title + for calendar in self._calendars.get_calendars(as_objs=True) + } + now = dt_util.now() + events = self.fetch_events(now - LOOKBACK, now + LOOKAHEAD, list(names)) + + return { + guid: IcloudCalendarData(name=name, events=events.get(guid, [])) + for guid, name in names.items() + } + + @override + async def _async_update_data(self) -> dict[str, IcloudCalendarData]: + """Fetch calendars and their upcoming events.""" + try: + return await self.hass.async_add_executor_job(self._fetch) + except PyiCloudException as err: + raise UpdateFailed(f"Error fetching calendars: {err}") from err + + +def _parse_apple_date(value: datetime | list[int] | None) -> datetime | None: + """Parse the date format iCloud returns for calendar events. + + ``EventObject`` is annotated as holding ``datetime``, but pyicloud hands + back the wire format unchanged: ``[yyyymmdd, year, month, day, hour, + minute, minutes_since_midnight]``. Both forms are accepted so this keeps + working if that is ever changed upstream. + """ + if value is None: + return None + if isinstance(value, datetime): + return value + if len(value) >= 6: + try: + _, year, month, day, hour, minute = value[:6] + return datetime(int(year), int(month), int(day), int(hour), int(minute)) + except TypeError, ValueError: + _LOGGER.debug("Unparsable calendar date: %r", value) + return None + + +def _event_timezone(event: EventObject) -> tzinfo: + """Return the timezone an event's wall-clock times are expressed in. + + iCloud reports naive local times alongside a `tz` field. "Floating" means + the event has no zone of its own and should follow the viewer, so fall + back to Home Assistant's timezone in that case. + """ + if (name := event.tz) and name != "Floating": + try: + if (zone := dt_util.get_time_zone(name)) is not None: + return zone + except ValueError: + # get_time_zone rejects malformed keys rather than returning None. + _LOGGER.debug("Unknown calendar event timezone: %r", name) + return dt_util.get_default_time_zone() + + +def _as_calendar_event(event: EventObject) -> CalendarEvent | None: + """Convert a pyicloud event into a Home Assistant calendar event.""" + start = _parse_apple_date(event.local_start_date) or _parse_apple_date( + event.start_date + ) + if start is None: + return None + + end = _parse_apple_date(event.local_end_date) or _parse_apple_date(event.end_date) + if end is None: + end = start + timedelta(hours=1) + + start_value: date | datetime + end_value: date | datetime + if event.all_day: + start_value = start.date() + end_value = end.date() + # Home Assistant treats the end of an all-day event as exclusive. + if end_value <= start_value: + end_value = start_value + timedelta(days=1) + else: + # The wire format is a naive wall-clock time; only a `datetime` from a + # future pyicloud can already carry a zone, and replacing it would + # move the event to a different instant. + zone = _event_timezone(event) + start_value = start if start.tzinfo else start.replace(tzinfo=zone) + end_value = end if end.tzinfo else end.replace(tzinfo=zone) + if end_value <= start_value: + end_value = start_value + timedelta(minutes=30) + + return CalendarEvent( + uid=event.guid or None, + summary=event.title or "", + start=start_value, + end=end_value, + location=event.location or None, + ) diff --git a/tests/components/icloud/snapshots/test_calendar.ambr b/tests/components/icloud/snapshots/test_calendar.ambr new file mode 100644 index 000000000000..39f16e0a7bc5 --- /dev/null +++ b/tests/components/icloud/snapshots/test_calendar.ambr @@ -0,0 +1,126 @@ +# serializer version: 1 +# name: test_all_day_event_end_is_exclusive + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-02', + 'start': '2024-05-01', + 'summary': 'Holiday', + }), + ]), + }), + }) +# --- +# name: test_datetime_dates_are_accepted + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00+02:00', + 'start': '2024-05-01T10:00:00+02:00', + 'summary': 'Naive', + }), + dict({ + 'end': '2024-05-01T11:00:00+00:00', + 'start': '2024-05-01T10:00:00+00:00', + 'summary': 'Aware', + }), + ]), + }), + }) +# --- +# name: test_entities[calendar.test_icloud_account_personal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_icloud_account_personal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Personal', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Personal', + 'platform': 'icloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test_account_id_cal1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.test_icloud_account_personal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test iCloud Account Personal', + }), + 'context': , + 'entity_id': 'calendar.test_icloud_account_personal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_event_uses_its_own_timezone + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00+02:00', + 'start': '2024-05-01T10:00:00+02:00', + 'summary': 'Meeting', + }), + ]), + }), + }) +# --- +# name: test_get_events_filters_to_the_requested_window + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T20:00:00-07:00', + 'start': '2024-05-01T07:00:00-07:00', + 'summary': 'Spanning the window', + }), + dict({ + 'end': '2024-05-01T10:30:00-07:00', + 'start': '2024-05-01T09:30:00-07:00', + 'summary': 'Overlapping the start', + }), + ]), + }), + }) +# --- +# name: test_unknown_event_timezone_falls_back + dict({ + 'calendar.test_icloud_account_personal': dict({ + 'events': list([ + dict({ + 'end': '2024-05-01T11:00:00-07:00', + 'start': '2024-05-01T10:00:00-07:00', + 'summary': 'Meeting', + }), + ]), + }), + }) +# --- diff --git a/tests/components/icloud/test_calendar.py b/tests/components/icloud/test_calendar.py new file mode 100644 index 000000000000..baf97f35a22f --- /dev/null +++ b/tests/components/icloud/test_calendar.py @@ -0,0 +1,402 @@ +"""Tests for the iCloud calendar platform.""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyicloud.exceptions import PyiCloudException +from pyicloud.services.calendar import CalendarObject, EventObject +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN +from homeassistant.components.icloud.coordinator import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import ( + AsyncMock, + MockConfigEntry, + async_fire_time_changed, + snapshot_platform, +) + +ENTITY_ID = "calendar.test_icloud_account_personal" + + +def _apple_date(value: datetime) -> list[int]: + """Return a datetime in the wire format pyicloud passes through.""" + return [ + int(value.strftime("%Y%m%d")), + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.hour * 60 + value.minute, + ] + + +def _event( + guid: str, + title: str, + start: datetime, + end: datetime, + *, + pguid: str = "cal1", + all_day: bool = False, + location: str = "", + tz: str = "Floating", +) -> MagicMock: + """Build a mock pyicloud event.""" + event = MagicMock(spec=EventObject) + event.guid = guid + event.pguid = pguid + event.title = title + event.all_day = all_day + event.location = location + event.tz = tz + event.local_start_date = _apple_date(start) + event.local_end_date = _apple_date(end) + event.start_date = event.local_start_date + event.end_date = event.local_end_date + return event + + +def _calendar(guid: str, title: str) -> MagicMock: + """Build a mock pyicloud calendar.""" + calendar = MagicMock(spec=CalendarObject) + calendar.guid = guid + calendar.title = title + return calendar + + +@pytest.fixture(name="calendars") +def mock_calendars(icloud_client: AsyncMock) -> MagicMock: + """Mock the calendar service with one calendar and one event.""" + service = icloud_client.api.calendar + service.get_calendars.return_value = [_calendar("cal1", "Personal")] + service.get_events.return_value = [ + _event( + "ev1", + "Dentist", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + ) + ] + return service + + +async def _setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the config entry with only the calendar platform loaded.""" + config_entry.add_to_hass(hass) + with patch("homeassistant.components.icloud.PLATFORMS", [Platform.CALENDAR]): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that a calendar becomes an entity.""" + await _setup(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_event_in_progress_wins( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a running event is preferred over a later one.""" + now = dt_util.now() + calendars.get_events.return_value = [ + _event( + "ev1", + "Now", + now.replace(tzinfo=None) - SCAN_INTERVAL, + now.replace(tzinfo=None) + SCAN_INTERVAL, + ), + _event( + "ev2", + "Later", + now.replace(tzinfo=None) + SCAN_INTERVAL * 2, + now.replace(tzinfo=None) + SCAN_INTERVAL * 3, + ), + ] + + await _setup(hass, config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["message"] == "Now" + + +async def test_all_day_event_end_is_exclusive( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a single all-day event ends on the following day. + + iCloud reports the same day for start and end, but Home Assistant treats + the end of an all-day event as exclusive. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Holiday", + datetime(2024, 5, 1), + datetime(2024, 5, 1), + all_day=True, + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_get_events_filters_to_the_requested_window( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that events outside the requested range are dropped. + + pyicloud sends both bounds as plain dates, so iCloud answers a one-hour + request with everything on the boundary days. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Before", + datetime(2024, 5, 1, 8, 0), + datetime(2024, 5, 1, 9, 0), + ), + _event( + "ev2", + "Overlapping the start", + datetime(2024, 5, 1, 9, 30), + datetime(2024, 5, 1, 10, 30), + ), + _event( + "ev3", + "Spanning the window", + datetime(2024, 5, 1, 7, 0), + datetime(2024, 5, 1, 20, 0), + ), + _event( + "ev4", + "After", + datetime(2024, 5, 1, 14, 0), + datetime(2024, 5, 1, 15, 0), + ), + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 5, 1, 10, 0), + "end_date_time": datetime(2024, 5, 1, 11, 0), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_new_calendar_added_on_later_poll( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a calendar created after setup appears on a later refresh.""" + await _setup(hass, config_entry) + assert hass.states.get("calendar.test_icloud_account_work") is None + + calendars.get_calendars.return_value = [ + _calendar("cal1", "Personal"), + _calendar("cal2", "Work"), + ] + freezer.tick(SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + # The scheduled refresh runs as a background task of the config entry. + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("calendar.test_icloud_account_work") is not None + + +async def test_get_events_error_raises( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, +) -> None: + """Test that an iCloud error surfaces as a Home Assistant error.""" + await _setup(hass, config_entry) + calendars.get_events.side_effect = PyiCloudException("boom") + + with pytest.raises(HomeAssistantError, match="Error fetching events"): + await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + + +async def test_event_uses_its_own_timezone( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that an event in another timezone keeps its own instant. + + iCloud reports naive wall-clock times alongside a `tz` field, so assuming + Home Assistant's timezone would place the event at the wrong instant. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Meeting", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_datetime_dates_are_accepted( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that real datetimes are accepted alongside the wire format. + + pyicloud annotates `EventObject` as holding `datetime` but passes Apple's + wire format through unchanged, so both forms are handled. A naive datetime + is a wall-clock time in the event's own timezone, while an aware one + already names its instant and must keep it. + """ + naive = _event( + "ev1", + "Naive", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + naive.local_start_date = datetime(2024, 5, 1, 10, 0) + naive.local_end_date = datetime(2024, 5, 1, 11, 0) + + aware = _event( + "ev2", + "Aware", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Europe/Rome", + ) + aware.local_start_date = datetime(2024, 5, 1, 10, 0, tzinfo=dt_util.UTC) + aware.local_end_date = datetime(2024, 5, 1, 11, 0, tzinfo=dt_util.UTC) + + calendars.get_events.return_value = [naive, aware] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot + + +async def test_unknown_event_timezone_falls_back( + hass: HomeAssistant, + config_entry: MockConfigEntry, + calendars: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a malformed timezone falls back to Home Assistant's own. + + The event has to survive the fetch rather than be dropped, and its + wall-clock time is read in the default timezone. + """ + calendars.get_events.return_value = [ + _event( + "ev1", + "Meeting", + datetime(2024, 5, 1, 10, 0), + datetime(2024, 5, 1, 11, 0), + tz="Not/AZone", + ) + ] + + await _setup(hass, config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + "get_events", + { + ATTR_ENTITY_ID: ENTITY_ID, + "start_date_time": datetime(2024, 4, 30), + "end_date_time": datetime(2024, 5, 3), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot