From f36ea1b591e2142437969db3af191fcdec5ef3e3 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 14 Dec 2023 22:15:21 +0100 Subject: [PATCH] Added ics_calender custom component. --- .gitignore | 1 + configuration.yaml | 1 + custom_components/ics_calendar/__init__.py | 113 +++++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 4043 bytes .../__pycache__/calendar.cpython-311.pyc | Bin 0 -> 13304 bytes .../__pycache__/calendardata.cpython-311.pyc | Bin 0 -> 9412 bytes .../__pycache__/const.cpython-311.pyc | Bin 0 -> 365 bytes .../__pycache__/filter.cpython-311.pyc | Bin 0 -> 6112 bytes .../icalendarparser.cpython-311.pyc | Bin 0 -> 4568 bytes .../__pycache__/utility.cpython-311.pyc | Bin 0 -> 1657 bytes custom_components/ics_calendar/calendar.py | 291 ++++++++++++++++++ .../ics_calendar/calendardata.py | 198 ++++++++++++ custom_components/ics_calendar/const.py | 7 + custom_components/ics_calendar/filter.py | 124 ++++++++ .../ics_calendar/icalendarparser.py | 97 ++++++ custom_components/ics_calendar/manifest.json | 13 + .../ics_calendar/parsers/__init__.py | 1 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 197 bytes .../__pycache__/parser_rie.cpython-311.pyc | Bin 0 -> 9031 bytes .../ics_calendar/parsers/parser_ics.py | 190 ++++++++++++ .../ics_calendar/parsers/parser_rie.py | 198 ++++++++++++ custom_components/ics_calendar/utility.py | 37 +++ 22 files changed, 1271 insertions(+) create mode 100644 custom_components/ics_calendar/__init__.py create mode 100644 custom_components/ics_calendar/__pycache__/__init__.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/calendar.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/calendardata.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/const.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/filter.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/icalendarparser.cpython-311.pyc create mode 100644 custom_components/ics_calendar/__pycache__/utility.cpython-311.pyc create mode 100644 custom_components/ics_calendar/calendar.py create mode 100644 custom_components/ics_calendar/calendardata.py create mode 100644 custom_components/ics_calendar/const.py create mode 100644 custom_components/ics_calendar/filter.py create mode 100644 custom_components/ics_calendar/icalendarparser.py create mode 100644 custom_components/ics_calendar/manifest.json create mode 100644 custom_components/ics_calendar/parsers/__init__.py create mode 100644 custom_components/ics_calendar/parsers/__pycache__/__init__.cpython-311.pyc create mode 100644 custom_components/ics_calendar/parsers/__pycache__/parser_rie.cpython-311.pyc create mode 100644 custom_components/ics_calendar/parsers/parser_ics.py create mode 100644 custom_components/ics_calendar/parsers/parser_rie.py create mode 100644 custom_components/ics_calendar/utility.py diff --git a/.gitignore b/.gitignore index c90768b..d6578f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ automations_webhooks.yaml spotify.yaml calendars.yaml +ics_calendars.yaml esphome/common/secrets.yaml esphome/secrets.yaml secrets.yaml diff --git a/configuration.yaml b/configuration.yaml index f7b8c44..d88c389 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -42,6 +42,7 @@ template: !include template.yaml # calendar integration calendar: !include calendars.yaml +ics_calendar: !include ics_calendars.yaml # DB-recorder configuration recorder: !include recorder.yaml diff --git a/custom_components/ics_calendar/__init__.py b/custom_components/ics_calendar/__init__.py new file mode 100644 index 0000000..b34768a --- /dev/null +++ b/custom_components/ics_calendar/__init__.py @@ -0,0 +1,113 @@ +"""ics Calendar for Home Assistant.""" + +import logging + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.const import ( + CONF_EXCLUDE, + CONF_INCLUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_PREFIX, + CONF_URL, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, UPGRADE_URL + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.CALENDAR] + +CONF_DEVICE_ID = "device_id" +CONF_CALENDARS = "calendars" +CONF_DAYS = "days" +CONF_INCLUDE_ALL_DAY = "include_all_day" +CONF_PARSER = "parser" +CONF_DOWNLOAD_INTERVAL = "download_interval" +CONF_USER_AGENT = "user_agent" +CONF_OFFSET_HOURS = "offset_hours" +CONF_ACCEPT_HEADER = "accept_header" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + # pylint: disable=no-value-for-parameter + vol.Optional(CONF_CALENDARS, default=[]): vol.All( + cv.ensure_list, + vol.Schema( + [ + vol.Schema( + { + vol.Required(CONF_URL): vol.Url(), + vol.Required(CONF_NAME): cv.string, + vol.Optional( + CONF_INCLUDE_ALL_DAY, default=False + ): cv.boolean, + vol.Optional( + CONF_USERNAME, default="" + ): cv.string, + vol.Optional( + CONF_PASSWORD, default="" + ): cv.string, + vol.Optional( + CONF_PARSER, default="rie" + ): cv.string, + vol.Optional( + CONF_PREFIX, default="" + ): cv.string, + vol.Optional( + CONF_DAYS, default=1 + ): cv.positive_int, + vol.Optional( + CONF_DOWNLOAD_INTERVAL, default=15 + ): cv.positive_int, + vol.Optional( + CONF_USER_AGENT, default="" + ): cv.string, + vol.Optional( + CONF_EXCLUDE, default="" + ): cv.string, + vol.Optional( + CONF_INCLUDE, default="" + ): cv.string, + vol.Optional( + CONF_OFFSET_HOURS, default=0 + ): int, + vol.Optional( + CONF_ACCEPT_HEADER, default="" + ): cv.string, + } + ) + ] + ), + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up calendars.""" + _LOGGER.debug("Setting up ics_calendar component") + hass.data.setdefault(DOMAIN, {}) + + if DOMAIN in config and config[DOMAIN]: + hass.helpers.discovery.load_platform( + PLATFORMS[0], DOMAIN, config[DOMAIN], config + ) + else: + _LOGGER.error( + "No configuration found! If you upgraded from ics_calendar v3.2.0 " + "or older, you need to update your configuration! See " + "%s for more information.", + UPGRADE_URL, + ) + + return True diff --git a/custom_components/ics_calendar/__pycache__/__init__.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37a3e0169b5f39576a428e29ff8e1f49a1423042 GIT binary patch literal 4043 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 literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/__pycache__/calendardata.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/calendardata.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4eb2f051dc7e73b642b55ead846c6249c92336e6 GIT binary patch literal 9412 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 EzdA(bvH$=8 literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/__pycache__/const.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/const.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4a1dbefb113561d12184dbe5c2574479bf4f309 GIT binary patch literal 365 zcmZ3^%ge<81obUmsRx1dV-N=h7@>^MVnD`ph7^VpkS_E6|0G!p`O7l9%Sh%pNx`{ zf?_Lu{q)R|jM5~% zj(I6Qi6yB;dbufnnk={2jsx#1N#k5@dmaJER3vjAD9^!c^bGIxQjS|>Ht>b BZOH%t literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/__pycache__/filter.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/filter.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d597616a26508ed65a8eb1d5b76885454f0b796 GIT binary patch literal 6112 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 literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/__pycache__/icalendarparser.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/icalendarparser.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f036f7a9b03a5d9598237dcf2a09633c2973823c GIT binary patch literal 4568 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 mA-m8`{y$~k?Dg_UmOuv-#|9Jsd`sjaVZr_vu literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/__pycache__/utility.cpython-311.pyc b/custom_components/ics_calendar/__pycache__/utility.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c45d96a336226b9d9cf8995218123c64e0188e0e GIT binary patch literal 1657 zcmZ`(-D}%c6u5Ault7QPU?stldUl_Oky&3WAyo0Yllt-V9+cecHK_?Z#$1Nay>Wd+yOs_t$hf zfq;HEdZh302)(1mh}fZWaTk=ANJAy0F&bNA>THRvGbM&W8h3Xh1G;3z9eX| zZB!JSS9TnuW;h3OU3Z>Yn!WTlEmmX+uPKgB;*>i^U00dmCR7OUa`0aCfxSe|rI?GH zQ9BTO1N!OM#=BFQaF7vAX4>lx-D605bgz)0`lU5{W4(JRdz1^igL*nPCwJ(^#(_)9 ze*ej`YMB+|yJfXuJ6636Yxg0^bnInAwacnf z(@jmm%Z=!}E$ttWbX}?H~Dd!&|l(g`}WU{FD?e+;s7ziSIq0uAKah2 zeqk*rtVN!4=~^J&>~gPne|hq@uzFTl^$Yid!hK(Q5J(T+N*ia=hA(Xe(v}zB>LoL+ zM;A~Wkr>vU1~yfZT0^1-r|-o#aH1)6@Nd9(I>yT(KY<8lD#}bmg{i185oM>MtjoEv zYKT32-DO;UsK;E^6{;a};QLa4s9Qql4{=1l``)<6R@Oz`F@{Hnq;e81hX^VlmHnQr zhHIfB6KN)^vJ5&DU12KDPsHPH9P_S-Sr>lI58doT9q4k{8KCj=4c*Z(KuMR4iVSF2 zkrh*u0e08{6>w_mFLW$B&lHn}KSY;`T!;=5bDD-(u`s<;iZg^Wtrvu+l}IcqL*h!U z29P>fAsqOL5W0yt^pcGJ93Z(;RvQ?DLeil>u@FtUC!A0+dwjq_BlQ}9!Akdj-Q=jnycs-M0Uq;Ivh z`l8hS>`Ywn#D!i`YJYn2@c3bGF4x)oY0XQ0)RX4g>Pgd+?sY1^39l?Key=Ykj+&mB z>t$wtG*3)FlMgcaW3GSoM#uK9-ht;V z>F;!{lMh8tnm^9;le6uelP|qwv7>d>S5=QM;tZVjaWO{3a#^#~ayh&vq6>rmyo7&h zS+#IDzoRfY9O3);7j;W()burc6S64C>|J03j$xP{n)lFr58d|u>lws&MjD_o%jB3l QePL!GfE=LzVd2jI0<1NGOaK4? literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/calendar.py b/custom_components/ics_calendar/calendar.py new file mode 100644 index 0000000..8fa6b23 --- /dev/null +++ b/custom_components/ics_calendar/calendar.py @@ -0,0 +1,291 @@ +"""Support for ICS Calendar.""" +import logging +from datetime import datetime, timedelta +from typing import Optional + +# import homeassistant.helpers.config_validation as cv +# import voluptuous as vol +from homeassistant.components.calendar import ( + ENTITY_ID_FORMAT, + CalendarEntity, + CalendarEvent, + extract_offset, + is_offset_reached, +) +from homeassistant.const import ( + CONF_EXCLUDE, + CONF_INCLUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_PREFIX, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import Throttle +from homeassistant.util.dt import now as hanow + +from . import ( + CONF_ACCEPT_HEADER, + CONF_CALENDARS, + CONF_DAYS, + CONF_DOWNLOAD_INTERVAL, + CONF_INCLUDE_ALL_DAY, + CONF_OFFSET_HOURS, + CONF_PARSER, + CONF_USER_AGENT, +) +from .calendardata import CalendarData +from .filter import Filter +from .icalendarparser import ICalendarParser + +_LOGGER = logging.getLogger(__name__) + + +OFFSET = "!!" + + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): + """Set up ics_calendar platform. + + :param hass: Home Assistant object + :type hass: HomeAssistant + :param config: Config information for the platform + :type config: ConfigType + :param add_entities: Callback to add entities to HA + :type add_entities: AddEntitiesCallback + :param discovery_info: Config information for the platform + :type discovery_info: DiscoveryInfoType | None, optional + """ + _LOGGER.debug("Setting up ics calendars") + if discovery_info is not None: + calendars: list = discovery_info.get(CONF_CALENDARS) + else: + calendars: list = config.get(CONF_CALENDARS) + + calendar_devices = [] + for calendar in calendars: + device_data = { + CONF_NAME: calendar.get(CONF_NAME), + CONF_URL: calendar.get(CONF_URL), + CONF_INCLUDE_ALL_DAY: calendar.get(CONF_INCLUDE_ALL_DAY), + CONF_USERNAME: calendar.get(CONF_USERNAME), + CONF_PASSWORD: calendar.get(CONF_PASSWORD), + CONF_PARSER: calendar.get(CONF_PARSER), + CONF_PREFIX: calendar.get(CONF_PREFIX), + CONF_DAYS: calendar.get(CONF_DAYS), + CONF_DOWNLOAD_INTERVAL: calendar.get(CONF_DOWNLOAD_INTERVAL), + CONF_USER_AGENT: calendar.get(CONF_USER_AGENT), + CONF_EXCLUDE: calendar.get(CONF_EXCLUDE), + CONF_INCLUDE: calendar.get(CONF_INCLUDE), + CONF_OFFSET_HOURS: calendar.get(CONF_OFFSET_HOURS), + CONF_ACCEPT_HEADER: calendar.get(CONF_ACCEPT_HEADER), + } + device_id = f"{device_data[CONF_NAME]}" + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) + calendar_devices.append(ICSCalendarEntity(entity_id, device_data)) + + add_entities(calendar_devices) + + +class ICSCalendarEntity(CalendarEntity): + """A CalendarEntity for an ICS Calendar.""" + + def __init__(self, entity_id: str, device_data): + """Construct ICSCalendarEntity. + + :param entity_id: Entity id for the calendar + :type entity_id: str + :param device_data: dict describing the calendar + :type device_data: dict + """ + _LOGGER.debug( + "Initializing calendar: %s with URL: %s", + device_data[CONF_NAME], + device_data[CONF_URL], + ) + self.data = ICSCalendarData(device_data) + self.entity_id = entity_id + self._event = None + self._name = device_data[CONF_NAME] + self._last_call = None + + @property + def event(self) -> Optional[CalendarEvent]: + """Return the current or next upcoming event or None. + + :return: The current event as a dict + :rtype: dict + """ + return self._event + + @property + def name(self): + """Return the name of the calendar.""" + return self._name + + @property + def should_poll(self): + """Indicate if the calendar should be polled. + + If the last call to update or get_api_events was not within the minimum + update time, then async_schedule_update_ha_state(True) is also called. + :return: True + :rtype: boolean + """ + this_call = hanow() + if ( + self._last_call is None + or (this_call - self._last_call) > MIN_TIME_BETWEEN_UPDATES + ): + self._last_call = this_call + self.async_schedule_update_ha_state(True) + return True + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame. + + :param hass: Home Assistant object + :type hass: HomeAssistant + :param start_date: The first starting date to consider + :type start_date: datetime + :param end_date: The last starting date to consider + :type end_date: datetime + """ + _LOGGER.debug( + "%s: async_get_events called; calling internal.", self.name + ) + return await self.data.async_get_events(hass, start_date, end_date) + + def update(self): + """Get the current or next event.""" + self.data.update() + self._event = self.data.event + self._attr_extra_state_attributes = { + "offset_reached": is_offset_reached( + self._event.start_datetime_local, self.data.offset + ) + if self._event + else False + } + + +class ICSCalendarData: # pylint: disable=R0902 + """Class to use the calendar ICS client object to get next event.""" + + def __init__(self, device_data): + """Set up how we are going to connect to the URL. + + :param device_data Information about the calendar + """ + self.name = device_data[CONF_NAME] + self._days = device_data[CONF_DAYS] + self._offset_hours = device_data[CONF_OFFSET_HOURS] + self.include_all_day = device_data[CONF_INCLUDE_ALL_DAY] + self._summary_prefix: str = device_data[CONF_PREFIX] + self.parser = ICalendarParser.get_instance(device_data[CONF_PARSER]) + self.parser.set_filter( + Filter(device_data[CONF_EXCLUDE], device_data[CONF_INCLUDE]) + ) + self.offset = None + self.event = None + + self._calendar_data = CalendarData( + _LOGGER, + self.name, + device_data[CONF_URL], + timedelta(minutes=device_data[CONF_DOWNLOAD_INTERVAL]), + ) + + self._calendar_data.set_headers( + device_data[CONF_USERNAME], + device_data[CONF_PASSWORD], + device_data[CONF_USER_AGENT], + device_data[CONF_ACCEPT_HEADER], + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame. + + :param hass: Home Assistant object + :type hass: HomeAssistant + :param start_date: The first starting date to consider + :type start_date: datetime + :param end_date: The last starting date to consider + :type end_date: datetime + """ + event_list = [] + if await hass.async_add_executor_job( + self._calendar_data.download_calendar + ): + _LOGGER.debug("%s: Setting calendar content", self.name) + self.parser.set_content(self._calendar_data.get()) + try: + event_list = self.parser.get_event_list( + start=start_date, + end=end_date, + include_all_day=self.include_all_day, + offset_hours=self._offset_hours, + ) + except: # pylint: disable=W0702 + _LOGGER.error( + "async_get_events: %s: Failed to parse ICS!", + self.name, + exc_info=True, + ) + event_list = [] + + for event in event_list: + event.summary = self._summary_prefix + event.summary + + return event_list + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the current or next event.""" + _LOGGER.debug("%s: Update was called", self.name) + if self._calendar_data.download_calendar(): + _LOGGER.debug("%s: Setting calendar content", self.name) + self.parser.set_content(self._calendar_data.get()) + try: + self.event = self.parser.get_current_event( + include_all_day=self.include_all_day, + now=hanow(), + days=self._days, + offset_hours=self._offset_hours, + ) + except: # pylint: disable=W0702 + _LOGGER.error( + "update: %s: Failed to parse ICS!", self.name, exc_info=True + ) + if self.event is not None: + _LOGGER.debug( + "%s: got event: %s; start: %s; end: %s; all_day: %s", + self.name, + self.event.summary, + self.event.start, + self.event.end, + self.event.all_day, + ) + (summary, offset) = extract_offset(self.event.summary, OFFSET) + self.event.summary = self._summary_prefix + summary + self.offset = offset + return True + + _LOGGER.debug("%s: No event found!", self.name) + return False diff --git a/custom_components/ics_calendar/calendardata.py b/custom_components/ics_calendar/calendardata.py new file mode 100644 index 0000000..2d870ea --- /dev/null +++ b/custom_components/ics_calendar/calendardata.py @@ -0,0 +1,198 @@ +"""Provide CalendarData class.""" +import zlib +from datetime import timedelta +from gzip import BadGzipFile, GzipFile +from logging import Logger +from threading import Lock +from urllib.error import ContentTooShortError, HTTPError, URLError +from urllib.request import ( + HTTPBasicAuthHandler, + HTTPDigestAuthHandler, + HTTPPasswordMgrWithDefaultRealm, + build_opener, + install_opener, + urlopen, +) + +from homeassistant.util.dt import now as hanow + + +class CalendarData: + """CalendarData class. + + The CalendarData class is used to download and cache calendar data from a + given URL. Use the get method to retrieve the data after constructing your + instance. + """ + + opener_lock = Lock() + + def __init__( + self, logger: Logger, name: str, url: str, min_update_time: timedelta + ): + """Construct CalendarData object. + + :param logger: The logger for reporting problems + :type logger: Logger + :param name: The name of the calendar (used for reporting problems) + :type name: str + :param url: The URL of the calendar + :type url: str + :param min_update_time: The minimum time between downloading data from + the URL when requested + :type min_update_time: timedelta + """ + self._calendar_data = None + self._last_download = None + self._min_update_time = min_update_time + self._opener = None + self.logger = logger + self.name = name + self.url = url + + def download_calendar(self) -> bool: + """Download the calendar data. + + This only downloads data if self.min_update_time has passed since the + last download. + + returns: True if data was downloaded, otherwise False. + rtype: bool + """ + now = hanow() + if ( + self._calendar_data is None + or self._last_download is None + or (now - self._last_download) > self._min_update_time + ): + self._last_download = now + self._calendar_data = None + self.logger.debug( + "%s: Downloading calendar data from: %s", self.name, self.url + ) + self._download_data() + return self._calendar_data is not None + + return False + + def get(self) -> str: + """Get the calendar data that was downloaded. + + :return: The downloaded calendar data. + :rtype: str + """ + return self._calendar_data + + def set_headers( + self, + user_name: str, + password: str, + user_agent: str, + accept_header: str, + ): + """Set a user agent, accept header, and/or user name and password. + + The user name and password will be set into an HTTPBasicAuthHandler an + an HTTPDigestAuthHandler. Both are attached to a new urlopener, so + that HTTP Basic Auth and HTTP Digest Auth will be supported when + opening the URL. + + If the user_agent parameter is not "", a User-agent header will be + added to the urlopener. + + :param user_name: The user name + :type user_name: str + :param password: The password + :type password: str + :param user_agent: The User Agent string to use or "" + :type user_agent: str + :param accept_header: The accept header string to use or "" + :type accept_header: str + """ + if user_name != "" and password != "": + passman = HTTPPasswordMgrWithDefaultRealm() + passman.add_password(None, self.url, user_name, password) + basic_auth_handler = HTTPBasicAuthHandler(passman) + digest_auth_handler = HTTPDigestAuthHandler(passman) + self._opener = build_opener( + digest_auth_handler, basic_auth_handler + ) + + additional_headers = [] + if user_agent != "": + additional_headers.append(("User-agent", user_agent)) + if accept_header != "": + additional_headers.append(("Accept", accept_header)) + if len(additional_headers) > 0: + if self._opener is None: + self._opener = build_opener() + self._opener.addheaders = additional_headers + + def _decode_data(self, conn): + if ( + "Content-Encoding" in conn.headers + and conn.headers["Content-Encoding"] == "gzip" + ): + reader = GzipFile(fileobj=conn) + else: + reader = conn + try: + return self._decode_stream(reader.read()).replace("\0", "") + except zlib.error: + self.logger.error( + "%s: Failed to uncompress gzip data from url(%s): zlib", + self.name, + self.url, + ) + except BadGzipFile as gzip_error: + self.logger.error( + "%s: Failed to uncompress gzip data from url(%s): %s", + self.name, + self.url, + gzip_error.strerror, + ) + return None + + def _decode_stream(self, strm): + for encoding in "utf-8-sig", "utf-8", "utf-16": + try: + return strm.decode(encoding) + except UnicodeDecodeError: + continue + return None + + def _download_data(self): + """Download the calendar data.""" + try: + with CalendarData.opener_lock: + if self._opener is not None: + install_opener(self._opener) + with urlopen(self._make_url()) as conn: + self._calendar_data = self._decode_data(conn) + except HTTPError as http_error: + self.logger.error( + "%s: Failed to open url(%s): %s", + self.name, + self.url, + http_error.reason, + ) + except ContentTooShortError as content_too_short_error: + self.logger.error( + "%s: Could not download calendar data: %s", + self.name, + content_too_short_error.reason, + ) + except URLError as url_error: + self.logger.error( + "%s: Failed to open url: %s", self.name, url_error.reason + ) + except: # pylint: disable=W0702 + self.logger.error( + "%s: Failed to open url!", self.name, exc_info=True + ) + + def _make_url(self): + now = hanow() + return self.url.replace("{year}", f"{now.year:04}").replace( + "{month}", f"{now.month:02}" + ) diff --git a/custom_components/ics_calendar/const.py b/custom_components/ics_calendar/const.py new file mode 100644 index 0000000..524b424 --- /dev/null +++ b/custom_components/ics_calendar/const.py @@ -0,0 +1,7 @@ +"""Constants for ics_calendar platform.""" +VERSION = "4.1.0" +DOMAIN = "ics_calendar" +UPGRADE_URL = ( + "https://github.com/franc6/ics_calendar/blob/releases/" + "UpgradeTo4.0AndLater.md" +) diff --git a/custom_components/ics_calendar/filter.py b/custom_components/ics_calendar/filter.py new file mode 100644 index 0000000..4787ded --- /dev/null +++ b/custom_components/ics_calendar/filter.py @@ -0,0 +1,124 @@ +"""Provide Filter class.""" +import re +from ast import literal_eval +from typing import List, Optional, Pattern + +from homeassistant.components.calendar import CalendarEvent + + +class Filter: + """Filter class. + + The Filter class is used to filter events according to the exclude and + include rules. + """ + + def __init__(self, exclude: str, include: str): + """Construct Filter class. + + :param exclude: The exclude rules + :type exclude: str + :param include: The include rules + :type include: str + """ + self._exclude = Filter.set_rules(exclude) + self._include = Filter.set_rules(include) + + @staticmethod + def set_rules(rules: str) -> List[Pattern]: + """Set the given rules into an array which is returned. + + :param rules: The rules to set + :type rules: str + :return: An array of regular expressions + :rtype: List[Pattern] + """ + arr = [] + if rules != "": + for rule in literal_eval(rules): + if rule.startswith("/"): + re_flags = re.NOFLAG + [expr, flags] = rule[1:].split("/") + for flag in flags: + match flag: + case "i": + re_flags |= re.IGNORECASE + case "m": + re_flags |= re.MULTILINE + case "s": + re_flags |= re.DOTALL + arr.append(re.compile(expr, re_flags)) + else: + arr.append(re.compile(rule, re.IGNORECASE)) + return arr + + def _is_match( + self, summary: str, description: Optional[str], regexes: List[Pattern] + ) -> bool: + """Indicate if the event matches the given list of regular expressions. + + :param summary: The event summary to examine + :type summary: str + :param description: The event description summary to examine + :type description: Optional[str] + :param regexes: The regular expressions to match against + :type regexes: List[] + :return: True if the event matches the exclude filter + :rtype: bool + """ + for regex in regexes: + if regex.search(summary) or ( + description and regex.search(description) + ): + return True + + return False + + def _is_excluded(self, summary: str, description: Optional[str]) -> bool: + """Indicate if the event should be excluded. + + :param summary: The event summary to examine + :type summary: str + :param description: The event description summary to examine + :type description: Optional[str] + :return: True if the event matches the exclude filter + :rtype: bool + """ + return self._is_match(summary, description, self._exclude) + + def _is_included(self, summary: str, description: Optional[str]) -> bool: + """Indicate if the event should be included. + + :param summary: The event summary to examine + :type summary: str + :param description: The event description summary to examine + :type description: Optional[str] + :return: True if the event matches the include filter + :rtype: bool + """ + return self._is_match(summary, description, self._include) + + def filter(self, summary: str, description: Optional[str]) -> bool: + """Check if the event should be included or not. + + :param summary: The event summary to examine + :type summary: str + :param description: The event description summary to examine + :type description: Optional[str] + :return: true if the event should be included, otherwise false + :rtype: bool + """ + add_event = not self._is_excluded(summary, description) + if not add_event: + add_event = self._is_included(summary, description) + return add_event + + def filter_event(self, event: CalendarEvent) -> bool: + """Check if the event should be included or not. + + :param event: The event to examine + :type event: CalendarEvent + :return: true if the event should be included, otherwise false + :rtype: bool + """ + return self.filter(event.summary, event.description) diff --git a/custom_components/ics_calendar/icalendarparser.py b/custom_components/ics_calendar/icalendarparser.py new file mode 100644 index 0000000..dc9e1d0 --- /dev/null +++ b/custom_components/ics_calendar/icalendarparser.py @@ -0,0 +1,97 @@ +"""Provide ICalendarParser class.""" +import importlib +from datetime import datetime +from typing import Optional + +from homeassistant.components.calendar import CalendarEvent + +from .filter import Filter + + +class ICalendarParser: + """Provide interface for various parser classes. + + The class provides a static method , get_instace, to get a parser instance. + The non static methods allow this class to act as an "interface" for the + parser classes. + """ + + @staticmethod + def get_class(parser: str): + """Get the class of the requested parser.""" + parser_module_name = ".parsers.parser_" + parser + parser = "Parser" + parser.upper() + try: + module = importlib.import_module(parser_module_name, __package__) + return getattr(module, parser) + except ImportError: + return None + + @staticmethod + def get_instance(parser: str, *args): + """Get an instance of the requested parser.""" + parser_cls = ICalendarParser.get_class(parser) + if parser_cls is not None: + return parser_cls(*args) + return None + + def set_content(self, content: str): + """Parse content into a calendar object. + + This must be called at least once before get_event_list or + get_current_event. + :param content is the calendar data + :type content str + """ + + def set_filter(self, filt: Filter): + """Set a Filter object to filter events. + + :param filt: The Filter object + :type exclude: Filter + """ + + def get_event_list( + self, + start: datetime, + end: datetime, + include_all_day: bool, + offset_hours: int = 0, + ) -> list[CalendarEvent]: + """Get a list of events. + + Gets the events from start to end, including or excluding all day + events. + :param start the earliest start time of events to return + :type start datetime + :param end the latest start time of events to return + :type end datetime + :param include_all_day if true, all day events will be included. + :type include_all_day boolean + :param offset_hours the number of hours to offset the event + :type offset_hours int + :returns a list of events, or an empty list + :rtype list[CalendarEvent] + """ + + def get_current_event( + self, + include_all_day: bool, + now: datetime, + days: int, + offset_hours: int = 0, + ) -> Optional[CalendarEvent]: + """Get the current or next event. + + Gets the current event, or the next upcoming event with in the + specified number of days, if there is no current event. + :param include_all_day if true, all day events will be included. + :type include_all_day boolean + :param now the current date and time + :type now datetime + :param days the number of days to check for an upcoming event + :type days int + :param offset_hours the number of hours to offset the event + :type offset_hours int + :returns a CalendarEvent or None + """ diff --git a/custom_components/ics_calendar/manifest.json b/custom_components/ics_calendar/manifest.json new file mode 100644 index 0000000..c52135c --- /dev/null +++ b/custom_components/ics_calendar/manifest.json @@ -0,0 +1,13 @@ +{ + + "domain": "ics_calendar", + "name": "ics Calendar", + "codeowners": ["@franc6"], + "dependencies": [], + "documentation": "https://github.com/franc6/ics_calendar", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/franc6/ics_calendar/issues", + "requirements": ["ics>=0.7.2", "recurring_ical_events>=2.0.2", "icalendar>=5.0.4"], + "version": "4.1.0" +} diff --git a/custom_components/ics_calendar/parsers/__init__.py b/custom_components/ics_calendar/parsers/__init__.py new file mode 100644 index 0000000..5fd5428 --- /dev/null +++ b/custom_components/ics_calendar/parsers/__init__.py @@ -0,0 +1 @@ +"""Provide parsers.""" diff --git a/custom_components/ics_calendar/parsers/__pycache__/__init__.cpython-311.pyc b/custom_components/ics_calendar/parsers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9032b1362e2030580bcea5d94e3ac6a44ea9178 GIT binary patch literal 197 zcmZ3^%ge<81obUmsp3HTF^B^Lj8MjBkdo;PDGX5zDU87knoLy!0Y&*`nJK9X1&Kw) zsYS(lewvK8*yH0<@{{A^S2BDCY5ZlSpPZkUmYJ@fTv}X`pBtZ?pIeZhmzq~nte=@& z9G{$+lbV;3Sfmd%Lq9$~GcU6wK3=b&@)w6qZhlH>PO4oI8_+0_Ly84~#0O?ZM#dWq MVi#bjhy^GL0Mo-Y1poj5 literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/parsers/__pycache__/parser_rie.cpython-311.pyc b/custom_components/ics_calendar/parsers/__pycache__/parser_rie.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79ed6941c2e95405243a6a5f9550886187b7cf6d GIT binary patch literal 9031 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 QTJ%4oCExE*P8adN0lZv|H2?qr literal 0 HcmV?d00001 diff --git a/custom_components/ics_calendar/parsers/parser_ics.py b/custom_components/ics_calendar/parsers/parser_ics.py new file mode 100644 index 0000000..fc1b5e9 --- /dev/null +++ b/custom_components/ics_calendar/parsers/parser_ics.py @@ -0,0 +1,190 @@ +"""Support for ics parser.""" +import re +from datetime import date, datetime, timedelta +from typing import Optional, Union + +from arrow import Arrow, get as arrowget +from homeassistant.components.calendar import CalendarEvent +from ics import Calendar + +from ..filter import Filter +from ..icalendarparser import ICalendarParser +from ..utility import compare_event_dates + + +class ParserICS(ICalendarParser): + """Class to provide parser using ics module.""" + + def __init__(self): + """Construct ParserICS.""" + self._re_method = re.compile("^METHOD:.*$", flags=re.MULTILINE) + self._calendar = None + self._filter = Filter("", "") + + def set_content(self, content: str): + """Parse content into a calendar object. + + This must be called at least once before get_event_list or + get_current_event. + :param content is the calendar data + :type content str + """ + self._calendar = Calendar(re.sub(self._re_method, "", content)) + + def set_filter(self, filt: Filter): + """Set a Filter object to filter events. + + :param filt: The Filter object + :type exclude: Filter + """ + self._filter = filt + + def get_event_list( + self, start, end, include_all_day: bool, offset_hours: int = 0 + ) -> list[CalendarEvent]: + """Get a list of events. + + Gets the events from start to end, including or excluding all day + events. + :param start the earliest start time of events to return + :type datetime + :param end the latest start time of events to return + :type datetime + :param include_all_day if true, all day events will be included. + :type boolean + :param offset_hours the number of hours to offset the event + :type offset_hours int + :returns a list of events, or an empty list + :rtype list[CalendarEvent] + """ + event_list: list[CalendarEvent] = [] + + if self._calendar is not None: + # ics 0.8 takes datetime not Arrow objects + # ar_start = start + # ar_end = end + ar_start = arrowget(start - timedelta(hours=offset_hours)) + ar_end = arrowget(end - timedelta(hours=offset_hours)) + + for event in self._calendar.timeline.included(ar_start, ar_end): + if event.all_day and not include_all_day: + continue + summary: str = "" + # ics 0.8 uses 'summary' reliably, older versions use 'name' + # if hasattr(event, "summary"): + # summary = event.summary + # elif hasattr(event, "name"): + summary = event.name + calendar_event: CalendarEvent = CalendarEvent( + summary=summary, + start=ParserICS.get_date( + event.begin, event.all_day, offset_hours + ), + end=ParserICS.get_date( + event.end, event.all_day, offset_hours + ), + location=event.location, + description=event.description, + ) + if self._filter.filter_event(calendar_event): + event_list.append(calendar_event) + + return event_list + + def get_current_event( # noqa: $701 + self, + include_all_day: bool, + now: datetime, + days: int, + offset_hours: int = 0, + ) -> Optional[CalendarEvent]: + """Get the current or next event. + + Gets the current event, or the next upcoming event with in the + specified number of days, if there is no current event. + :param include_all_day if true, all day events will be included. + :type boolean + :param now the current date and time + :type datetime + :param days the number of days to check for an upcoming event + :type int + :param offset_hours the number of hours to offset the event + :type int + :returns a CalendarEvent or None + """ + if self._calendar is None: + return None + + temp_event = None + now = now - timedelta(offset_hours) + end = now + timedelta(days=days) + for event in self._calendar.timeline.included( + arrowget(now), arrowget(end) + ): + if event.all_day and not include_all_day: + continue + + if not self._filter.filter(event.name, event.description): + continue + + if temp_event is None or compare_event_dates( + now, + temp_event.end, + temp_event.begin, + temp_event.all_day, + event.end, + event.begin, + event.all_day, + ): + temp_event = event + + if temp_event is None: + return None + # if hasattr(event, "summary"): + # summary = temp_event.summary + # elif hasattr(event, "name"): + summary = temp_event.name + return CalendarEvent( + summary=summary, + start=ParserICS.get_date( + temp_event.begin, temp_event.all_day, offset_hours + ), + end=ParserICS.get_date( + temp_event.end, temp_event.all_day, offset_hours + ), + location=temp_event.location, + description=temp_event.description, + ) + + @staticmethod + def get_date( + arw: Arrow, is_all_day: bool, offset_hours: int + ) -> Union[datetime, date]: + """Get datetime. + + :param arw The arrow object representing the date. + :type Arrow + :param is_all_day If true, the returned datetime will have the time + component set to 0. + :type: bool + :param offset_hours the number of hours to offset the event + :type int + :returns The datetime. + :rtype datetime + """ + # if isinstance(arw, Arrow): + if is_all_day: + return arw.date() + # else: + # if arw.tzinfo is None or arw.tzinfo.utcoffset(arw) is None + # or is_all_day: + # arw = arw.astimezone() + # if is_all_day: + # return arw.date() + # + arw = arw.shift(hours=offset_hours) + + return_value = arw.datetime + if return_value.tzinfo is None: + return_value = return_value.astimezone() + return return_value diff --git a/custom_components/ics_calendar/parsers/parser_rie.py b/custom_components/ics_calendar/parsers/parser_rie.py new file mode 100644 index 0000000..5d71f7a --- /dev/null +++ b/custom_components/ics_calendar/parsers/parser_rie.py @@ -0,0 +1,198 @@ +"""Support for recurring_ical_events parser.""" +from datetime import date, datetime, timedelta +from typing import Optional, Union + +import recurring_ical_events as rie +from homeassistant.components.calendar import CalendarEvent +from icalendar import Calendar + +from ..filter import Filter +from ..icalendarparser import ICalendarParser +from ..utility import compare_event_dates + + +class ParserRIE(ICalendarParser): + """Provide parser using recurring_ical_events.""" + + def __init__(self): + """Construct ParserRIE.""" + self._calendar = None + self.oneday = timedelta(days=1) + self.oneday2 = timedelta(hours=23, minutes=59, seconds=59) + self._filter = Filter("", "") + + def set_content(self, content: str): + """Parse content into a calendar object. + + This must be called at least once before get_event_list or + get_current_event. + :param content is the calendar data + :type content str + """ + self._calendar = Calendar.from_ical(content) + + def set_filter(self, filt: Filter): + """Set a Filter object to filter events. + + :param filt: The Filter object + :type exclude: Filter + """ + self._filter = filt + + def get_event_list( + self, + start: datetime, + end: datetime, + include_all_day: bool, + offset_hours: int = 0, + ) -> list[CalendarEvent]: + """Get a list of events. + + Gets the events from start to end, including or excluding all day + events. + :param start the earliest start time of events to return + :type datetime + :param end the latest start time of events to return + :type datetime + :param include_all_day if true, all day events will be included. + :type boolean + :param offset_hours the number of hours to offset the event + :type offset_hours int + :returns a list of events, or an empty list + :rtype list[CalendarEvent] + """ + event_list: list[CalendarEvent] = [] + + if self._calendar is not None: + for event in rie.of(self._calendar).between( + start - timedelta(hours=offset_hours), + end - timedelta(hours=offset_hours), + ): + start, end, all_day = self.is_all_day(event, offset_hours) + + if all_day and not include_all_day: + continue + + calendar_event: CalendarEvent = CalendarEvent( + summary=event.get("SUMMARY"), + start=start, + end=end, + location=event.get("LOCATION"), + description=event.get("DESCRIPTION"), + ) + if self._filter.filter_event(calendar_event): + event_list.append(calendar_event) + + return event_list + + def get_current_event( # noqa: R701 + self, + include_all_day: bool, + now: datetime, + days: int, + offset_hours: int = 0, + ) -> Optional[CalendarEvent]: + """Get the current or next event. + + Gets the current event, or the next upcoming event with in the + specified number of days, if there is no current event. + :param include_all_day if true, all day events will be included. + :type boolean + :param now the current date and time + :type datetime + :param days the number of days to check for an upcoming event + :type int + :param offset_hours the number of hours to offset the event + :type offset_hours int + :returns a CalendarEvent or None + """ + if self._calendar is None: + return None + + temp_event: CalendarEvent = None + temp_start: date | datetime = None + temp_end: date | datetime = None + temp_all_day: bool = None + end: datetime = now + timedelta(days=days) + for event in rie.of(self._calendar).between( + now - timedelta(hours=offset_hours), + end - timedelta(hours=offset_hours), + ): + start, end, all_day = self.is_all_day(event, offset_hours) + + if all_day and not include_all_day: + continue + + if not self._filter.filter( + event.get("SUMMARY"), event.get("DESCRIPTION") + ): + continue + + if temp_start is None or compare_event_dates( + now, temp_end, temp_start, temp_all_day, end, start, all_day + ): + temp_event = event + temp_start = start + temp_end = end + temp_all_day = all_day + + if temp_event is None: + return None + + return CalendarEvent( + summary=temp_event.get("SUMMARY"), + start=temp_start, + end=temp_end, + location=temp_event.get("LOCATION"), + description=temp_event.get("DESCRIPTION"), + ) + + @staticmethod + def get_date(date_time) -> Union[datetime, date]: + """Get datetime with timezone information. + + If a date object is passed, it will first have a time component added, + set to 0. + :param date_time The date or datetime object + :type date_time datetime or date + :type: bool + :returns The datetime. + :rtype datetime + """ + # Must use type here, since a datetime is also a date! + if isinstance(date_time, date) and not isinstance(date_time, datetime): + date_time = datetime.combine(date_time, datetime.min.time()) + return date_time.astimezone() + + def is_all_day(self, event, offset_hours: int): + """Determine if the event is an all day event. + + Return all day status and start and end times for the event. + :param event The event to examine + :param offset_hours the number of hours to offset the event + :type offset_hours int + """ + start: datetime | date = ParserRIE.get_date(event.get("DTSTART").dt) + end: datetime | date = ParserRIE.get_date(event.get("DTEND").dt) + all_day = False + diff = event.get("DURATION") + if diff is not None: + diff = diff.dt + else: + diff = end - start + if (start == end or diff in {self.oneday, self.oneday2}) and all( + x == 0 for x in [start.hour, start.minute, start.second] + ): + # if all_day, start and end must be date, not datetime! + start = start.date() + end = end.date() + all_day = True + else: + start = start + timedelta(hours=offset_hours) + end = end + timedelta(hours=offset_hours) + if start.tzinfo is None: + start = start.astimezone() + if end.tzinfo is None: + end = end.astimezone() + + return start, end, all_day diff --git a/custom_components/ics_calendar/utility.py b/custom_components/ics_calendar/utility.py new file mode 100644 index 0000000..821e454 --- /dev/null +++ b/custom_components/ics_calendar/utility.py @@ -0,0 +1,37 @@ +"""Utility methods.""" +from datetime import date, datetime + + +def make_datetime(val): + """Ensure val is a datetime, not a date.""" + if isinstance(val, date) and not isinstance(val, datetime): + return datetime.combine(val, datetime.min.time()).astimezone() + return val + + +def compare_event_dates( # pylint: disable=R0913 + now, end2, start2, all_day2, end, start, all_day +) -> bool: + """Determine if end2 and start2 are newer than end and start.""" + # Make sure we only compare datetime values, not dates with datetimes. + # Set each date object to a datetime at midnight. + end = make_datetime(end) + end2 = make_datetime(end2) + start = make_datetime(start) + start2 = make_datetime(start2) + + if all_day2 == all_day: + if end2 == end: + return start2 > start + return end2 > end and start2 >= start + + if now.tzinfo is None: + now = now.astimezone() + + event2_current = start2 <= now <= end2 + event_current = start <= now <= end + + if event_current and event2_current: + return all_day + + return start2 >= start or end2 >= end