mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 12:02:16 +01:00
Add calendar platform to Apple iCloud (#180735)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9145c009f8
commit
700e9da2ee
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, [])
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'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': <ANY>,
|
||||
'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({
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test iCloud Account Personal',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'calendar.test_icloud_account_personal',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user