382 lines
13 KiB
Python

"""Support for ICS Calendar."""
import logging
from datetime import datetime, timedelta
from typing import Any, 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.config_entries import ConfigEntry
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.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import now as hanow
from .calendardata import CalendarData
from .const import (
CONF_ACCEPT_HEADER,
CONF_CALENDARS,
CONF_CONNECTION_TIMEOUT,
CONF_DAYS,
CONF_DOWNLOAD_INTERVAL,
CONF_INCLUDE_ALL_DAY,
CONF_OFFSET_HOURS,
CONF_PARSER,
CONF_SET_TIMEOUT,
CONF_SUMMARY_DEFAULT,
CONF_USER_AGENT,
DOMAIN,
)
from .filter import Filter
from .getparser import GetParser
from .parserevent import ParserEvent
_LOGGER = logging.getLogger(__name__)
OFFSET = "!!"
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the calendar in background."""
hass.async_create_task(
_async_setup_entry_bg_task(hass, config_entry, async_add_entities)
)
async def _async_setup_entry_bg_task(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the calendar."""
data = hass.data[DOMAIN][config_entry.entry_id]
device_id = f"{data[CONF_NAME]}"
entity = ICSCalendarEntity(
hass,
generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass),
hass.data[DOMAIN][config_entry.entry_id],
config_entry.entry_id,
)
async_add_entities([entity])
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:
_LOGGER.debug(
"setup_platform: ignoring discovery_info, already imported!"
)
# calendars: list = discovery_info.get(CONF_CALENDARS)
calendars = []
else:
_LOGGER.debug("setup_platform: discovery_info is None")
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),
CONF_CONNECTION_TIMEOUT: calendar.get(CONF_CONNECTION_TIMEOUT),
}
device_id = f"{device_data[CONF_NAME]}"
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append(
ICSCalendarEntity(hass, entity_id, device_data)
)
add_entities(calendar_devices)
class ICSCalendarEntity(CalendarEntity):
"""A CalendarEntity for an ICS Calendar."""
def __init__(
self,
hass: HomeAssistant,
entity_id: str,
device_data,
unique_id: str = None,
):
"""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, uniqueid: %s",
device_data[CONF_NAME],
device_data[CONF_URL],
unique_id,
)
self.data = ICSCalendarData(hass, device_data)
self.entity_id = entity_id
self._attr_unique_id = f"ICSCalendar.{unique_id}"
self._event = None
self._attr_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 should_poll(self) -> bool:
"""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(start_date, end_date)
async def async_update(self):
"""Get the current or next event."""
await self.data.async_update()
self._event: CalendarEvent | None = 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
)
}
async def async_create_event(self, **kwargs: Any):
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
async def async_update_event(
self,
uid: str,
event: dict[str, Any],
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Raise error, this is a read-only calendar."""
raise NotImplementedError()
class ICSCalendarData: # pylint: disable=R0902
"""Class to use the calendar ICS client object to get next event."""
def __init__(self, hass: HomeAssistant, 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._summary_default: str = device_data[CONF_SUMMARY_DEFAULT]
self.parser = GetParser.get_parser(device_data[CONF_PARSER])
self.parser.set_filter(
Filter(device_data[CONF_EXCLUDE], device_data[CONF_INCLUDE])
)
self.offset = None
self.event = None
self._hass = hass
self._calendar_data = CalendarData(
get_async_client(hass),
_LOGGER,
{
"name": self.name,
"url": device_data[CONF_URL],
"min_update_time": 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],
)
if device_data.get(CONF_SET_TIMEOUT):
self._calendar_data.set_timeout(
device_data[CONF_CONNECTION_TIMEOUT]
)
async def async_get_events(
self, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame.
: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: list[ParserEvent] = []
if await 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: list[ParserEvent] = []
for event in event_list:
event.summary = self._summary_prefix + event.summary
if not event.summary:
event.summary = self._summary_default
# Since we skipped the validation code earlier, invoke it now,
# before passing the object outside this component
event.validate()
return event_list
async def async_update(self):
"""Get the current or next event."""
_LOGGER.debug("%s: Update was called", self.name)
parser_event: ParserEvent | None = None
if await self._calendar_data.download_calendar():
_LOGGER.debug("%s: Setting calendar content", self.name)
self.parser.set_content(self._calendar_data.get())
try:
parser_event: ParserEvent | None = 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 parser_event is not None:
_LOGGER.debug(
"%s: got event: %s; start: %s; end: %s; all_day: %s",
self.name,
parser_event.summary,
parser_event.start,
parser_event.end,
parser_event.all_day,
)
(summary, offset) = extract_offset(parser_event.summary, OFFSET)
parser_event.summary = self._summary_prefix + summary
if not parser_event.summary:
parser_event.summary = self._summary_default
self.offset = offset
# Invoke validation here, since it was skipped when creating the
# ParserEvent
parser_event.validate()
self.event: CalendarEvent = parser_event
return True
_LOGGER.debug("%s: No event found!", self.name)
return False