Support for Wink oauth application authorization (#8208)

This commit is contained in:
William Scanlon 2017-07-21 20:18:57 -04:00 committed by GitHub
parent 06ceadfd54
commit dc42b6358a
2 changed files with 262 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -7,15 +7,21 @@ https://home-assistant.io/components/wink/
import logging import logging
import time import time
import json import json
import os
from datetime import timedelta from datetime import timedelta
import voluptuous as vol 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 import discovery
from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.event import track_time_interval
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP, __version__)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
import homeassistant.helpers.config_validation as cv 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__) _LOGGER = logging.getLogger(__name__)
CHANNELS = []
DOMAIN = 'wink' DOMAIN = 'wink'
SUBSCRIPTION_HANDLER = None SUBSCRIPTION_HANDLER = None
CONF_CLIENT_ID = 'client_id' CONF_CLIENT_ID = 'client_id'
CONF_CLIENT_SECRET = 'client_secret' CONF_CLIENT_SECRET = 'client_secret'
CONF_USER_AGENT = 'user_agent' 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_MISSING_OATH_MSG = 'Missing oath2 credentials.'
CONF_TOKEN_URL = "https://winkbearertoken.appspot.com/token" 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_ADD_NEW_DEVICES = 'add_new_devices'
SERVICE_REFRESH_STATES = 'refresh_state_from_wink' SERVICE_REFRESH_STATES = 'refresh_state_from_wink'
SERVICE_KEEP_ALIVE = 'keep_pubnub_updates_flowing'
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({ DOMAIN: vol.Schema({
@ -52,11 +73,6 @@ CONFIG_SCHEMA = vol.Schema({
msg=CONF_MISSING_OATH_MSG): cv.string, msg=CONF_MISSING_OATH_MSG): cv.string,
vol.Exclusive(CONF_EMAIL, CONF_OATH, vol.Exclusive(CONF_EMAIL, CONF_OATH,
msg=CONF_DEFINED_BOTH_MSG): cv.string, 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) }, 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): def setup(hass, config):
"""Set up the Wink component.""" """Set up the Wink component."""
import pywink import pywink
import requests
from pubnubsubhandler import PubNubSubscriptionHandler from pubnubsubhandler import PubNubSubscriptionHandler
hass.data[DOMAIN] = {} if hass.data.get(DOMAIN) is None:
hass.data[DOMAIN]['entities'] = [] hass.data[DOMAIN] = {
hass.data[DOMAIN]['unique_ids'] = [] 'unique_ids': [],
hass.data[DOMAIN]['entities'] = {} 'entities': {},
'oauth': {},
user_agent = config[DOMAIN].get(CONF_USER_AGENT) 'configuring': {},
'pubnub': None,
if user_agent: 'configurator': False
pywink.set_user_agent(user_agent) }
access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN)
client_id = config[DOMAIN].get('client_id')
def _get_wink_token_from_web(): def _get_wink_token_from_web():
email = hass.data[DOMAIN]["oath"]["email"] _email = hass.data[DOMAIN]["oauth"]["email"]
password = hass.data[DOMAIN]["oath"]["password"] _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) token_response = requests.post(CONF_TOKEN_URL, data=payload)
try: try:
token = token_response.text.split(':')[1].split()[0].rstrip('<br') token = token_response.text.split(':')[1].split()[0].rstrip('<br')
@ -98,59 +202,106 @@ def setup(hass, config):
return False return False
pywink.set_bearer_token(token) pywink.set_bearer_token(token)
if access_token: client_id = config[DOMAIN].get(ATTR_CLIENT_ID)
pywink.set_bearer_token(access_token) client_secret = config[DOMAIN].get(ATTR_CLIENT_SECRET)
elif client_id: email = config[DOMAIN].get(CONF_EMAIL)
email = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN].get(CONF_PASSWORD)
password = config[DOMAIN][CONF_PASSWORD] if None not in [client_id, client_secret]:
client_id = config[DOMAIN]['client_id'] _LOGGER.info("Using legacy oauth authentication")
client_secret = config[DOMAIN]['client_secret'] hass.data[DOMAIN]["oauth"]["client_id"] = client_id
hass.data[DOMAIN]["oauth"]["client_secret"] = client_secret
hass.data[DOMAIN]["oauth"]["email"] = email
hass.data[DOMAIN]["oauth"]["password"] = password
pywink.legacy_set_wink_credentials(email, password, pywink.legacy_set_wink_credentials(email, password,
client_id, client_secret) client_id, client_secret)
hass.data[DOMAIN]['oath'] = {"email": email, elif None not in [email, password]:
"password": password, _LOGGER.info("Using web form authentication")
"client_id": client_id, hass.data[DOMAIN]["oauth"]["email"] = email
"client_secret": client_secret} hass.data[DOMAIN]["oauth"]["password"] = password
else:
email = config[DOMAIN][CONF_EMAIL]
password = config[DOMAIN][CONF_PASSWORD]
hass.data[DOMAIN]['oath'] = {"email": email, "password": password}
_get_wink_token_from_web() _get_wink_token_from_web()
else:
_LOGGER.info("Using new oauth authentication")
config_path = hass.config.path(WINK_CONFIG_FILE)
if os.path.isfile(config_path):
config_file = _read_config_file(config_path)
if config_file == DEFAULT_CONFIG:
_request_app_setup(hass, config)
return True
# else move on because the user modified the file
else:
_write_config_file(config_path, DEFAULT_CONFIG)
_request_app_setup(hass, config)
return True
if DOMAIN in hass.data[DOMAIN]['configuring']:
_configurator = hass.data[DOMAIN]['configuring']
get_component('configurator').request_done(_configurator.pop(
DOMAIN))
# Using oauth
access_token = config_file.get(ATTR_ACCESS_TOKEN)
refresh_token = config_file.get(ATTR_REFRESH_TOKEN)
# This will be called after authorizing Home-Assistant
if None not in (access_token, refresh_token):
pywink.set_wink_credentials(config_file.get(ATTR_CLIENT_ID),
config_file.get(ATTR_CLIENT_SECRET),
access_token=access_token,
refresh_token=refresh_token)
# This is called to create the redirect so the user can Authorize
# Home-Assistant
else:
redirect_uri = '{}{}'.format(hass.config.api.base_url,
WINK_AUTH_CALLBACK_PATH)
wink_auth_start_url = pywink.get_authorization_url(
config_file.get(ATTR_CLIENT_ID), redirect_uri)
hass.http.register_redirect(WINK_AUTH_START, wink_auth_start_url)
hass.http.register_view(WinkAuthCallbackView(config,
config_file,
pywink.request_token))
_request_oauth_completion(hass, config)
return True
pywink.set_user_agent(USER_AGENT)
hass.data[DOMAIN]['pubnub'] = PubNubSubscriptionHandler( hass.data[DOMAIN]['pubnub'] = PubNubSubscriptionHandler(
pywink.get_subscription_key()) pywink.get_subscription_key())
def _subscribe():
hass.data[DOMAIN]['pubnub'].subscribe()
# Call subscribe after the user sets up wink via the configurator
# All other methods will complete setup before
# EVENT_HOMEASSISTANT_START is called meaning they
# will call subscribe via the method below. (start_subscription)
if hass.data[DOMAIN]['configurator']:
_subscribe()
def keep_alive_call(event_time): def keep_alive_call(event_time):
"""Call the Wink API endpoints to keep PubNub working.""" """Call the Wink API endpoints to keep PubNub working."""
_LOGGER.info("Getting a new Wink token.")
if hass.data[DOMAIN]["oath"].get("client_id") is not None:
_email = hass.data[DOMAIN]["oath"]["email"]
_password = hass.data[DOMAIN]["oath"]["password"]
_client_id = hass.data[DOMAIN]["oath"]["client_id"]
_client_secret = hass.data[DOMAIN]["oath"]["client_secret"]
pywink.set_wink_credentials(_email, _password, _client_id,
_client_secret)
else:
_LOGGER.info("Getting a new Wink token.")
_get_wink_token_from_web()
time.sleep(1)
_LOGGER.info("Polling the Wink API to keep PubNub updates flowing.") _LOGGER.info("Polling the Wink API to keep PubNub updates flowing.")
_LOGGER.debug(str(json.dumps(pywink.wink_api_fetch()))) pywink.set_user_agent(str(int(time.time())))
_temp_response = pywink.get_user()
_LOGGER.debug(str(json.dumps(_temp_response)))
time.sleep(1) time.sleep(1)
_LOGGER.debug(str(json.dumps(pywink.get_user()))) pywink.set_user_agent(USER_AGENT)
_temp_response = pywink.wink_api_fetch()
_LOGGER.debug(str(json.dumps(_temp_response)))
# Call the Wink API every hour to keep PubNub updates flowing # Call the Wink API every hour to keep PubNub updates flowing
if access_token is None: track_time_interval(hass, keep_alive_call, timedelta(minutes=60))
track_time_interval(hass, keep_alive_call, timedelta(minutes=120))
def start_subscription(event): def start_subscription(event):
"""Start the pubnub subscription.""" """Start the pubnub subscription."""
hass.data[DOMAIN]['pubnub'].subscribe() _subscribe()
hass.bus.listen(EVENT_HOMEASSISTANT_START, start_subscription) hass.bus.listen(EVENT_HOMEASSISTANT_START, start_subscription)
def stop_subscription(event): def stop_subscription(event):
"""Stop the pubnub subscription.""" """Stop the pubnub subscription."""
hass.data[DOMAIN]['pubnub'].unsubscribe() hass.data[DOMAIN]['pubnub'].unsubscribe()
hass.bus.listen(EVENT_HOMEASSISTANT_STOP, stop_subscription) hass.bus.listen(EVENT_HOMEASSISTANT_STOP, stop_subscription)
def force_update(call): def force_update(call):
@ -166,8 +317,9 @@ def setup(hass, config):
def pull_new_devices(call): def pull_new_devices(call):
"""Pull new devices added to users Wink account since startup.""" """Pull new devices added to users Wink account since startup."""
_LOGGER.info("Getting new devices from Wink API") _LOGGER.info("Getting new devices from Wink API")
for component in WINK_COMPONENTS: for _component in WINK_COMPONENTS:
discovery.load_platform(hass, component, DOMAIN, {}, config) discovery.load_platform(hass, _component, DOMAIN, {}, config)
hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices) hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices)
# Load components for the devices in Wink that we support # Load components for the devices in Wink that we support
@ -178,6 +330,57 @@ def setup(hass, config):
return True return True
class WinkAuthCallbackView(HomeAssistantView):
"""Handle OAuth finish callback requests."""
url = '/auth/wink/callback'
name = 'auth:wink:callback'
requires_auth = False
def __init__(self, config, config_file, request_token):
"""Initialize the OAuth callback view."""
self.config = config
self.config_file = config_file
self.request_token = request_token
@callback
def get(self, request):
"""Finish OAuth callback request."""
from aiohttp import web
hass = request.app['hass']
data = request.GET
response_message = """Wink has been successfully authorized!
You can close this window now! For the best results you should reboot
HomeAssistant"""
html_response = """<html><head><title>Wink Auth</title></head>
<body><h1>{}</h1></body></html>"""
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): class WinkDevice(Entity):
"""Representation a base Wink device.""" """Representation a base Wink device."""