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