439 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			439 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
"""Support for Google - Calendar Event Devices."""
 | 
						|
from datetime import datetime, timedelta, timezone
 | 
						|
from enum import Enum
 | 
						|
import logging
 | 
						|
import os
 | 
						|
 | 
						|
from googleapiclient import discovery as google_discovery
 | 
						|
import httplib2
 | 
						|
from oauth2client.client import (
 | 
						|
    FlowExchangeError,
 | 
						|
    OAuth2DeviceCodeError,
 | 
						|
    OAuth2WebServerFlow,
 | 
						|
)
 | 
						|
from oauth2client.file import Storage
 | 
						|
import voluptuous as vol
 | 
						|
from voluptuous.error import Error as VoluptuousError
 | 
						|
import yaml
 | 
						|
 | 
						|
from homeassistant.const import (
 | 
						|
    CONF_CLIENT_ID,
 | 
						|
    CONF_CLIENT_SECRET,
 | 
						|
    CONF_DEVICE_ID,
 | 
						|
    CONF_ENTITIES,
 | 
						|
    CONF_NAME,
 | 
						|
    CONF_OFFSET,
 | 
						|
)
 | 
						|
from homeassistant.helpers import discovery
 | 
						|
import homeassistant.helpers.config_validation as cv
 | 
						|
from homeassistant.helpers.entity import generate_entity_id
 | 
						|
from homeassistant.helpers.event import track_utc_time_change
 | 
						|
from homeassistant.util import convert
 | 
						|
 | 
						|
_LOGGER = logging.getLogger(__name__)
 | 
						|
 | 
						|
DOMAIN = "google"
 | 
						|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
 | 
						|
 | 
						|
CONF_TRACK_NEW = "track_new_calendar"
 | 
						|
 | 
						|
CONF_CAL_ID = "cal_id"
 | 
						|
CONF_TRACK = "track"
 | 
						|
CONF_SEARCH = "search"
 | 
						|
CONF_IGNORE_AVAILABILITY = "ignore_availability"
 | 
						|
CONF_MAX_RESULTS = "max_results"
 | 
						|
CONF_CALENDAR_ACCESS = "calendar_access"
 | 
						|
 | 
						|
DEFAULT_CONF_TRACK_NEW = True
 | 
						|
DEFAULT_CONF_OFFSET = "!!"
 | 
						|
 | 
						|
EVENT_CALENDAR_ID = "calendar_id"
 | 
						|
EVENT_DESCRIPTION = "description"
 | 
						|
EVENT_END_CONF = "end"
 | 
						|
EVENT_END_DATE = "end_date"
 | 
						|
EVENT_END_DATETIME = "end_date_time"
 | 
						|
EVENT_IN = "in"
 | 
						|
EVENT_IN_DAYS = "days"
 | 
						|
EVENT_IN_WEEKS = "weeks"
 | 
						|
EVENT_START_CONF = "start"
 | 
						|
EVENT_START_DATE = "start_date"
 | 
						|
EVENT_START_DATETIME = "start_date_time"
 | 
						|
EVENT_SUMMARY = "summary"
 | 
						|
EVENT_TYPES_CONF = "event_types"
 | 
						|
 | 
						|
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"
 | 
						|
SERVICE_ADD_EVENT = "add_event"
 | 
						|
 | 
						|
DATA_INDEX = "google_calendars"
 | 
						|
 | 
						|
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
 | 
						|
 | 
						|
TOKEN_FILE = f".{DOMAIN}.token"
 | 
						|
 | 
						|
 | 
						|
class FeatureAccess(Enum):
 | 
						|
    """Class to represent different access scopes."""
 | 
						|
 | 
						|
    read_only = "https://www.googleapis.com/auth/calendar.readonly"
 | 
						|
    read_write = "https://www.googleapis.com/auth/calendar"
 | 
						|
 | 
						|
    def __init__(self, scope: str) -> None:
 | 
						|
        """Init instance."""
 | 
						|
        self._scope = scope
 | 
						|
 | 
						|
    @property
 | 
						|
    def scope(self) -> str:
 | 
						|
        """Google calendar scope for the feature."""
 | 
						|
        return self._scope
 | 
						|
 | 
						|
 | 
						|
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,
 | 
						|
                vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum(
 | 
						|
                    FeatureAccess
 | 
						|
                ),
 | 
						|
            }
 | 
						|
        )
 | 
						|
    },
 | 
						|
    extra=vol.ALLOW_EXTRA,
 | 
						|
)
 | 
						|
 | 
						|
_SINGLE_CALSEARCH_CONFIG = vol.All(
 | 
						|
    cv.deprecated(CONF_MAX_RESULTS),
 | 
						|
    vol.Schema(
 | 
						|
        {
 | 
						|
            vol.Required(CONF_NAME): cv.string,
 | 
						|
            vol.Required(CONF_DEVICE_ID): cv.string,
 | 
						|
            vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean,
 | 
						|
            vol.Optional(CONF_OFFSET): cv.string,
 | 
						|
            vol.Optional(CONF_SEARCH): cv.string,
 | 
						|
            vol.Optional(CONF_TRACK): cv.boolean,
 | 
						|
            vol.Optional(CONF_MAX_RESULTS): cv.positive_int,  # Now unused
 | 
						|
        }
 | 
						|
    ),
 | 
						|
)
 | 
						|
 | 
						|
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,
 | 
						|
)
 | 
						|
 | 
						|
_EVENT_IN_TYPES = vol.Schema(
 | 
						|
    {
 | 
						|
        vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int,
 | 
						|
        vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int,
 | 
						|
    }
 | 
						|
)
 | 
						|
 | 
						|
ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
 | 
						|
    {
 | 
						|
        vol.Required(EVENT_CALENDAR_ID): cv.string,
 | 
						|
        vol.Required(EVENT_SUMMARY): cv.string,
 | 
						|
        vol.Optional(EVENT_DESCRIPTION, default=""): cv.string,
 | 
						|
        vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date,
 | 
						|
        vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date,
 | 
						|
        vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime,
 | 
						|
        vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime,
 | 
						|
        vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): _EVENT_IN_TYPES,
 | 
						|
    }
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
def do_authentication(hass, hass_config, config):
 | 
						|
    """Notify user of actions and authenticate.
 | 
						|
 | 
						|
    Notify user of user_code and verification_url then poll
 | 
						|
    until we have an access token.
 | 
						|
    """
 | 
						|
    oauth = OAuth2WebServerFlow(
 | 
						|
        client_id=config[CONF_CLIENT_ID],
 | 
						|
        client_secret=config[CONF_CLIENT_SECRET],
 | 
						|
        scope=config[CONF_CALENDAR_ACCESS].scope,
 | 
						|
        redirect_uri="Home-Assistant.io",
 | 
						|
    )
 | 
						|
    try:
 | 
						|
        dev_flow = oauth.step1_get_device_and_user_codes()
 | 
						|
    except OAuth2DeviceCodeError as err:
 | 
						|
        hass.components.persistent_notification.create(
 | 
						|
            f"Error: {err}<br />You will need to restart hass after fixing." "",
 | 
						|
            title=NOTIFICATION_TITLE,
 | 
						|
            notification_id=NOTIFICATION_ID,
 | 
						|
        )
 | 
						|
        return False
 | 
						|
 | 
						|
    hass.components.persistent_notification.create(
 | 
						|
        (
 | 
						|
            f"In order to authorize Home-Assistant to view your calendars "
 | 
						|
            f'you must visit: <a href="{dev_flow.verification_url}" target="_blank">{dev_flow.verification_url}</a> and enter '
 | 
						|
            f"code: {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."""
 | 
						|
 | 
						|
        # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
 | 
						|
        # object without tzinfo. For the comparison below to work, it needs one.
 | 
						|
        user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc)
 | 
						|
 | 
						|
        if now >= user_code_expiry:
 | 
						|
            hass.components.persistent_notification.create(
 | 
						|
                "Authentication 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, hass_config, config)
 | 
						|
        listener()
 | 
						|
        hass.components.persistent_notification.create(
 | 
						|
            (
 | 
						|
                f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
 | 
						|
                f"been found"
 | 
						|
            ),
 | 
						|
            title=NOTIFICATION_TITLE,
 | 
						|
            notification_id=NOTIFICATION_ID,
 | 
						|
        )
 | 
						|
 | 
						|
    listener = track_utc_time_change(
 | 
						|
        hass, step2_exchange, second=range(0, 60, dev_flow.interval)
 | 
						|
    )
 | 
						|
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
def setup(hass, config):
 | 
						|
    """Set up the Google platform."""
 | 
						|
    if DATA_INDEX not in hass.data:
 | 
						|
        hass.data[DATA_INDEX] = {}
 | 
						|
 | 
						|
    conf = config.get(DOMAIN, {})
 | 
						|
    if not conf:
 | 
						|
        # component is set up by tts platform
 | 
						|
        return True
 | 
						|
 | 
						|
    token_file = hass.config.path(TOKEN_FILE)
 | 
						|
    if not os.path.isfile(token_file):
 | 
						|
        do_authentication(hass, config, conf)
 | 
						|
    else:
 | 
						|
        if not check_correct_scopes(token_file, conf):
 | 
						|
            do_authentication(hass, config, conf)
 | 
						|
        else:
 | 
						|
            do_setup(hass, config, conf)
 | 
						|
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
def check_correct_scopes(token_file, config):
 | 
						|
    """Check for the correct scopes in file."""
 | 
						|
    with open(token_file, encoding="utf8") as tokenfile:
 | 
						|
        contents = tokenfile.read()
 | 
						|
 | 
						|
        # Check for quoted scope as our scopes can be subsets of other scopes
 | 
						|
        target_scope = f'"{config.get(CONF_CALENDAR_ACCESS).scope}"'
 | 
						|
        if target_scope not in contents:
 | 
						|
            _LOGGER.warning("Please re-authenticate with Google")
 | 
						|
            return False
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
def setup_services(
 | 
						|
    hass, hass_config, config, track_new_found_calendars, calendar_service
 | 
						|
):
 | 
						|
    """Set up the 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]) 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_config,
 | 
						|
        )
 | 
						|
 | 
						|
    hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
 | 
						|
 | 
						|
    def _scan_for_calendars(service):
 | 
						|
        """Scan for new calendars."""
 | 
						|
        service = calendar_service.get()
 | 
						|
        cal_list = service.calendarList()
 | 
						|
        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)
 | 
						|
 | 
						|
    def _add_event(call):
 | 
						|
        """Add a new event to calendar."""
 | 
						|
        service = calendar_service.get()
 | 
						|
        start = {}
 | 
						|
        end = {}
 | 
						|
 | 
						|
        if EVENT_IN in call.data:
 | 
						|
            if EVENT_IN_DAYS in call.data[EVENT_IN]:
 | 
						|
                now = datetime.now()
 | 
						|
 | 
						|
                start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
 | 
						|
                end_in = start_in + timedelta(days=1)
 | 
						|
 | 
						|
                start = {"date": start_in.strftime("%Y-%m-%d")}
 | 
						|
                end = {"date": end_in.strftime("%Y-%m-%d")}
 | 
						|
 | 
						|
            elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
 | 
						|
                now = datetime.now()
 | 
						|
 | 
						|
                start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
 | 
						|
                end_in = start_in + timedelta(days=1)
 | 
						|
 | 
						|
                start = {"date": start_in.strftime("%Y-%m-%d")}
 | 
						|
                end = {"date": end_in.strftime("%Y-%m-%d")}
 | 
						|
 | 
						|
        elif EVENT_START_DATE in call.data:
 | 
						|
            start = {"date": str(call.data[EVENT_START_DATE])}
 | 
						|
            end = {"date": str(call.data[EVENT_END_DATE])}
 | 
						|
 | 
						|
        elif EVENT_START_DATETIME in call.data:
 | 
						|
            start_dt = str(
 | 
						|
                call.data[EVENT_START_DATETIME].strftime("%Y-%m-%dT%H:%M:%S")
 | 
						|
            )
 | 
						|
            end_dt = str(call.data[EVENT_END_DATETIME].strftime("%Y-%m-%dT%H:%M:%S"))
 | 
						|
            start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
 | 
						|
            end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
 | 
						|
 | 
						|
        event = {
 | 
						|
            "summary": call.data[EVENT_SUMMARY],
 | 
						|
            "description": call.data[EVENT_DESCRIPTION],
 | 
						|
            "start": start,
 | 
						|
            "end": end,
 | 
						|
        }
 | 
						|
        service_data = {"calendarId": call.data[EVENT_CALENDAR_ID], "body": event}
 | 
						|
        event = service.events().insert(**service_data).execute()
 | 
						|
 | 
						|
    # Only expose the add event service if we have the correct permissions
 | 
						|
    if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
 | 
						|
        hass.services.register(
 | 
						|
            DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
 | 
						|
        )
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
def do_setup(hass, hass_config, 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, hass_config, config, track_new_found_calendars, calendar_service
 | 
						|
    )
 | 
						|
 | 
						|
    for calendar in hass.data[DATA_INDEX].values():
 | 
						|
        discovery.load_platform(hass, "calendar", DOMAIN, calendar, hass_config)
 | 
						|
 | 
						|
    # Look for any new calendars
 | 
						|
    hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
class GoogleCalendarService:
 | 
						|
    """Calendar service interface to Google."""
 | 
						|
 | 
						|
    def __init__(self, token_file):
 | 
						|
        """Init the Google Calendar service."""
 | 
						|
        self.token_file = token_file
 | 
						|
 | 
						|
    def get(self):
 | 
						|
        """Get the calendar service from the storage file token."""
 | 
						|
        credentials = Storage(self.token_file).get()
 | 
						|
        http = credentials.authorize(httplib2.Http())
 | 
						|
        service = google_discovery.build(
 | 
						|
            "calendar", "v3", http=http, cache_discovery=False
 | 
						|
        )
 | 
						|
        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, encoding="utf8") as file:
 | 
						|
            data = yaml.safe_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", encoding="utf8") as out:
 | 
						|
        out.write("\n")
 | 
						|
        yaml.dump([calendar], out, default_flow_style=False)
 |