Rewrite calendar component (#24950)

* Correct google calendar test name

* Rewrite calendar component

* Save component in hass.data.
* Rename device_state_attributes to state_attributes.
* Remove offset attribute from base state_attributes.
* Extract offset helpers to calendar component.
* Clean imports.
* Remove stale constants.
* Remove name and add async_get_events.
* Add normalize_event helper function. Copied from #21495.
* Add event property to base entity.
* Use event property for calendar state.
* Ensure event start and end.
* Remove entity init.
* Add comment about event data class.
* Temporary keep old start and end datetime format.

* Convert demo calendar

* Convert google calendar

* Convert google calendar.
* Clean up google component.
* Keep offset feature by using offset helpers.

* Convert caldav calendar

* Clean up caldav calendar.
* Update caldav cal on addition.
* Bring back offset to caldav calendar.
* Copy caldav event on update.

* Convert todoist calendar
This commit is contained in:
Martin Hjelmare 2019-07-11 05:59:37 +02:00 committed by Paulus Schoutsen
parent c6af8811fb
commit 177f5a35ae
7 changed files with 272 additions and 248 deletions

View File

@ -1,4 +1,5 @@
"""Support for WebDav Calendar.""" """Support for WebDav Calendar."""
import copy
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import re import re
@ -6,37 +7,38 @@ import re
import voluptuous as vol import voluptuous as vol
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
PLATFORM_SCHEMA, CalendarEventDevice, get_date) ENTITY_ID_FORMAT, PLATFORM_SCHEMA, CalendarEventDevice, calculate_offset,
get_date, is_offset_reached)
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL) CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL)
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.util import Throttle, dt from homeassistant.util import Throttle, dt
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_DEVICE_ID = 'device_id'
CONF_CALENDARS = 'calendars' CONF_CALENDARS = 'calendars'
CONF_CUSTOM_CALENDARS = 'custom_calendars' CONF_CUSTOM_CALENDARS = 'custom_calendars'
CONF_CALENDAR = 'calendar' CONF_CALENDAR = 'calendar'
CONF_SEARCH = 'search' CONF_SEARCH = 'search'
OFFSET = '!!'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
vol.Required(CONF_URL): vol.Url(), vol.Required(CONF_URL): vol.Url(),
vol.Optional(CONF_CALENDARS, default=[]): vol.Optional(CONF_CALENDARS, default=[]):
vol.All(cv.ensure_list, vol.Schema([ vol.All(cv.ensure_list, [cv.string]),
cv.string
])),
vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): vol.Optional(CONF_CUSTOM_CALENDARS, default=[]):
vol.All(cv.ensure_list, vol.Schema([ vol.All(cv.ensure_list, [
vol.Schema({ vol.Schema({
vol.Required(CONF_CALENDAR): cv.string, vol.Required(CONF_CALENDAR): cv.string,
vol.Required(CONF_NAME): cv.string, vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_SEARCH): cv.string, vol.Required(CONF_SEARCH): cv.string,
}) })
])), ]),
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean
}) })
@ -47,12 +49,12 @@ def setup_platform(hass, config, add_entities, disc_info=None):
"""Set up the WebDav Calendar platform.""" """Set up the WebDav Calendar platform."""
import caldav import caldav
url = config.get(CONF_URL) url = config[CONF_URL]
username = config.get(CONF_USERNAME) username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD) password = config.get(CONF_PASSWORD)
client = caldav.DAVClient(url, None, username, password, client = caldav.DAVClient(
ssl_verify_cert=config.get(CONF_VERIFY_SSL)) url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL])
calendars = client.principal().calendars() calendars = client.principal().calendars()
@ -60,65 +62,83 @@ def setup_platform(hass, config, add_entities, disc_info=None):
for calendar in list(calendars): for calendar in list(calendars):
# If a calendar name was given in the configuration, # If a calendar name was given in the configuration,
# ignore all the others # ignore all the others
if (config.get(CONF_CALENDARS) if (config[CONF_CALENDARS]
and calendar.name not in config.get(CONF_CALENDARS)): and calendar.name not in config[CONF_CALENDARS]):
_LOGGER.debug("Ignoring calendar '%s'", calendar.name) _LOGGER.debug("Ignoring calendar '%s'", calendar.name)
continue continue
# Create additional calendars based on custom filtering rules # Create additional calendars based on custom filtering rules
for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): for cust_calendar in config[CONF_CUSTOM_CALENDARS]:
# Check that the base calendar matches # Check that the base calendar matches
if cust_calendar.get(CONF_CALENDAR) != calendar.name: if cust_calendar[CONF_CALENDAR] != calendar.name:
continue continue
device_data = { name = cust_calendar[CONF_NAME]
CONF_NAME: cust_calendar.get(CONF_NAME), device_id = "{} {}".format(
CONF_DEVICE_ID: "{} {}".format( cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME])
cust_calendar.get(CONF_CALENDAR), entity_id = generate_entity_id(
cust_calendar.get(CONF_NAME)), ENTITY_ID_FORMAT, device_id, hass=hass)
}
calendar_devices.append( calendar_devices.append(
WebDavCalendarEventDevice( WebDavCalendarEventDevice(
hass, device_data, calendar, True, name, calendar, entity_id, True,
cust_calendar.get(CONF_SEARCH))) cust_calendar[CONF_SEARCH]))
# Create a default calendar if there was no custom one # Create a default calendar if there was no custom one
if not config.get(CONF_CUSTOM_CALENDARS): if not config[CONF_CUSTOM_CALENDARS]:
device_data = { name = calendar.name
CONF_NAME: calendar.name, device_id = calendar.name
CONF_DEVICE_ID: calendar.name, entity_id = generate_entity_id(
} ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append( calendar_devices.append(
WebDavCalendarEventDevice(hass, device_data, calendar) WebDavCalendarEventDevice(name, calendar, entity_id)
) )
add_entities(calendar_devices) add_entities(calendar_devices, True)
class WebDavCalendarEventDevice(CalendarEventDevice): class WebDavCalendarEventDevice(CalendarEventDevice):
"""A device for getting the next Task from a WebDav Calendar.""" """A device for getting the next Task from a WebDav Calendar."""
def __init__(self, hass, device_data, calendar, all_day=False, def __init__(self, name, calendar, entity_id, all_day=False, search=None):
search=None):
"""Create the WebDav Calendar Event Device.""" """Create the WebDav Calendar Event Device."""
self.data = WebDavCalendarData(calendar, all_day, search) self.data = WebDavCalendarData(calendar, all_day, search)
super().__init__(hass, device_data) self.entity_id = entity_id
self._event = None
self._name = name
self._offset_reached = False
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the device state attributes.""" """Return the device state attributes."""
if self.data.event is None: return {
# No tasks, we don't REALLY need to show anything. 'offset_reached': self._offset_reached,
return {} }
attributes = super().device_state_attributes @property
return attributes def event(self):
"""Return the next upcoming event."""
return self._event
@property
def name(self):
"""Return the name of the entity."""
return self._name
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date) return await self.data.async_get_events(hass, start_date, end_date)
def update(self):
"""Update event data."""
self.data.update()
event = copy.deepcopy(self.data.event)
if event is None:
self._event = event
return
event = calculate_offset(event, OFFSET)
self._offset_reached = is_offset_reached(event)
self._event = event
class WebDavCalendarData: class WebDavCalendarData:
"""Class to utilize the calendar dav client object to get next event.""" """Class to utilize the calendar dav client object to get next event."""
@ -174,10 +194,12 @@ class WebDavCalendarData:
)) ))
vevent = next(( vevent = next((
event.instance.vevent for event in results event.instance.vevent
for event in results
if (self.is_matching(event.instance.vevent, self.search) if (self.is_matching(event.instance.vevent, self.search)
and (not self.is_all_day(event.instance.vevent) and (
or self.include_all_day) not self.is_all_day(event.instance.vevent)
or self.include_all_day)
and not self.is_over(event.instance.vevent))), None) and not self.is_over(event.instance.vevent))), None)
# If no matching event could be found # If no matching event could be found
@ -186,7 +208,7 @@ class WebDavCalendarData:
"No matching event found in the %d results for %s", "No matching event found in the %d results for %s",
len(results), self.calendar.name) len(results), self.calendar.name)
self.event = None self.event = None
return True return
# Populate the entity attributes with the event values # Populate the entity attributes with the event values
self.event = { self.event = {
@ -196,7 +218,6 @@ class WebDavCalendarData:
"location": self.get_attr_value(vevent, "location"), "location": self.get_attr_value(vevent, "location"),
"description": self.get_attr_value(vevent, "description") "description": self.get_attr_value(vevent, "description")
} }
return True
@staticmethod @staticmethod
def is_matching(vevent, search): def is_matching(vevent, search):
@ -205,12 +226,13 @@ class WebDavCalendarData:
return True return True
pattern = re.compile(search) pattern = re.compile(search)
return (hasattr(vevent, "summary") return (
and pattern.match(vevent.summary.value) hasattr(vevent, "summary")
or hasattr(vevent, "location") and pattern.match(vevent.summary.value)
and pattern.match(vevent.location.value) or hasattr(vevent, "location")
or hasattr(vevent, "description") and pattern.match(vevent.location.value)
and pattern.match(vevent.description.value)) or hasattr(vevent, "description")
and pattern.match(vevent.description.value))
@staticmethod @staticmethod
def is_all_day(vevent): def is_all_day(vevent):

View File

@ -1,35 +1,29 @@
"""Support for Google Calendar event device sensors.""" """Support for Google Calendar event device sensors."""
import logging
from datetime import timedelta from datetime import timedelta
import logging
import re import re
from aiohttp import web from aiohttp import web
from homeassistant.components.google import ( from homeassistant.components import http
CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME)
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.helpers.config_validation import ( # noqa from homeassistant.helpers.config_validation import ( # noqa
PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, time_period_str)
from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity import Entity, generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.util import dt from homeassistant.util import dt
from homeassistant.components import http
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'calendar' DOMAIN = 'calendar'
ENTITY_ID_FORMAT = DOMAIN + '.{}' ENTITY_ID_FORMAT = DOMAIN + '.{}'
SCAN_INTERVAL = timedelta(seconds=60) SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup(hass, config): async def async_setup(hass, config):
"""Track states and offer events for calendars.""" """Track states and offer events for calendars."""
component = EntityComponent( component = hass.data[DOMAIN] = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN) _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN)
hass.http.register_view(CalendarListView(component)) hass.http.register_view(CalendarListView(component))
@ -43,10 +37,6 @@ async def async_setup(hass, config):
return True return True
DEFAULT_CONF_TRACK_NEW = True
DEFAULT_CONF_OFFSET = '!!'
def get_date(date): def get_date(date):
"""Get the dateTime from date or dateTime as a local.""" """Get the dateTime from date or dateTime as a local."""
if 'date' in date: if 'date' in date:
@ -55,70 +45,106 @@ def get_date(date):
return dt.as_local(dt.parse_datetime(date['dateTime'])) return dt.as_local(dt.parse_datetime(date['dateTime']))
def normalize_event(event):
"""Normalize a calendar event."""
normalized_event = {}
start = event.get('start')
end = event.get('end')
start = get_date(start) if start is not None else None
end = get_date(end) if end is not None else None
normalized_event['dt_start'] = start
normalized_event['dt_end'] = end
start = start.strftime(DATE_STR_FORMAT) if start is not None else None
end = end.strftime(DATE_STR_FORMAT) if end is not None else None
normalized_event['start'] = start
normalized_event['end'] = end
# cleanup the string so we don't have a bunch of double+ spaces
summary = event.get('summary', '')
normalized_event['message'] = re.sub(' +', '', summary).strip()
normalized_event['location'] = event.get('location', '')
normalized_event['description'] = event.get('description', '')
normalized_event['all_day'] = 'date' in event['start']
return normalized_event
def calculate_offset(event, offset):
"""Calculate event offset.
Return the updated event with the offset_time included.
"""
summary = event.get('summary', '')
# check if we have an offset tag in the message
# time is HH:MM or MM
reg = '{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)'.format(offset)
search = re.search(reg, summary)
if search and search.group(1):
time = search.group(1)
if ':' not in time:
if time[0] == '+' or time[0] == '-':
time = '{}0:{}'.format(time[0], time[1:])
else:
time = '0:{}'.format(time)
offset_time = time_period_str(time)
summary = (
summary[:search.start()] + summary[search.end():]).strip()
event['summary'] = summary
else:
offset_time = dt.dt.timedelta() # default it
event['offset_time'] = offset_time
return event
def is_offset_reached(event):
"""Have we reached the offset time specified in the event title."""
start = get_date(event['start'])
if start is None or event['offset_time'] == dt.dt.timedelta():
return False
return start + event['offset_time'] <= dt.now(start.tzinfo)
class CalendarEventDevice(Entity): class CalendarEventDevice(Entity):
"""A calendar event device.""" """A calendar event device."""
# Classes overloading this must set data to an object @property
# with an update() method def event(self):
data = None """Return the next upcoming event."""
raise NotImplementedError()
def __init__(self, hass, data):
"""Create the Calendar Event Device."""
self._name = data.get(CONF_NAME)
self.dev_id = data.get(CONF_DEVICE_ID)
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self.entity_id = generate_entity_id(
ENTITY_ID_FORMAT, self.dev_id, hass=hass)
self._cal_data = {
'all_day': False,
'offset_time': dt.dt.timedelta(),
'message': '',
'start': None,
'end': None,
'location': '',
'description': '',
}
self.update()
def offset_reached(self):
"""Have we reached the offset time specified in the event title."""
if self._cal_data['start'] is None or \
self._cal_data['offset_time'] == dt.dt.timedelta():
return False
return self._cal_data['start'] + self._cal_data['offset_time'] <= \
dt.now(self._cal_data['start'].tzinfo)
@property @property
def name(self): def state_attributes(self):
"""Return the name of the entity.""" """Return the entity state attributes."""
return self._name event = self.event
if event is None:
@property return None
def device_state_attributes(self):
"""Return the device state attributes."""
start = self._cal_data.get('start', None)
end = self._cal_data.get('end', None)
start = start.strftime(DATE_STR_FORMAT) if start is not None else None
end = end.strftime(DATE_STR_FORMAT) if end is not None else None
event = normalize_event(event)
return { return {
'message': self._cal_data.get('message', ''), 'message': event['message'],
'all_day': self._cal_data.get('all_day', False), 'all_day': event['all_day'],
'offset_reached': self.offset_reached(), 'start_time': event['start'],
'start_time': start, 'end_time': event['end'],
'end_time': end, 'location': event['location'],
'location': self._cal_data.get('location', None), 'description': event['description'],
'description': self._cal_data.get('description', None),
} }
@property @property
def state(self): def state(self):
"""Return the state of the calendar event.""" """Return the state of the calendar event."""
start = self._cal_data.get('start', None) event = self.event
end = self._cal_data.get('end', None) if event is None:
return STATE_OFF
event = normalize_event(event)
start = event['dt_start']
end = event['dt_end']
if start is None or end is None: if start is None or end is None:
return STATE_OFF return STATE_OFF
@ -127,65 +153,11 @@ class CalendarEventDevice(Entity):
if start <= now < end: if start <= now < end:
return STATE_ON return STATE_ON
if now >= end:
self.cleanup()
return STATE_OFF return STATE_OFF
def cleanup(self): async def async_get_events(self, hass, start_date, end_date):
"""Cleanup any start/end listeners that were setup.""" """Return calendar events within a datetime range."""
self._cal_data = { raise NotImplementedError()
'all_day': False,
'offset_time': 0,
'message': '',
'start': None,
'end': None,
'location': None,
'description': None
}
def update(self):
"""Search for the next event."""
if not self.data or not self.data.update():
# update cached, don't do anything
return
if not self.data.event:
# we have no event to work on, make sure we're clean
self.cleanup()
return
start = get_date(self.data.event['start'])
end = get_date(self.data.event['end'])
summary = self.data.event.get('summary', '')
# check if we have an offset tag in the message
# time is HH:MM or MM
reg = '{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)'.format(self._offset)
search = re.search(reg, summary)
if search and search.group(1):
time = search.group(1)
if ':' not in time:
if time[0] == '+' or time[0] == '-':
time = '{}0:{}'.format(time[0], time[1:])
else:
time = '0:{}'.format(time)
offset_time = time_period_str(time)
summary = (summary[:search.start()] + summary[search.end():]) \
.strip()
else:
offset_time = dt.dt.timedelta() # default it
# cleanup the string so we don't have a bunch of double+ spaces
self._cal_data['message'] = re.sub(' +', '', summary).strip()
self._cal_data['offset_time'] = offset_time
self._cal_data['location'] = self.data.event.get('location', '')
self._cal_data['description'] = self.data.event.get('description', '')
self._cal_data['start'] = start
self._cal_data['end'] = end
self._cal_data['all_day'] = 'date' in self.data.event['start']
class CalendarEventView(http.HomeAssistantView): class CalendarEventView(http.HomeAssistantView):
@ -227,11 +199,11 @@ class CalendarListView(http.HomeAssistantView):
async def get(self, request): async def get(self, request):
"""Retrieve calendar list.""" """Retrieve calendar list."""
get_state = request.app['hass'].states.get hass = request.app['hass']
calendar_list = [] calendar_list = []
for entity in self.component.entities: for entity in self.component.entities:
state = get_state(entity.entity_id) state = hass.states.get(entity.entity_id)
calendar_list.append({ calendar_list.append({
"name": state.name, "name": state.name,
"entity_id": entity.entity_id, "entity_id": entity.entity_id,

View File

@ -1,7 +1,6 @@
"""Demo platform that has two fake binary sensors.""" """Demo platform that has two fake binary sensors."""
import copy import copy
from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.components.calendar import CalendarEventDevice, get_date from homeassistant.components.calendar import CalendarEventDevice, get_date
@ -12,27 +11,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
calendar_data_future = DemoGoogleCalendarDataFuture() calendar_data_future = DemoGoogleCalendarDataFuture()
calendar_data_current = DemoGoogleCalendarDataCurrent() calendar_data_current = DemoGoogleCalendarDataCurrent()
add_entities([ add_entities([
DemoGoogleCalendar(hass, calendar_data_future, { DemoGoogleCalendar(hass, calendar_data_future, 'Calendar 1'),
CONF_NAME: 'Calendar 1', DemoGoogleCalendar(hass, calendar_data_current, 'Calendar 2'),
CONF_DEVICE_ID: 'calendar_1',
}),
DemoGoogleCalendar(hass, calendar_data_current, {
CONF_NAME: 'Calendar 2',
CONF_DEVICE_ID: 'calendar_2',
}),
]) ])
class DemoGoogleCalendarData: class DemoGoogleCalendarData:
"""Representation of a Demo Calendar element.""" """Representation of a Demo Calendar element."""
event = {} event = None
# pylint: disable=no-self-use
def update(self):
"""Return true so entity knows we have new data."""
return True
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
@ -84,11 +71,21 @@ class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData):
class DemoGoogleCalendar(CalendarEventDevice): class DemoGoogleCalendar(CalendarEventDevice):
"""Representation of a Demo Calendar element.""" """Representation of a Demo Calendar element."""
def __init__(self, hass, calendar_data, data): def __init__(self, hass, calendar_data, name):
"""Initialize Google Calendar but without the API calls.""" """Initialize demo calendar."""
self.data = calendar_data self.data = calendar_data
super().__init__(hass, data) self._name = name
@property
def event(self):
"""Return the next upcoming event."""
return self.data.event
@property
def name(self):
"""Return the name of the entity."""
return self._name
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame.""" """Return calendar events within a datetime range."""
return await self.data.async_get_events(hass, start_date, end_date) return await self.data.async_get_events(hass, start_date, end_date)

View File

@ -8,7 +8,6 @@ import voluptuous as vol
from voluptuous.error import Error as VoluptuousError from voluptuous.error import Error as VoluptuousError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.setup import setup_component
from homeassistant.helpers import discovery from homeassistant.helpers import discovery
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.event import track_time_change from homeassistant.helpers.event import track_time_change
@ -312,9 +311,6 @@ def do_setup(hass, hass_config, config):
setup_services(hass, hass_config, track_new_found_calendars, setup_services(hass, hass_config, track_new_found_calendars,
calendar_service) calendar_service)
# Ensure component is loaded
setup_component(hass, 'calendar', config)
for calendar in hass.data[DATA_INDEX].values(): for calendar in hass.data[DATA_INDEX].values():
discovery.load_platform(hass, 'calendar', DOMAIN, calendar, discovery.load_platform(hass, 'calendar', DOMAIN, calendar,
hass_config) hass_config)

View File

@ -1,13 +1,17 @@
"""Support for Google Calendar Search binary sensors.""" """Support for Google Calendar Search binary sensors."""
import copy
from datetime import timedelta from datetime import timedelta
import logging import logging
from homeassistant.components.calendar import CalendarEventDevice from homeassistant.components.calendar import (
ENTITY_ID_FORMAT, CalendarEventDevice, calculate_offset, is_offset_reached)
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.util import Throttle, dt from homeassistant.util import Throttle, dt
from . import ( from . import (
CONF_CAL_ID, CONF_ENTITIES, CONF_IGNORE_AVAILABILITY, CONF_SEARCH, CONF_CAL_ID, CONF_DEVICE_ID, CONF_ENTITIES, CONF_IGNORE_AVAILABILITY,
CONF_TRACK, TOKEN_FILE, CONF_MAX_RESULTS, GoogleCalendarService) CONF_MAX_RESULTS, CONF_NAME, CONF_OFFSET, CONF_SEARCH, CONF_TRACK,
DEFAULT_CONF_OFFSET, TOKEN_FILE, GoogleCalendarService)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,27 +33,66 @@ def setup_platform(hass, config, add_entities, disc_info=None):
return return
calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE))
add_entities([GoogleCalendarEventDevice(hass, calendar_service, entities = []
disc_info[CONF_CAL_ID], data) for data in disc_info[CONF_ENTITIES]:
for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]]) if not data[CONF_TRACK]:
continue
entity_id = generate_entity_id(
ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass)
entity = GoogleCalendarEventDevice(
calendar_service, disc_info[CONF_CAL_ID], data, entity_id)
entities.append(entity)
add_entities(entities, True)
class GoogleCalendarEventDevice(CalendarEventDevice): class GoogleCalendarEventDevice(CalendarEventDevice):
"""A calendar event device.""" """A calendar event device."""
def __init__(self, hass, calendar_service, calendar, data): def __init__(self, calendar_service, calendar, data, entity_id):
"""Create the Calendar event device.""" """Create the Calendar event device."""
self.data = GoogleCalendarData(calendar_service, calendar, self.data = GoogleCalendarData(
data.get(CONF_SEARCH), calendar_service, calendar,
data.get(CONF_IGNORE_AVAILABILITY), data.get(CONF_SEARCH), data.get(CONF_IGNORE_AVAILABILITY),
data.get(CONF_MAX_RESULTS)) data.get(CONF_MAX_RESULTS))
self._event = None
self._name = data[CONF_NAME]
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self._offset_reached = False
self.entity_id = entity_id
super().__init__(hass, data) @property
def device_state_attributes(self):
"""Return the device state attributes."""
return {
'offset_reached': self._offset_reached,
}
@property
def event(self):
"""Return the next upcoming event."""
return self._event
@property
def name(self):
"""Return the name of the entity."""
return self._name
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date) return await self.data.async_get_events(hass, start_date, end_date)
def update(self):
"""Update event data."""
self.data.update()
event = copy.deepcopy(self.data.event)
if event is None:
self._event = event
return
event = calculate_offset(event, self._offset)
self._offset_reached = is_offset_reached(event)
self._event = event
class GoogleCalendarData: class GoogleCalendarData:
"""Class to utilize calendar service object to get next event.""" """Class to utilize calendar service object to get next event."""
@ -71,7 +114,7 @@ class GoogleCalendarData:
try: try:
service = self.calendar_service.get() service = self.calendar_service.get()
except ServerNotFoundError: except ServerNotFoundError:
_LOGGER.warning("Unable to connect to Google, using cached data") _LOGGER.error("Unable to connect to Google")
return None, None return None, None
params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS)
params['calendarId'] = self.calendar_id params['calendarId'] = self.calendar_id
@ -87,7 +130,7 @@ class GoogleCalendarData:
service, params = await hass.async_add_executor_job( service, params = await hass.async_add_executor_job(
self._prepare_query) self._prepare_query)
if service is None: if service is None:
return return []
params['timeMin'] = start_date.isoformat('T') params['timeMin'] = start_date.isoformat('T')
params['timeMax'] = end_date.isoformat('T') params['timeMax'] = end_date.isoformat('T')
@ -111,7 +154,7 @@ class GoogleCalendarData:
"""Get the latest data.""" """Get the latest data."""
service, params = self._prepare_query() service, params = self._prepare_query()
if service is None: if service is None:
return False return
params['timeMin'] = dt.now().isoformat('T') params['timeMin'] = dt.now().isoformat('T')
events = service.events() events = service.events()
@ -131,4 +174,3 @@ class GoogleCalendarData:
break break
self.event = new_event self.event = new_event
return True

View File

@ -6,7 +6,6 @@ import voluptuous as vol
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
DOMAIN, PLATFORM_SCHEMA, CalendarEventDevice) DOMAIN, PLATFORM_SCHEMA, CalendarEventDevice)
from homeassistant.components.google import CONF_DEVICE_ID
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.template import DATE_STR_FORMAT
@ -148,17 +147,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
label_id_lookup[label[NAME].lower()] = label[ID] label_id_lookup[label[NAME].lower()] = label[ID]
# Check config for more projects. # Check config for more projects.
extra_projects = config.get(CONF_EXTRA_PROJECTS) extra_projects = config[CONF_EXTRA_PROJECTS]
for project in extra_projects: for project in extra_projects:
# Special filter: By date # Special filter: By date
project_due_date = project.get(CONF_PROJECT_DUE_DATE) project_due_date = project.get(CONF_PROJECT_DUE_DATE)
# Special filter: By label # Special filter: By label
project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) project_label_filter = project[CONF_PROJECT_LABEL_WHITELIST]
# Special filter: By name # Special filter: By name
# Names must be converted into IDs. # Names must be converted into IDs.
project_name_filter = project.get(CONF_PROJECT_WHITELIST) project_name_filter = project[CONF_PROJECT_WHITELIST]
project_id_filter = [ project_id_filter = [
project_id_lookup[project_name.lower()] project_id_lookup[project_name.lower()]
for project_name in project_name_filter] for project_name in project_name_filter]
@ -226,30 +225,26 @@ class TodoistProjectDevice(CalendarEventDevice):
data, labels, token, latest_task_due_date, data, labels, token, latest_task_due_date,
whitelisted_labels, whitelisted_projects whitelisted_labels, whitelisted_projects
) )
self._cal_data = {}
self._name = data[CONF_NAME]
# Set up the calendar side of things @property
calendar_format = { def event(self):
CONF_NAME: data[CONF_NAME], """Return the next upcoming event."""
# Set Entity ID to use the name so we can identify calendars return self.data.event
CONF_DEVICE_ID: data[CONF_NAME]
}
super().__init__(hass, calendar_format) @property
def name(self):
"""Return the name of the entity."""
return self._name
def update(self): def update(self):
"""Update all Todoist Calendars.""" """Update all Todoist Calendars."""
# Set basic calendar data self.data.update()
super().update()
# Set Todoist-specific data that can't easily be grabbed # Set Todoist-specific data that can't easily be grabbed
self._cal_data[ALL_TASKS] = [ self._cal_data[ALL_TASKS] = [
task[SUMMARY] for task in self.data.all_project_tasks] task[SUMMARY] for task in self.data.all_project_tasks]
def cleanup(self):
"""Clean up all calendar data."""
super().cleanup()
self._cal_data[ALL_TASKS] = []
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date) return await self.data.async_get_events(hass, start_date, end_date)
@ -259,11 +254,9 @@ class TodoistProjectDevice(CalendarEventDevice):
"""Return the device state attributes.""" """Return the device state attributes."""
if self.data.event is None: if self.data.event is None:
# No tasks, we don't REALLY need to show anything. # No tasks, we don't REALLY need to show anything.
return {} return None
attributes = super().device_state_attributes attributes = {}
# Add additional attributes.
attributes[DUE_TODAY] = self.data.event[DUE_TODAY] attributes[DUE_TODAY] = self.data.event[DUE_TODAY]
attributes[OVERDUE] = self.data.event[OVERDUE] attributes[OVERDUE] = self.data.event[OVERDUE]
attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] attributes[ALL_TASKS] = self._cal_data[ALL_TASKS]
@ -314,7 +307,7 @@ class TodoistProjectData:
self.event = None self.event = None
self._api = api self._api = api
self._name = project_data.get(CONF_NAME) self._name = project_data[CONF_NAME]
# If no ID is defined, fetch all tasks. # If no ID is defined, fetch all tasks.
self._id = project_data.get(CONF_ID) self._id = project_data.get(CONF_ID)
@ -487,10 +480,12 @@ class TodoistProjectData:
if self._id is None: if self._id is None:
project_task_data = [ project_task_data = [
task for task in self._api.state[TASKS] task for task in self._api.state[TASKS]
if not self._project_id_whitelist or if not self._project_id_whitelist
task[PROJECT_ID] in self._project_id_whitelist] or task[PROJECT_ID] in self._project_id_whitelist]
else: else:
project_task_data = self._api.projects.get_data(self._id)[TASKS] project_data = await hass.async_add_executor_job(
self._api.projects.get_data, self._id)
project_task_data = project_data[TASKS]
events = [] events = []
time_format = '%a %d %b %Y %H:%M:%S %z' time_format = '%a %d %b %Y %H:%M:%S %z'
@ -515,8 +510,8 @@ class TodoistProjectData:
self._api.sync() self._api.sync()
project_task_data = [ project_task_data = [
task for task in self._api.state[TASKS] task for task in self._api.state[TASKS]
if not self._project_id_whitelist or if not self._project_id_whitelist
task[PROJECT_ID] in self._project_id_whitelist] or task[PROJECT_ID] in self._project_id_whitelist]
else: else:
project_task_data = self._api.projects.get_data(self._id)[TASKS] project_task_data = self._api.projects.get_data(self._id)[TASKS]
@ -524,7 +519,7 @@ class TodoistProjectData:
if not project_task_data: if not project_task_data:
_LOGGER.debug("No data for %s", self._name) _LOGGER.debug("No data for %s", self._name)
self.event = None self.event = None
return True return
# Keep an updated list of all tasks in this project. # Keep an updated list of all tasks in this project.
project_tasks = [] project_tasks = []
@ -539,7 +534,7 @@ class TodoistProjectData:
# We had no valid tasks # We had no valid tasks
_LOGGER.debug("No valid tasks for %s", self._name) _LOGGER.debug("No valid tasks for %s", self._name)
self.event = None self.event = None
return True return
# Make sure the task collection is reset to prevent an # Make sure the task collection is reset to prevent an
# infinite collection repeating the same tasks # infinite collection repeating the same tasks
@ -574,4 +569,3 @@ class TodoistProjectData:
).strftime(DATE_STR_FORMAT) ).strftime(DATE_STR_FORMAT)
} }
_LOGGER.debug("Updated %s", self._name) _LOGGER.debug("Updated %s", self._name)
return True

View File

@ -17,6 +17,7 @@ import homeassistant.util.dt as dt_util
from tests.common import async_mock_service from tests.common import async_mock_service
GOOGLE_CONFIG = { GOOGLE_CONFIG = {
CONF_CLIENT_ID: 'client_id', CONF_CLIENT_ID: 'client_id',
CONF_CLIENT_SECRET: 'client_secret', CONF_CLIENT_SECRET: 'client_secret',
@ -304,7 +305,7 @@ async def test_all_day_offset_event(hass, mock_next_event):
} }
async def test_update_false(hass, google_service): async def test_update_error(hass, google_service):
"""Test that the calendar handles a server error.""" """Test that the calendar handles a server error."""
google_service.return_value.get = Mock( google_service.return_value.get = Mock(
side_effect=httplib2.ServerNotFoundError("unit test")) side_effect=httplib2.ServerNotFoundError("unit test"))