From 613615433a3676a25d2a07cc27adf88a3415bb9e Mon Sep 17 00:00:00 2001 From: mnestor <mnestor@users.noreply.github.com> Date: Sat, 19 Nov 2016 01:29:20 -0500 Subject: [PATCH] Google Calendar round 2 (#4161) * Google Calendar round 2 * Add google back to .coveragerc * Update __init__.py --- .coveragerc | 5 +- homeassistant/components/calendar/__init__.py | 183 ++++++++ homeassistant/components/calendar/demo.py | 82 ++++ homeassistant/components/calendar/google.py | 79 ++++ homeassistant/components/demo.py | 1 + homeassistant/components/google.py | 292 ++++++++++++ requirements_all.txt | 6 + tests/components/calendar/__init__.py | 1 + tests/components/calendar/test_google.py | 421 ++++++++++++++++++ tests/components/calendar/test_init.py | 1 + tests/components/test_google.py | 90 ++++ 11 files changed, 1160 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/calendar/__init__.py create mode 100755 homeassistant/components/calendar/demo.py create mode 100644 homeassistant/components/calendar/google.py create mode 100644 homeassistant/components/google.py create mode 100644 tests/components/calendar/__init__.py create mode 100644 tests/components/calendar/test_google.py create mode 100644 tests/components/calendar/test_init.py create mode 100644 tests/components/test_google.py diff --git a/.coveragerc b/.coveragerc index 09d06ec1082..d9e74d99d78 100644 --- a/.coveragerc +++ b/.coveragerc @@ -28,6 +28,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/google.py + homeassistant/components/*/google.py + homeassistant/components/insteon_hub.py homeassistant/components/*/insteon_hub.py @@ -132,7 +135,7 @@ omit = homeassistant/components/climate/knx.py homeassistant/components/climate/proliphix.py homeassistant/components/climate/radiotherm.py - homeassistant/components/cover/garadget.py + homeassistant/components/cover/garadget.py homeassistant/components/cover/homematic.py homeassistant/components/cover/rpi_gpio.py homeassistant/components/cover/scsgate.py diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py new file mode 100644 index 00000000000..503b97a2b13 --- /dev/null +++ b/homeassistant/components/calendar/__init__.py @@ -0,0 +1,183 @@ +""" +Support for Google Calendar event device sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar/ + +""" +import logging +import re + +from homeassistant.components.google import (CONF_OFFSET, + CONF_DEVICE_ID, + CONF_NAME) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import time_period_str +from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'calendar' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + + +def setup(hass, config): + """Track states and offer events for calendars.""" + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, 60, DOMAIN) + + component.setup(config) + + return True + + +DEFAULT_CONF_TRACK_NEW = True +DEFAULT_CONF_OFFSET = '!!' + + +# pylint: disable=too-many-instance-attributes +class CalendarEventDevice(Entity): + """A calendar event device.""" + + # Classes overloading this must set data to an object + # with an update() method + data = None + + # pylint: disable=too-many-arguments + 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 + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def device_state_attributes(self): + """State Attributes for HA.""" + 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 + + return { + 'message': self._cal_data.get('message', ''), + 'all_day': self._cal_data.get('all_day', False), + 'offset_reached': self.offset_reached(), + 'start_time': start, + 'end_time': end, + 'location': self._cal_data.get('location', None), + 'description': self._cal_data.get('description', None), + } + + @property + def state(self): + """Return the state of the calendar event.""" + start = self._cal_data.get('start', None) + end = self._cal_data.get('end', None) + if start is None or end is None: + return STATE_OFF + + now = dt.now() + + if start <= now and end > now: + return STATE_ON + + if now >= end: + self.cleanup() + + return STATE_OFF + + def cleanup(self): + """Cleanup any start/end listeners that were setup.""" + self._cal_data = { + '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 + + def _get_date(date): + """Get the dateTime from date or dateTime as a local.""" + if 'date' in date: + return dt.as_utc(dt.dt.datetime.combine( + dt.parse_date(date['date']), dt.dt.time())) + else: + return dt.parse_datetime(date['dateTime']) + + start = _get_date(self.data.event['start']) + end = _get_date(self.data.event['end']) + + summary = self.data.event['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'] diff --git a/homeassistant/components/calendar/demo.py b/homeassistant/components/calendar/demo.py new file mode 100755 index 00000000000..279119a1ff5 --- /dev/null +++ b/homeassistant/components/calendar/demo.py @@ -0,0 +1,82 @@ +""" +Demo platform that has two fake binary sensors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import homeassistant.util.dt as dt_util +from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo binary sensor platform.""" + calendar_data_future = DemoGoogleCalendarDataFuture() + calendar_data_current = DemoGoogleCalendarDataCurrent() + add_devices([ + DemoGoogleCalendar(hass, calendar_data_future, { + CONF_NAME: 'Future Event', + CONF_DEVICE_ID: 'future_event', + }), + + DemoGoogleCalendar(hass, calendar_data_current, { + CONF_NAME: 'Current Event', + CONF_DEVICE_ID: 'current_event', + }), + ]) + + +class DemoGoogleCalendarData(object): + """Setup base class for data.""" + + # pylint: disable=no-self-use + def update(self): + """Return true so entity knows we have new data.""" + return True + + +class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): + """Setup future data event.""" + + def __init__(self): + """Set the event to a future event.""" + one_hour_from_now = dt_util.now() \ + + dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': one_hour_from_now.isoformat() + }, + 'end': { + 'dateTime': (one_hour_from_now + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Future Event', + } + + +class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): + """Create a current event we're in the middle of.""" + + def __init__(self): + """Set the event data.""" + middle_of_event = dt_util.now() \ + - dt_util.dt.timedelta(minutes=30) + self.event = { + 'start': { + 'dateTime': middle_of_event.isoformat() + }, + 'end': { + 'dateTime': (middle_of_event + dt_util.dt. + timedelta(minutes=60)).isoformat() + }, + 'summary': 'Current Event', + } + + +class DemoGoogleCalendar(CalendarEventDevice): + """A Demo binary sensor.""" + + def __init__(self, hass, calendar_data, data): + """The same as a google calendar but without the api calls.""" + self.data = calendar_data + super().__init__(hass, data) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py new file mode 100644 index 00000000000..741b3238b49 --- /dev/null +++ b/homeassistant/components/calendar/google.py @@ -0,0 +1,79 @@ +""" +Support for Google Calendar Search binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.google_calendar/ +""" +# pylint: disable=import-error +import logging +from datetime import timedelta + +from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.components.google import (CONF_CAL_ID, CONF_ENTITIES, + CONF_TRACK, TOKEN_FILE, + GoogleCalendarService) +from homeassistant.util import Throttle, dt + +DEFAULT_GOOGLE_SEARCH_PARAMS = { + 'orderBy': 'startTime', + 'maxResults': 1, + 'singleEvents': True, +} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Setup the calendar platform for event devices.""" + if disc_info is None: + return + + if not any([data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]]): + return + + calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) + add_devices([GoogleCalendarEventDevice(hass, calendar_service, + disc_info[CONF_CAL_ID], data) + for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]]) + + +# pylint: disable=too-many-instance-attributes +class GoogleCalendarEventDevice(CalendarEventDevice): + """A calendar event device.""" + + def __init__(self, hass, calendar_service, calendar, data): + """Create the Calendar event device.""" + self.data = GoogleCalendarData(calendar_service, calendar, + data.get('search', None)) + super().__init__(hass, data) + + +class GoogleCalendarData(object): + """Class to utilize calendar service object to get next event.""" + + def __init__(self, calendar_service, calendar_id, search=None): + """Setup how we are going to search the google calendar.""" + self.calendar_service = calendar_service + self.calendar_id = calendar_id + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + service = self.calendar_service.get() + params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) + params['timeMin'] = dt.utcnow().isoformat('T') + params['calendarId'] = self.calendar_id + if self.search: + params['q'] = self.search + + events = service.events() # pylint: disable=no-member + result = events.list(**params).execute() + + items = result.get('items', []) + self.event = items[0] if len(items) == 1 else None + return True diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 9f3042320c9..3f3454e0f02 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -17,6 +17,7 @@ DOMAIN = 'demo' COMPONENTS_WITH_DEMO_PLATFORM = [ 'alarm_control_panel', 'binary_sensor', + 'calendar', 'camera', 'climate', 'cover', diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py new file mode 100644 index 00000000000..3dbc2c1a1ec --- /dev/null +++ b/homeassistant/components/google.py @@ -0,0 +1,292 @@ +""" +Support for Google - Calendar Event Devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/google/ + +NOTE TO OTHER DEVELOPERS: IF YOU ADD MORE SCOPES TO THE OAUTH THAN JUST +CALENDAR THEN USERS WILL NEED TO DELETE THEIR TOKEN_FILE. THEY WILL LOSE THEIR +REFRESH_TOKEN PIECE WHEN RE-AUTHENTICATING TO ADD MORE API ACCESS +IT'S BEST TO JUST HAVE SEPARATE OAUTH FOR DIFFERENT PIECES OF GOOGLE +""" +import logging +import os +import yaml + +import voluptuous as vol +from voluptuous.error import Error as VoluptuousError + +import homeassistant.helpers.config_validation as cv +import homeassistant.loader as loader +from homeassistant import bootstrap +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_time_change +from homeassistant.util import convert, dt + +REQUIREMENTS = [ + 'google-api-python-client==1.5.5', + 'oauth2client==3.0.0', +] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'google' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_TRACK_NEW = 'track_new_calendar' + +CONF_CAL_ID = 'cal_id' +CONF_DEVICE_ID = 'device_id' +CONF_NAME = 'name' +CONF_ENTITIES = 'entities' +CONF_TRACK = 'track' +CONF_SEARCH = 'search' +CONF_OFFSET = 'offset' + +DEFAULT_CONF_TRACK_NEW = True +DEFAULT_CONF_OFFSET = '!!' + +NOTIFICATION_ID = 'google_calendar_notification' +NOTIFICATION_TITLE = 'Google Calendar Setup' +GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors" + +SERVICE_SCAN_CALENDARS = 'scan_for_calendars' +SERVICE_FOUND_CALENDARS = 'found_calendar' + +DATA_INDEX = 'google_calendars' + +YAML_DEVICES = '{}_calendars.yaml'.format(DOMAIN) +SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' + +TOKEN_FILE = '.{}.token'.format(DOMAIN) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_TRACK_NEW): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + +_SINGLE_CALSEARCH_CONFIG = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_TRACK): cv.boolean, + vol.Optional(CONF_SEARCH): vol.Any(cv.string, None), + vol.Optional(CONF_OFFSET): cv.string, +}) + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_CAL_ID): cv.string, + vol.Required(CONF_ENTITIES, None): + vol.All(cv.ensure_list, [_SINGLE_CALSEARCH_CONFIG]), +}, extra=vol.ALLOW_EXTRA) + + +def do_authentication(hass, config): + """Notify user of actions and authenticate. + + Notify user of user_code and verification_url then poll + until we have an access token. + """ + from oauth2client.client import ( + OAuth2WebServerFlow, + OAuth2DeviceCodeError, + FlowExchangeError + ) + from oauth2client.file import Storage + + oauth = OAuth2WebServerFlow( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + 'https://www.googleapis.com/auth/calendar.readonly', + 'Home-Assistant.io', + ) + + persistent_notification = loader.get_component('persistent_notification') + try: + dev_flow = oauth.step1_get_device_and_user_codes() + except OAuth2DeviceCodeError as err: + persistent_notification.create( + hass, 'Error: {}<br />You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + persistent_notification.create( + hass, 'In order to authorize Home-Assistant to view your calendars' + 'You must visit: <a href="{}" target="_blank">{}</a> and enter' + 'code: {}'.format(dev_flow.verification_url, + dev_flow.verification_url, + dev_flow.user_code), + title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID + ) + + def step2_exchange(now): + """Keep trying to validate the user_code until it expires.""" + if now >= dt.as_local(dev_flow.user_code_expiry): + persistent_notification.create( + hass, 'Authenication code expired, please restart ' + 'Home-Assistant and try again', + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + listener() + + try: + credentials = oauth.step2_exchange(device_flow_info=dev_flow) + except FlowExchangeError: + # not ready yet, call again + return + + storage = Storage(hass.config.path(TOKEN_FILE)) + storage.put(credentials) + do_setup(hass, config) + listener() + persistent_notification.create( + hass, 'We are all setup now. Check {} for calendars that have ' + 'been found'.format(YAML_DEVICES), + title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) + + listener = track_time_change(hass, step2_exchange, + second=range(0, 60, dev_flow.interval)) + + return True + + +def setup(hass, config): + """Setup the platform.""" + if DATA_INDEX not in hass.data: + hass.data[DATA_INDEX] = {} + + conf = config.get(DOMAIN, {}) + + token_file = hass.config.path(TOKEN_FILE) + if not os.path.isfile(token_file): + do_authentication(hass, conf) + else: + do_setup(hass, conf) + + return True + + +def setup_services(hass, track_new_found_calendars, calendar_service): + """Setup service listeners.""" + def _found_calendar(call): + """Check if we know about a calendar and generate PLATFORM_DISCOVER.""" + calendar = get_calendar_info(hass, call.data) + if hass.data[DATA_INDEX].get(calendar[CONF_CAL_ID], None) is not None: + return + + hass.data[DATA_INDEX].update({calendar[CONF_CAL_ID]: calendar}) + + update_config( + hass.config.path(YAML_DEVICES), + hass.data[DATA_INDEX][calendar[CONF_CAL_ID]] + ) + + discovery.load_platform(hass, 'calendar', DOMAIN, + hass.data[DATA_INDEX][calendar[CONF_CAL_ID]]) + + hass.services.register( + DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar, + None, schema=None) + + def _scan_for_calendars(service): + """Scan for new calendars.""" + service = calendar_service.get() + cal_list = service.calendarList() # pylint: disable=no-member + calendars = cal_list.list().execute()['items'] + for calendar in calendars: + calendar['track'] = track_new_found_calendars + hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, + calendar) + + hass.services.register( + DOMAIN, SERVICE_SCAN_CALENDARS, + _scan_for_calendars, + None, schema=None) + return True + + +def do_setup(hass, config): + """Run the setup after we have everything configured.""" + # load calendars the user has configured + hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES)) + + calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE)) + track_new_found_calendars = convert(config.get(CONF_TRACK_NEW), + bool, DEFAULT_CONF_TRACK_NEW) + setup_services(hass, track_new_found_calendars, calendar_service) + + # Ensure component is loaded + bootstrap.setup_component(hass, 'calendar', config) + + for calendar in hass.data[DATA_INDEX].values(): + discovery.load_platform(hass, 'calendar', DOMAIN, calendar) + + # look for any new calendars + hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None) + return True + + +class GoogleCalendarService(object): + """Calendar service interface to google.""" + + def __init__(self, token_file): + """We just need the token_file.""" + self.token_file = token_file + + def get(self): + """Get the calendar service from the storage file token.""" + import httplib2 + from oauth2client.file import Storage + from googleapiclient import discovery as google_discovery + credentials = Storage(self.token_file).get() + http = credentials.authorize(httplib2.Http()) + service = google_discovery.build('calendar', 'v3', http=http) + return service + + +def get_calendar_info(hass, calendar): + """Convert data from Google into DEVICE_SCHEMA.""" + calendar_info = DEVICE_SCHEMA({ + CONF_CAL_ID: calendar['id'], + CONF_ENTITIES: [{ + CONF_TRACK: calendar['track'], + CONF_NAME: calendar['summary'], + CONF_DEVICE_ID: generate_entity_id('{}', calendar['summary'], + hass=hass), + }] + }) + return calendar_info + + +def load_config(path): + """Load the google_calendar_devices.yaml.""" + calendars = {} + try: + with open(path) as file: + data = yaml.load(file) + for calendar in data: + try: + calendars.update({calendar[CONF_CAL_ID]: + DEVICE_SCHEMA(calendar)}) + except VoluptuousError as exception: + # keep going + _LOGGER.warning('Calendar Invalid Data: %s', exception) + except FileNotFoundError: + # When YAML file could not be loaded/did not contain a dict + return {} + + return calendars + + +def update_config(path, calendar): + """Write the google_calendar_devices.yaml.""" + with open(path, 'a') as out: + out.write('\n') + yaml.dump([calendar], out, default_flow_style=False) diff --git a/requirements_all.txt b/requirements_all.txt index 70331ae1940..8fe00b301c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -124,6 +124,9 @@ fuzzywuzzy==0.14.0 # homeassistant.components.notify.gntp gntp==1.0.3 +# homeassistant.components.google +google-api-python-client==1.5.5 + # homeassistant.components.sensor.google_travel_time googlemaps==2.4.4 @@ -280,6 +283,9 @@ netdisco==0.7.6 # homeassistant.components.sensor.neurio_energy neurio==0.2.10 +# homeassistant.components.google +oauth2client==3.0.0 + # homeassistant.components.switch.orvibo orvibo==1.1.1 diff --git a/tests/components/calendar/__init__.py b/tests/components/calendar/__init__.py new file mode 100644 index 00000000000..4386f422d21 --- /dev/null +++ b/tests/components/calendar/__init__.py @@ -0,0 +1 @@ +"""The tests for calendar sensor platforms.""" diff --git a/tests/components/calendar/test_google.py b/tests/components/calendar/test_google.py new file mode 100644 index 00000000000..534faccd737 --- /dev/null +++ b/tests/components/calendar/test_google.py @@ -0,0 +1,421 @@ +"""The tests for the google calendar component.""" +# pylint: disable=protected-access +import logging +import unittest +from unittest.mock import patch + +import homeassistant.components.calendar as calendar_base +import homeassistant.components.calendar.google as calendar +import homeassistant.util.dt as dt_util +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.helpers.template import DATE_STR_FORMAT +from tests.common import get_test_home_assistant + +TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} + +_LOGGER = logging.getLogger(__name__) + + +class TestComponentsGoogleCalendar(unittest.TestCase): + """Test the Google calendar.""" + + hass = None # HomeAssistant + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + dt_util.set_default_time_zone(dt_util.get_time_zone('America/Regina')) + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + dt_util.set_default_time_zone(dt_util.get_time_zone('UTC')) + + self.hass.stop() + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_all_day_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + week_from_today = dt_util.dt.date.today() \ + + dt_util.dt.timedelta(days=7) + event = { + 'summary': 'Test All Day Event', + 'start': { + 'date': week_from_today.isoformat() + }, + 'end': { + 'date': (week_from_today + dt_util.dt.timedelta(days=1)) + .isoformat() + }, + 'location': 'Test Cases', + 'description': 'We\'re just testing that all day events get setup ' + 'correctly', + 'kind': 'calendar#event', + 'created': '2016-06-23T16:37:57.000Z', + 'transparency': 'transparent', + 'updated': '2016-06-24T01:57:21.045Z', + 'reminders': {'useDefault': True}, + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'id': '_c8rinwq863h45qnucyoi43ny8', + 'etag': '"2933466882090000"', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + 'iCalUID': 'cydrevtfuybguinhomj@google.com', + 'status': 'confirmed' + } + + mock_next_event.return_value.event = event + + device_name = 'Test All Day' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, + '', {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_OFF) + + self.assertFalse(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event['summary'], + 'all_day': True, + 'offset_reached': False, + 'start_time': '{} 06:00:00'.format(event['start']['date']), + 'end_time': '{} 06:00:00'.format(event['end']['date']), + 'location': event['location'], + 'description': event['description'] + }) + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_future_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + one_hour_from_now = dt_util.now() \ + + dt_util.dt.timedelta(minutes=30) + event = { + 'start': { + 'dateTime': one_hour_from_now.isoformat() + }, + 'end': { + 'dateTime': (one_hour_from_now + + dt_util.dt.timedelta(minutes=60)) + .isoformat() + }, + 'summary': 'Test Event in 30 minutes', + 'reminders': {'useDefault': True}, + 'id': 'aioehgni435lihje', + 'status': 'confirmed', + 'updated': '2016-11-05T15:52:07.329Z', + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True, + }, + 'created': '2016-11-05T15:52:07.000Z', + 'iCalUID': 'dsfohuygtfvgbhnuju@google.com', + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + }, + 'etag': '"2956722254658000"', + 'kind': 'calendar#event', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + } + mock_next_event.return_value.event = event + + device_name = 'Test Future Event' + device_id = 'test_future_event' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, + {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_OFF) + + self.assertFalse(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event['summary'], + 'all_day': False, + 'offset_reached': False, + 'start_time': one_hour_from_now.strftime(DATE_STR_FORMAT), + 'end_time': + (one_hour_from_now + dt_util.dt.timedelta(minutes=60)) + .strftime(DATE_STR_FORMAT), + 'location': '', + 'description': '' + }) + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_in_progress_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + middle_of_event = dt_util.now() \ + - dt_util.dt.timedelta(minutes=30) + event = { + 'start': { + 'dateTime': middle_of_event.isoformat() + }, + 'end': { + 'dateTime': (middle_of_event + dt_util.dt + .timedelta(minutes=60)) + .isoformat() + }, + 'summary': 'Test Event in Progress', + 'reminders': {'useDefault': True}, + 'id': 'aioehgni435lihje', + 'status': 'confirmed', + 'updated': '2016-11-05T15:52:07.329Z', + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True, + }, + 'created': '2016-11-05T15:52:07.000Z', + 'iCalUID': 'dsfohuygtfvgbhnuju@google.com', + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + }, + 'etag': '"2956722254658000"', + 'kind': 'calendar#event', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + } + + mock_next_event.return_value.event = event + + device_name = 'Test Event in Progress' + device_id = 'test_event_in_progress' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, + {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_ON) + + self.assertFalse(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event['summary'], + 'all_day': False, + 'offset_reached': False, + 'start_time': middle_of_event.strftime(DATE_STR_FORMAT), + 'end_time': + (middle_of_event + dt_util.dt.timedelta(minutes=60)) + .strftime(DATE_STR_FORMAT), + 'location': '', + 'description': '' + }) + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_offset_in_progress_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + middle_of_event = dt_util.now() \ + + dt_util.dt.timedelta(minutes=14) + event_summary = 'Test Event in Progress' + event = { + 'start': { + 'dateTime': middle_of_event.isoformat() + }, + 'end': { + 'dateTime': (middle_of_event + dt_util.dt + .timedelta(minutes=60)) + .isoformat() + }, + 'summary': '{} !!-15'.format(event_summary), + 'reminders': {'useDefault': True}, + 'id': 'aioehgni435lihje', + 'status': 'confirmed', + 'updated': '2016-11-05T15:52:07.329Z', + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True, + }, + 'created': '2016-11-05T15:52:07.000Z', + 'iCalUID': 'dsfohuygtfvgbhnuju@google.com', + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + }, + 'etag': '"2956722254658000"', + 'kind': 'calendar#event', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + } + + mock_next_event.return_value.event = event + + device_name = 'Test Event in Progress' + device_id = 'test_event_in_progress' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, + {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_OFF) + + self.assertTrue(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event_summary, + 'all_day': False, + 'offset_reached': True, + 'start_time': middle_of_event.strftime(DATE_STR_FORMAT), + 'end_time': + (middle_of_event + dt_util.dt.timedelta(minutes=60)) + .strftime(DATE_STR_FORMAT), + 'location': '', + 'description': '' + }) + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_all_day_offset_in_progress_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + tomorrow = dt_util.dt.date.today() \ + + dt_util.dt.timedelta(days=1) + + offset_hours = (25 - dt_util.now().hour) + event_summary = 'Test All Day Event Offset In Progress' + event = { + 'summary': '{} !!-{}:0'.format(event_summary, offset_hours), + 'start': { + 'date': tomorrow.isoformat() + }, + 'end': { + 'date': (tomorrow + dt_util.dt.timedelta(days=1)) + .isoformat() + }, + 'location': 'Test Cases', + 'description': 'We\'re just testing that all day events get setup ' + 'correctly', + 'kind': 'calendar#event', + 'created': '2016-06-23T16:37:57.000Z', + 'transparency': 'transparent', + 'updated': '2016-06-24T01:57:21.045Z', + 'reminders': {'useDefault': True}, + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'id': '_c8rinwq863h45qnucyoi43ny8', + 'etag': '"2933466882090000"', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + 'iCalUID': 'cydrevtfuybguinhomj@google.com', + 'status': 'confirmed' + } + + mock_next_event.return_value.event = event + + device_name = 'Test All Day Offset In Progress' + device_id = 'test_all_day_offset_in_progress' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, + {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_OFF) + + self.assertTrue(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event_summary, + 'all_day': True, + 'offset_reached': True, + 'start_time': '{} 06:00:00'.format(event['start']['date']), + 'end_time': '{} 06:00:00'.format(event['end']['date']), + 'location': event['location'], + 'description': event['description'] + }) + + @patch('homeassistant.components.calendar.google.GoogleCalendarData') + def test_all_day_offset_event(self, mock_next_event): + """Test that we can create an event trigger on device.""" + tomorrow = dt_util.dt.date.today() \ + + dt_util.dt.timedelta(days=2) + + offset_hours = (1 + dt_util.now().hour) + event_summary = 'Test All Day Event Offset' + event = { + 'summary': '{} !!-{}:0'.format(event_summary, offset_hours), + 'start': { + 'date': tomorrow.isoformat() + }, + 'end': { + 'date': (tomorrow + dt_util.dt.timedelta(days=1)) + .isoformat() + }, + 'location': 'Test Cases', + 'description': 'We\'re just testing that all day events get setup ' + 'correctly', + 'kind': 'calendar#event', + 'created': '2016-06-23T16:37:57.000Z', + 'transparency': 'transparent', + 'updated': '2016-06-24T01:57:21.045Z', + 'reminders': {'useDefault': True}, + 'organizer': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'sequence': 0, + 'creator': { + 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com', + 'displayName': 'Organizer Name', + 'self': True + }, + 'id': '_c8rinwq863h45qnucyoi43ny8', + 'etag': '"2933466882090000"', + 'htmlLink': 'https://www.google.com/calendar/event?eid=*******', + 'iCalUID': 'cydrevtfuybguinhomj@google.com', + 'status': 'confirmed' + } + + mock_next_event.return_value.event = event + + device_name = 'Test All Day Offset' + device_id = 'test_all_day_offset' + + cal = calendar.GoogleCalendarEventDevice(self.hass, None, device_id, + {'name': device_name}) + + self.assertEquals(cal.name, device_name) + + self.assertEquals(cal.state, STATE_OFF) + + self.assertFalse(cal.offset_reached()) + + self.assertEquals(cal.device_state_attributes, { + 'message': event_summary, + 'all_day': True, + 'offset_reached': False, + 'start_time': '{} 06:00:00'.format(event['start']['date']), + 'end_time': '{} 06:00:00'.format(event['end']['date']), + 'location': event['location'], + 'description': event['description'] + }) diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py new file mode 100644 index 00000000000..164c3f57f52 --- /dev/null +++ b/tests/components/calendar/test_init.py @@ -0,0 +1 @@ +"""The tests for the calendar component.""" diff --git a/tests/components/test_google.py b/tests/components/test_google.py new file mode 100644 index 00000000000..aaaaf8a9983 --- /dev/null +++ b/tests/components/test_google.py @@ -0,0 +1,90 @@ +"""The tests for the Google Calendar component.""" +import logging +import unittest + +import homeassistant.components.google as google +from homeassistant.bootstrap import setup_component +from tests.common import get_test_home_assistant + +_LOGGER = logging.getLogger(__name__) + + +class TestGoogle(unittest.TestCase): + """Test the Google component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_component(self): + """Test setup component.""" + config = { + 'google': { + 'client_id': 'id', + 'client_secret': 'secret', + } + } + + self.assertTrue(setup_component(self.hass, 'google', config)) + + def test_get_calendar_info(self): + calendar = { + 'id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com', + 'etag': '"3584134138943410"', + 'timeZone': 'UTC', + 'accessRole': 'reader', + 'foregroundColor': '#000000', + 'selected': True, + 'kind': 'calendar#calendarListEntry', + 'backgroundColor': '#16a765', + 'description': 'Test Calendar', + 'summary': 'We are, we are, a... Test Calendar', + 'colorId': '8', + 'defaultReminders': [], + 'track': True + } + + calendar_info = google.get_calendar_info(self.hass, calendar) + self.assertEquals(calendar_info, { + 'cal_id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com', + 'entities': [{ + 'device_id': 'we_are_we_are_a_test_calendar', + 'name': 'We are, we are, a... Test Calendar', + 'track': True, + }] + }) + + def test_found_calendar(self): + calendar = { + 'id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com', + 'etag': '"3584134138943410"', + 'timeZone': 'UTC', + 'accessRole': 'reader', + 'foregroundColor': '#000000', + 'selected': True, + 'kind': 'calendar#calendarListEntry', + 'backgroundColor': '#16a765', + 'description': 'Test Calendar', + 'summary': 'We are, we are, a... Test Calendar', + 'colorId': '8', + 'defaultReminders': [], + 'track': True + } + + # self.assertIsInstance(self.hass.data[google.DATA_INDEX], dict) + # self.assertEquals(self.hass.data[google.DATA_INDEX], {}) + + calendar_service = google.GoogleCalendarService( + self.hass.config.path(google.TOKEN_FILE)) + self.assertTrue(google.setup_services(self.hass, True, + calendar_service)) + self.hass.services.call('google', 'found_calendar', calendar, + blocking=True) + + # TODO: Fix this + # self.assertTrue(self.hass.data[google.DATA_INDEX] + # # .get(calendar['id'], None) is not None) -- GitLab