Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Data update coordinator for caldav."""
2 
3 from __future__ import annotations
4 
5 from datetime import date, datetime, time, timedelta
6 from functools import partial
7 import logging
8 import re
9 from typing import TYPE_CHECKING
10 
11 import caldav
12 
13 from homeassistant.components.calendar import CalendarEvent, extract_offset
14 from homeassistant.core import HomeAssistant
15 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
16 from homeassistant.util import dt as dt_util
17 
18 from .api import get_attr_value
19 
20 if TYPE_CHECKING:
21  from . import CalDavConfigEntry
22 
23 _LOGGER = logging.getLogger(__name__)
24 
25 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
26 OFFSET = "!!"
27 
28 
29 class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
30  """Class to utilize the calendar dav client object to get next event."""
31 
32  def __init__(
33  self,
34  hass: HomeAssistant,
35  entry: CalDavConfigEntry | None,
36  calendar: caldav.Calendar,
37  days: int,
38  include_all_day: bool,
39  search: str | None,
40  ) -> None:
41  """Set up how we are going to search the WebDav calendar."""
42  super().__init__(
43  hass,
44  _LOGGER,
45  config_entry=entry,
46  name=f"CalDAV {calendar.name}",
47  update_interval=MIN_TIME_BETWEEN_UPDATES,
48  )
49  self.calendarcalendar = calendar
50  self.daysdays = days
51  self.include_all_dayinclude_all_day = include_all_day
52  self.searchsearch = search
53  self.offsetoffset: timedelta | None = None
54 
55  async def async_get_events(
56  self, hass: HomeAssistant, start_date: datetime, end_date: datetime
57  ) -> list[CalendarEvent]:
58  """Get all events in a specific time frame."""
59  # Get event list from the current calendar
60  vevent_list = await hass.async_add_executor_job(
61  partial(
62  self.calendarcalendar.search,
63  start=start_date,
64  end=end_date,
65  event=True,
66  expand=True,
67  )
68  )
69  event_list = []
70  for event in vevent_list:
71  if not hasattr(event.instance, "vevent"):
72  _LOGGER.warning("Skipped event with missing 'vevent' property")
73  continue
74  vevent = event.instance.vevent
75  if not self.is_matchingis_matching(vevent, self.searchsearch):
76  continue
77  event_list.append(
79  summary=get_attr_value(vevent, "summary") or "",
80  start=self.to_localto_local(vevent.dtstart.value),
81  end=self.to_localto_local(self.get_end_dateget_end_date(vevent)),
82  location=get_attr_value(vevent, "location"),
83  description=get_attr_value(vevent, "description"),
84  )
85  )
86 
87  return event_list
88 
89  async def _async_update_data(self) -> CalendarEvent | None:
90  """Get the latest data."""
91  start_of_today = dt_util.start_of_local_day()
92  start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.daysdays)
93 
94  # We have to retrieve the results for the whole day as the server
95  # won't return events that have already started
96  results = await self.hasshass.async_add_executor_job(
97  partial(
98  self.calendarcalendar.search,
99  start=start_of_today,
100  end=start_of_tomorrow,
101  event=True,
102  expand=True,
103  ),
104  )
105 
106  # Create new events for each recurrence of an event that happens today.
107  # For recurring events, some servers return the original event with recurrence rules
108  # and they would not be properly parsed using their original start/end dates.
109  new_events = []
110  for event in results:
111  if not hasattr(event.instance, "vevent"):
112  _LOGGER.warning("Skipped event with missing 'vevent' property")
113  continue
114  vevent = event.instance.vevent
115  for start_dt in vevent.getrruleset() or []:
116  _start_of_today: date | datetime
117  _start_of_tomorrow: datetime | date
118  if self.is_all_dayis_all_day(vevent):
119  start_dt = start_dt.date()
120  _start_of_today = start_of_today.date()
121  _start_of_tomorrow = start_of_tomorrow.date()
122  else:
123  _start_of_today = start_of_today
124  _start_of_tomorrow = start_of_tomorrow
125  if _start_of_today <= start_dt < _start_of_tomorrow:
126  new_event = event.copy()
127  new_vevent = new_event.instance.vevent # type: ignore[attr-defined]
128  if hasattr(new_vevent, "dtend"):
129  dur = new_vevent.dtend.value - new_vevent.dtstart.value
130  new_vevent.dtend.value = start_dt + dur
131  new_vevent.dtstart.value = start_dt
132  new_events.append(new_event)
133  elif _start_of_tomorrow <= start_dt:
134  break
135  vevents = [
136  event.instance.vevent
137  for event in results + new_events
138  if hasattr(event.instance, "vevent")
139  ]
140 
141  # dtstart can be a date or datetime depending if the event lasts a
142  # whole day. Convert everything to datetime to be able to sort it
143  vevents.sort(key=lambda x: self.to_datetimeto_datetime(x.dtstart.value))
144 
145  vevent = next(
146  (
147  vevent
148  for vevent in vevents
149  if (
150  self.is_matchingis_matching(vevent, self.searchsearch)
151  and (not self.is_all_dayis_all_day(vevent) or self.include_all_dayinclude_all_day)
152  and not self.is_overis_over(vevent)
153  )
154  ),
155  None,
156  )
157 
158  # If no matching event could be found
159  if vevent is None:
160  _LOGGER.debug(
161  "No matching event found in the %d results for %s",
162  len(vevents),
163  self.calendarcalendar.name,
164  )
165  self.offsetoffset = None
166  return None
167 
168  # Populate the entity attributes with the event values
169  (summary, offset) = extract_offset(
170  get_attr_value(vevent, "summary") or "", OFFSET
171  )
172  self.offsetoffset = offset
173  return CalendarEvent(
174  summary=summary,
175  start=self.to_localto_local(vevent.dtstart.value),
176  end=self.to_localto_local(self.get_end_dateget_end_date(vevent)),
177  location=get_attr_value(vevent, "location"),
178  description=get_attr_value(vevent, "description"),
179  )
180 
181  @staticmethod
182  def is_matching(vevent, search):
183  """Return if the event matches the filter criteria."""
184  if search is None:
185  return True
186 
187  pattern = re.compile(search)
188  return (
189  hasattr(vevent, "summary")
190  and pattern.match(vevent.summary.value)
191  or hasattr(vevent, "location")
192  and pattern.match(vevent.location.value)
193  or hasattr(vevent, "description")
194  and pattern.match(vevent.description.value)
195  )
196 
197  @staticmethod
198  def is_all_day(vevent):
199  """Return if the event last the whole day."""
200  return not isinstance(vevent.dtstart.value, datetime)
201 
202  @staticmethod
203  def is_over(vevent):
204  """Return if the event is over."""
205  return dt_util.now() >= CalDavUpdateCoordinator.to_datetime(
206  CalDavUpdateCoordinator.get_end_date(vevent)
207  )
208 
209  @staticmethod
210  def to_datetime(obj):
211  """Return a datetime."""
212  if isinstance(obj, datetime):
213  return CalDavUpdateCoordinator.to_local(obj)
214  return datetime.combine(obj, time.min).replace(
215  tzinfo=dt_util.get_default_time_zone()
216  )
217 
218  @staticmethod
219  def to_local(obj: datetime | date) -> datetime | date:
220  """Return a datetime as a local datetime, leaving dates unchanged.
221 
222  This handles giving floating times a timezone for comparison
223  with all day events and dropping the custom timezone object
224  used by the caldav client and dateutil so the datetime can be copied.
225  """
226  if isinstance(obj, datetime):
227  return dt_util.as_local(obj)
228  return obj
229 
230  @staticmethod
231  def get_end_date(obj):
232  """Return the end datetime as determined by dtend or duration."""
233  if hasattr(obj, "dtend"):
234  enddate = obj.dtend.value
235  elif hasattr(obj, "duration"):
236  enddate = obj.dtstart.value + obj.duration.value
237  else:
238  enddate = obj.dtstart.value + timedelta(days=1)
239 
240  # End date for an all day event is exclusive. This fixes the case where
241  # an all day event has a start and end values are the same, or the event
242  # has a zero duration.
243  if not isinstance(enddate, datetime) and obj.dtstart.value == enddate:
244  enddate += timedelta(days=1)
245 
246  return enddate
list[CalendarEvent] async_get_events(self, HomeAssistant hass, datetime start_date, datetime end_date)
Definition: coordinator.py:57
None __init__(self, HomeAssistant hass, CalDavConfigEntry|None entry, caldav.Calendar calendar, int days, bool include_all_day, str|None search)
Definition: coordinator.py:40
str|None get_attr_value(caldav.CalendarObjectResource obj, str attribute)
Definition: api.py:23
tuple[str, datetime.timedelta] extract_offset(str summary, str offset_prefix)
Definition: __init__.py:454