Add calendar platform to Apple iCloud (#180735)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Federico Zivolo
2026-08-30 17:14:38 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9145c009f8
commit 700e9da2ee
7 changed files with 861 additions and 1 deletions
@@ -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:
+120
View File
@@ -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, [])
+1 -1
View File
@@ -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',
}),
]),
}),
})
# ---
+402
View File
@@ -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