From dc42b6358aa84a8ec7a856c57fd5b01922d5d791 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 21 Jul 2017 20:18:57 -0400 Subject: [PATCH] Support for Wink oauth application authorization (#8208) --- .../www_static/images/config_wink.png | Bin 0 -> 8174 bytes homeassistant/components/wink.py | 321 ++++++++++++++---- 2 files changed, 262 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/frontend/www_static/images/config_wink.png diff --git a/homeassistant/components/frontend/www_static/images/config_wink.png b/homeassistant/components/frontend/www_static/images/config_wink.png new file mode 100644 index 0000000000000000000000000000000000000000..6b91f8cb58ee80f95c0e2277940b629e23a2cfd3 GIT binary patch literal 8174 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 Fe*h|;;rsvq literal 0 HcmV?d00001 diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 7024291e7fe..58a6f51b67b 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -7,15 +7,21 @@ https://home-assistant.io/components/wink/ import logging import time import json +import os from datetime import timedelta import voluptuous as vol +import requests +from homeassistant.loader import get_component +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView from homeassistant.helpers import discovery from homeassistant.helpers.event import track_time_interval from homeassistant.const import ( - CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, __version__) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -23,11 +29,10 @@ REQUIREMENTS = ['python-wink==1.3.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) -CHANNELS = [] - DOMAIN = 'wink' SUBSCRIPTION_HANDLER = None + CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' CONF_USER_AGENT = 'user_agent' @@ -37,8 +42,24 @@ CONF_DEFINED_BOTH_MSG = 'Remove access token to use oath2.' CONF_MISSING_OATH_MSG = 'Missing oath2 credentials.' CONF_TOKEN_URL = "https://winkbearertoken.appspot.com/token" +ATTR_ACCESS_TOKEN = 'access_token' +ATTR_REFRESH_TOKEN = 'refresh_token' +ATTR_CLIENT_ID = 'client_id' +ATTR_CLIENT_SECRET = 'client_secret' + +WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' +WINK_AUTH_START = '/auth/wink' +WINK_CONFIG_FILE = '.wink.conf' +USER_AGENT = "Manufacturer/Home-Assistant%s python/3 Wink/3" % (__version__) + +DEFAULT_CONFIG = { + 'client_id': 'CLIENT_ID_HERE', + 'client_secret': 'CLIENT_SECRET_HERE' +} + SERVICE_ADD_NEW_DEVICES = 'add_new_devices' SERVICE_REFRESH_STATES = 'refresh_state_from_wink' +SERVICE_KEEP_ALIVE = 'keep_pubnub_updates_flowing' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -52,11 +73,6 @@ CONFIG_SCHEMA = vol.Schema({ msg=CONF_MISSING_OATH_MSG): cv.string, vol.Exclusive(CONF_EMAIL, CONF_OATH, msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Exclusive(CONF_ACCESS_TOKEN, CONF_OATH, - msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Exclusive(CONF_ACCESS_TOKEN, CONF_APPSPOT, - msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Optional(CONF_USER_AGENT, default=None): cv.string }) }, extra=vol.ALLOW_EXTRA) @@ -66,30 +82,118 @@ WINK_COMPONENTS = [ ] +def _write_config_file(file_path, config): + try: + with open(file_path, 'w') as conf_file: + conf_file.write(json.dumps(config, sort_keys=True, indent=4)) + except IOError as error: + _LOGGER.error("Saving config file failed: %s", error) + raise IOError("Saving Wink config file failed") + return config + + +def _read_config_file(file_path): + try: + with open(file_path, 'r') as conf_file: + return json.loads(conf_file.read()) + except IOError as error: + _LOGGER.error("Reading config file failed: %s", error) + raise IOError("Reading Wink config file failed") + + +def _request_app_setup(hass, config): + """Assist user with configuring the Wink dev application.""" + hass.data['configurator'] = True + configurator = get_component('configurator') + + # pylint: disable=unused-argument + def wink_configuration_callback(callback_data): + """Handle configuration updates.""" + _config_path = hass.config.path(WINK_CONFIG_FILE) + if not os.path.isfile(_config_path): + setup(hass, config) + return + + client_id = callback_data.get('client_id') + client_secret = callback_data.get('client_secret') + if None not in (client_id, client_secret): + _write_config_file(_config_path, + {ATTR_CLIENT_ID: client_id, + ATTR_CLIENT_SECRET: client_secret}) + setup(hass, config) + return + else: + error_msg = ("Your input was invalid. Please try again.") + _configurator = hass.data[DOMAIN]['configuring'][DOMAIN] + configurator.notify_errors(_configurator, error_msg) + + start_url = "{}{}".format(hass.config.api.base_url, + WINK_AUTH_CALLBACK_PATH) + + description = """Please create a Wink developer app at + https://developer.wink.com. + Add a Redirect URI of {}. + They will provide you a Client ID and secret + after reviewing your request. + (This can take several days). + """.format(start_url) + + hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( + hass, DOMAIN, wink_configuration_callback, + description=description, submit_caption="submit", + description_image="/static/images/config_wink.png", + fields=[{'id': 'client_id', 'name': 'Client ID', 'type': 'string'}, + {'id': 'client_secret', + 'name': 'Client secret', + 'type': 'string'}] + ) + + +def _request_oauth_completion(hass, config): + """Request user complete Wink OAuth2 flow.""" + hass.data['configurator'] = True + configurator = get_component('configurator') + if DOMAIN in hass.data[DOMAIN]['configuring']: + configurator.notify_errors( + hass.data[DOMAIN]['configuring'][DOMAIN], + "Failed to register, please try again.") + return + + # pylint: disable=unused-argument + def wink_configuration_callback(callback_data): + """Call setup again.""" + setup(hass, config) + + start_url = '{}{}'.format(hass.config.api.base_url, WINK_AUTH_START) + + description = "Please authorize Wink by visiting {}".format(start_url) + + hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( + hass, DOMAIN, wink_configuration_callback, + description=description + ) + + def setup(hass, config): """Set up the Wink component.""" import pywink - import requests from pubnubsubhandler import PubNubSubscriptionHandler - hass.data[DOMAIN] = {} - hass.data[DOMAIN]['entities'] = [] - hass.data[DOMAIN]['unique_ids'] = [] - hass.data[DOMAIN]['entities'] = {} - - user_agent = config[DOMAIN].get(CONF_USER_AGENT) - - if user_agent: - pywink.set_user_agent(user_agent) - - access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) - client_id = config[DOMAIN].get('client_id') + if hass.data.get(DOMAIN) is None: + hass.data[DOMAIN] = { + 'unique_ids': [], + 'entities': {}, + 'oauth': {}, + 'configuring': {}, + 'pubnub': None, + 'configurator': False + } def _get_wink_token_from_web(): - email = hass.data[DOMAIN]["oath"]["email"] - password = hass.data[DOMAIN]["oath"]["password"] + _email = hass.data[DOMAIN]["oauth"]["email"] + _password = hass.data[DOMAIN]["oauth"]["password"] - payload = {'username': email, 'password': password} + payload = {'username': _email, 'password': _password} token_response = requests.post(CONF_TOKEN_URL, data=payload) try: token = token_response.text.split(':')[1].split()[0].rstrip('Wink Auth +

{}

""" + + if data.get('code') is not None: + response = self.request_token(data.get('code'), + self.config_file["client_secret"]) + + config_contents = { + ATTR_ACCESS_TOKEN: response['access_token'], + ATTR_REFRESH_TOKEN: response['refresh_token'], + ATTR_CLIENT_ID: self.config_file["client_id"], + ATTR_CLIENT_SECRET: self.config_file["client_secret"] + } + _write_config_file(hass.config.path(WINK_CONFIG_FILE), + config_contents) + + hass.async_add_job(setup, hass, self.config) + + return web.Response(text=html_response.format(response_message), + content_type='text/html') + + error_msg = "No code returned from Wink API" + _LOGGER.error(error_msg) + return web.Response(text=html_response.format(error_msg), + content_type='text/html') + + class WinkDevice(Entity): """Representation a base Wink device."""