382 lines
13 KiB
Python
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
|