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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 Qfq{X+)78&qol`;+04kp0`~Uy| 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."""