Skip to content

Commit 9f9c02f

Browse files
committed
Fix Office 365 calendars to be compatible with rfc5545
1 parent 8eaddbf commit 9f9c02f

File tree

6 files changed

+157
-19
lines changed

6 files changed

+157
-19
lines changed

homeassistant/components/remote_calendar/config_flow.py

+3-10
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55
from typing import Any
66

77
from httpx import HTTPError, InvalidURL
8-
from ical.calendar_stream import IcsCalendarStream
9-
from ical.exceptions import CalendarParseError
108
import voluptuous as vol
119

1210
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
1311
from homeassistant.const import CONF_URL
1412
from homeassistant.helpers.httpx_client import get_async_client
1513

1614
from .const import CONF_CALENDAR_NAME, DOMAIN
15+
from .ics import InvalidIcsException, parse_calendar
1716

1817
_LOGGER = logging.getLogger(__name__)
1918

@@ -64,15 +63,9 @@ async def async_step_user(
6463
_LOGGER.debug("An error occurred: %s", err)
6564
else:
6665
try:
67-
await self.hass.async_add_executor_job(
68-
IcsCalendarStream.calendar_from_ics, res.text
69-
)
70-
except CalendarParseError as err:
66+
await parse_calendar(self.hass, res.text)
67+
except InvalidIcsException:
7168
errors["base"] = "invalid_ics_file"
72-
_LOGGER.error("Error reading the calendar information: %s", err.message)
73-
_LOGGER.debug(
74-
"Additional calendar error detail: %s", str(err.detailed_error)
75-
)
7669
else:
7770
return self.async_create_entry(
7871
title=user_input[CONF_CALENDAR_NAME], data=user_input

homeassistant/components/remote_calendar/coordinator.py

+3-9
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
from httpx import HTTPError, InvalidURL
77
from ical.calendar import Calendar
8-
from ical.calendar_stream import IcsCalendarStream
9-
from ical.exceptions import CalendarParseError
108

119
from homeassistant.config_entries import ConfigEntry
1210
from homeassistant.const import CONF_URL
@@ -15,6 +13,7 @@
1513
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1614

1715
from .const import DOMAIN
16+
from .ics import InvalidIcsException, parse_calendar
1817

1918
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
2019

@@ -56,14 +55,9 @@ async def _async_update_data(self) -> Calendar:
5655
translation_placeholders={"err": str(err)},
5756
) from err
5857
try:
59-
# calendar_from_ics will dynamically load packages
60-
# the first time it is called, so we need to do it
61-
# in a separate thread to avoid blocking the event loop
6258
self.ics = res.text
63-
return await self.hass.async_add_executor_job(
64-
IcsCalendarStream.calendar_from_ics, self.ics
65-
)
66-
except CalendarParseError as err:
59+
return await parse_calendar(self.hass, res.text)
60+
except InvalidIcsException as err:
6761
raise UpdateFailed(
6862
translation_domain=DOMAIN,
6963
translation_key="unable_to_parse",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Module for parsing ICS content.
2+
3+
This module exists to fix known issues where calendar providers return calendars
4+
that do not follow rfcc5545. This module will attempt to fix the calendar and return
5+
a valid calendar object.
6+
"""
7+
8+
import logging
9+
10+
from ical.calendar import Calendar
11+
from ical.calendar_stream import IcsCalendarStream
12+
from ical.exceptions import CalendarParseError
13+
14+
from homeassistant.core import HomeAssistant
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
19+
class InvalidIcsException(Exception):
20+
"""Exception to indicate that the ICS content is invalid."""
21+
22+
23+
def _make_compat(ics: str) -> str:
24+
"""Make the ICS content compatible with the parser."""
25+
# Office 365 returns a calendar with a TZID that is not valid and does not meet
26+
# rfc5545. Remove the invalid TZID from the calendar so that it can be parsed correctly.
27+
return ics.replace(";TZID=Customized Time Zone", "")
28+
29+
30+
async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar:
31+
"""Parse the ICS content and return a Calendar object."""
32+
33+
ics = _make_compat(ics)
34+
35+
# calendar_from_ics will dynamically load packages the first time it is called, so we need
36+
# to do it in a separate thread to avoid blocking the event loop
37+
try:
38+
return await hass.async_add_executor_job(
39+
IcsCalendarStream.calendar_from_ics, ics
40+
)
41+
except CalendarParseError as err:
42+
_LOGGER.error("Error parsing calendar information: %s", err.message)
43+
_LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error))
44+
raise InvalidIcsException(err.message) from err
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# serializer version: 1
2+
# name: test_calendar_examples[office365_invalid_tzid]
3+
list([
4+
dict({
5+
'description': None,
6+
'end': dict({
7+
'dateTime': '2024-04-26T15:00:00-06:00',
8+
}),
9+
'location': '',
10+
'recurrence_id': None,
11+
'rrule': None,
12+
'start': dict({
13+
'dateTime': '2024-04-26T14:00:00-06:00',
14+
}),
15+
'summary': 'Uffe',
16+
'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B',
17+
}),
18+
])
19+
# ---

tests/components/remote_calendar/test_calendar.py

+30
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Tests for calendar platform of Remote Calendar."""
22

33
from datetime import datetime
4+
import pathlib
45
import textwrap
56

67
from httpx import Response
78
import pytest
89
import respx
10+
from syrupy.assertion import SnapshotAssertion
911

1012
from homeassistant.const import STATE_OFF, STATE_ON
1113
from homeassistant.core import HomeAssistant
@@ -21,6 +23,13 @@
2123

2224
from tests.common import MockConfigEntry
2325

26+
# Test data files with known calendars from various sources. You can add a new file
27+
# in the testdata directory and add it will be parsed and tested.
28+
TESTDATA_FILES = sorted(
29+
pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics")
30+
)
31+
TESTDATA_IDS = [f.stem for f in TESTDATA_FILES]
32+
2433

2534
@respx.mock
2635
async def test_empty_calendar(
@@ -392,3 +401,24 @@ async def test_all_day_iter_order(
392401

393402
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
394403
assert [event["summary"] for event in events] == event_order
404+
405+
406+
@respx.mock
407+
@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS)
408+
async def test_calendar_examples(
409+
hass: HomeAssistant,
410+
config_entry: MockConfigEntry,
411+
get_events: GetEventsFn,
412+
ics_filename: pathlib.Path,
413+
snapshot: SnapshotAssertion,
414+
) -> None:
415+
"""Test parsing known calendars form test data files."""
416+
respx.get(CALENDER_URL).mock(
417+
return_value=Response(
418+
status_code=200,
419+
text=ics_filename.read_text(),
420+
)
421+
)
422+
await setup_integration(hass, config_entry)
423+
events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00")
424+
assert events == snapshot
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
BEGIN:VCALENDAR
2+
METHOD:PUBLISH
3+
PRODID:Microsoft Exchange Server 2010
4+
VERSION:2.0
5+
X-WR-CALNAME:Kalender
6+
BEGIN:VTIMEZONE
7+
TZID:W. Europe Standard Time
8+
BEGIN:STANDARD
9+
DTSTART:16010101T030000
10+
TZOFFSETFROM:+0200
11+
TZOFFSETTO:+0100
12+
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
13+
END:STANDARD
14+
BEGIN:DAYLIGHT
15+
DTSTART:16010101T020000
16+
TZOFFSETFROM:+0100
17+
TZOFFSETTO:+0200
18+
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
19+
END:DAYLIGHT
20+
END:VTIMEZONE
21+
BEGIN:VTIMEZONE
22+
TZID:UTC
23+
BEGIN:STANDARD
24+
DTSTART:16010101T000000
25+
TZOFFSETFROM:+0000
26+
TZOFFSETTO:+0000
27+
END:STANDARD
28+
BEGIN:DAYLIGHT
29+
DTSTART:16010101T000000
30+
TZOFFSETFROM:+0000
31+
TZOFFSETTO:+0000
32+
END:DAYLIGHT
33+
END:VTIMEZONE
34+
BEGIN:VEVENT
35+
UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000
36+
010000000309AE93C8C3A94489F90ADBEA30C2F2B
37+
SUMMARY:Uffe
38+
DTSTART;TZID=Customized Time Zone:20240426T140000
39+
DTEND;TZID=Customized Time Zone:20240426T150000
40+
CLASS:PUBLIC
41+
PRIORITY:5
42+
DTSTAMP:20250417T155647Z
43+
TRANSP:OPAQUE
44+
STATUS:CONFIRMED
45+
SEQUENCE:0
46+
LOCATION:
47+
X-MICROSOFT-CDO-APPT-SEQUENCE:0
48+
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
49+
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
50+
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
51+
X-MICROSOFT-CDO-IMPORTANCE:1
52+
X-MICROSOFT-CDO-INSTTYPE:0
53+
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
54+
X-MICROSOFT-DISALLOW-COUNTER:FALSE
55+
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
56+
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
57+
END:VEVENT
58+
END:VCALENDAR

0 commit comments

Comments
 (0)