diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index ad9dac1f727..7cdf69a0c33 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -193,27 +193,50 @@ class WebDavCalendarData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" + start_of_today = dt.start_of_local_day() + start_of_tomorrow = dt.start_of_local_day() + timedelta(days=1) + # We have to retrieve the results for the whole day as the server # won't return events that have already started - results = self.calendar.date_search( - dt.start_of_local_day(), dt.start_of_local_day() + timedelta(days=1) - ) + results = self.calendar.date_search(start_of_today, start_of_tomorrow) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = _start_of_today.date() + _start_of_tomorrow = _start_of_tomorrow.date() + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [event.instance.vevent for event in results + new_events] # dtstart can be a date or datetime depending if the event lasts a # whole day. Convert everything to datetime to be able to sort it - results.sort(key=lambda x: self.to_datetime(x.instance.vevent.dtstart.value)) + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) vevent = next( ( - event.instance.vevent - for event in results + vevent + for vevent in vevents if ( - self.is_matching(event.instance.vevent, self.search) - and ( - not self.is_all_day(event.instance.vevent) - or self.include_all_day - ) - and not self.is_over(event.instance.vevent) + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(vevent) ) ), None, @@ -223,7 +246,7 @@ class WebDavCalendarData: if vevent is None: _LOGGER.debug( "No matching event found in the %d results for %s", - len(results), + len(vevents), self.calendar.name, ) self.event = None diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index c0be635988a..fa6f331363f 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -124,6 +124,96 @@ LOCATION:Hamburg DESCRIPTION:What a day END:VEVENT END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:9 +DTSTAMP:20171125T000000Z +DTSTART:20171027T220000Z +DTEND:20171027T223000Z +SUMMARY:This is a recurring event +LOCATION:Hamburg +DESCRIPTION:Every day for a while +RRULE:FREQ=DAILY;UNTIL=20171227T215959 +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:10 +DTSTAMP:20171125T000000Z +DTSTART:20171027T230000Z +DURATION:PT30M +SUMMARY:This is a recurring event with a duration +LOCATION:Hamburg +DESCRIPTION:Every day for a while as well +RRULE:FREQ=DAILY;UNTIL=20171227T215959 +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:11 +DTSTAMP:20171125T000000Z +DTSTART:20171027T233000Z +DTEND:20171027T235959Z +SUMMARY:This is a recurring event that has ended +LOCATION:Hamburg +DESCRIPTION:Every day for a while +RRULE:FREQ=DAILY;UNTIL=20171127T225959 +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:12 +DTSTAMP:20171125T000000Z +DTSTART:20171027T234500Z +DTEND:20171027T235959Z +SUMMARY:This is a recurring event that never ends +LOCATION:Hamburg +DESCRIPTION:Every day forever +RRULE:FREQ=DAILY +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:13 +DTSTAMP:20161125T000000Z +DTSTART:20161127 +DTEND:20161128 +SUMMARY:This is a recurring all day event +LOCATION:Hamburg +DESCRIPTION:Groundhog Day +RRULE:FREQ=DAILY;COUNT=100 +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:14 +DTSTAMP:20151125T000000Z +DTSTART:20151127T000000Z +DTEND:20151127T003000Z +SUMMARY:This is an hourly recurring event +LOCATION:Hamburg +DESCRIPTION:The bell tolls for thee +RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=12 +END:VEVENT +END:VCALENDAR """, ] @@ -461,3 +551,227 @@ async def test_all_day_event_returned(mock_now, hass, calendar): "location": "Hamburg", "description": "What a beautiful day", } + + +@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45)) +async def test_event_rrule(mock_now, hass, calendar): + """Test that the future recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 22:00:00", + "end_time": "2017-11-27 22:30:00", + "location": "Hamburg", + "description": "Every day for a while", + } + + +@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15)) +async def test_event_rrule_ongoing(mock_now, hass, calendar): + """Test that the current recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 22:00:00", + "end_time": "2017-11-27 22:30:00", + "location": "Hamburg", + "description": "Every day for a while", + } + + +@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45)) +async def test_event_rrule_duration(mock_now, hass, calendar): + """Test that the future recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring event with a duration", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 23:00:00", + "end_time": "2017-11-27 23:30:00", + "location": "Hamburg", + "description": "Every day for a while as well", + } + + +@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15)) +async def test_event_rrule_duration_ongoing(mock_now, hass, calendar): + """Test that the ongoing recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring event with a duration", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 23:00:00", + "end_time": "2017-11-27 23:30:00", + "location": "Hamburg", + "description": "Every day for a while as well", + } + + +@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37)) +async def test_event_rrule_endless(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring event that never ends", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 23:45:00", + "end_time": "2017-11-27 23:59:59", + "location": "Hamburg", + "description": "Every day forever", + } + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2016, 12, 1, 17, 30)), +) +async def test_event_rrule_all_day(mock_now, hass, calendar): + """Test that the recurring all day event is returned.""" + config = dict(CALDAV_CONFIG) + config["custom_calendars"] = [ + {"name": "Private", "calendar": "Private", "search": ".*"} + ] + + assert await async_setup_component(hass, "calendar", {"calendar": config}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private_private") + assert state.name == calendar.name + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is a recurring all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2016-12-01 00:00:00", + "end_time": "2016-12-02 00:00:00", + "location": "Hamburg", + "description": "Groundhog Day", + } + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)), +) +async def test_event_rrule_hourly_on_first(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is an hourly recurring event", + "all_day": False, + "offset_reached": False, + "start_time": "2015-11-27 00:00:00", + "end_time": "2015-11-27 00:30:00", + "location": "Hamburg", + "description": "The bell tolls for thee", + } + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)), +) +async def test_event_rrule_hourly_on_last(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": "Private", + "message": "This is an hourly recurring event", + "all_day": False, + "offset_reached": False, + "start_time": "2015-11-27 11:00:00", + "end_time": "2015-11-27 11:30:00", + "location": "Hamburg", + "description": "The bell tolls for thee", + } + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 45)), +) +async def test_event_rrule_hourly_off_first(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 45)), +) +async def test_event_rrule_hourly_off_last(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF + + +@patch( + "homeassistant.util.dt.now", + return_value=dt.as_local(datetime.datetime(2015, 11, 27, 12, 15)), +) +async def test_event_rrule_hourly_ended(mock_now, hass, calendar): + """Test that the endless recurring event is returned.""" + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() + + state = hass.states.get("calendar.private") + assert state.name == calendar.name + assert state.state == STATE_OFF