Update SmartThings config flow to be entirely UI based (#34163)

* bump pysmartthings 0.7.1

* Update config flow to use UI

* Code review comments and fix for resetting oauth client

* Replace html with markdown
This commit is contained in:
Andrew Sayre 2020-04-14 17:26:53 -05:00 committed by GitHub
parent bf33169627
commit 075030f15a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 543 additions and 382 deletions

View file

@ -3,26 +3,30 @@ import logging
from aiohttp import ClientResponseError
from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
# pylint: disable=unused-import
from .const import (
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_INSTALLED_APPS,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_OAUTH_CLIENT_ID,
CONF_OAUTH_CLIENT_SECRET,
CONF_REFRESH_TOKEN,
DOMAIN,
VAL_UID_MATCHER,
)
from .smartapp import (
create_app,
find_app,
get_webhook_url,
setup_smartapp,
setup_smartapp_endpoint,
update_app,
@ -32,23 +36,8 @@ from .smartapp import (
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN)
class SmartThingsFlowHandler(config_entries.ConfigFlow):
"""
Handle configuration of SmartThings integrations.
Any number of integrations are supported. The high level flow follows:
1) Flow initiated
a) User initiates through the UI
b) Re-configuration of a failed entry setup
2) Enter access token
a) Check not already setup
b) Validate format
c) Setup SmartApp
3) Wait for Installation
a) Check user installed into one or more locations
b) Config entries setup for all installations
"""
class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle configuration of SmartThings integrations."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
@ -60,55 +49,84 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
self.api = None
self.oauth_client_secret = None
self.oauth_client_id = None
self.installed_app_id = None
self.refresh_token = None
self.location_id = None
async def async_step_import(self, user_input=None):
"""Occurs when a previously entry setup fails and is re-initiated."""
return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None):
"""Get access token and validate it."""
"""Validate and confirm webhook setup."""
await setup_smartapp_endpoint(self.hass)
webhook_url = get_webhook_url(self.hass)
# Abort if the webhook is invalid
if not validate_webhook_requirements(self.hass):
return self.async_abort(
reason="invalid_webhook_url",
description_placeholders={
"webhook_url": webhook_url,
"component_url": "https://www.home-assistant.io/integrations/smartthings/",
},
)
# Show the confirmation
if user_input is None:
return self.async_show_form(
step_id="user", description_placeholders={"webhook_url": webhook_url},
)
# Show the next screen
return await self.async_step_pat()
async def async_step_pat(self, user_input=None):
"""Get the Personal Access Token and validate it."""
errors = {}
if user_input is None or CONF_ACCESS_TOKEN not in user_input:
return self._show_step_user(errors)
return self._show_step_pat(errors)
self.access_token = user_input.get(CONF_ACCESS_TOKEN, "")
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
self.access_token = user_input[CONF_ACCESS_TOKEN]
# Ensure token is a UUID
if not VAL_UID_MATCHER.match(self.access_token):
errors[CONF_ACCESS_TOKEN] = "token_invalid_format"
return self._show_step_user(errors)
# Check not already setup in another entry
if any(
entry.data.get(CONF_ACCESS_TOKEN) == self.access_token
for entry in self.hass.config_entries.async_entries(DOMAIN)
):
errors[CONF_ACCESS_TOKEN] = "token_already_setup"
return self._show_step_user(errors)
return self._show_step_pat(errors)
# Setup end-point
await setup_smartapp_endpoint(self.hass)
if not validate_webhook_requirements(self.hass):
errors["base"] = "base_url_not_https"
return self._show_step_user(errors)
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
try:
app = await find_app(self.hass, self.api)
if app:
await app.refresh() # load all attributes
await update_app(self.hass, app)
# Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth)
# Find an existing entry to copy the oauth client
existing = next(
(
entry
for entry in self._async_current_entries()
if entry.data[CONF_APP_ID] == app.app_id
),
None,
)
if existing:
self.oauth_client_id = existing.data[CONF_OAUTH_CLIENT_ID]
self.oauth_client_secret = existing.data[CONF_OAUTH_CLIENT_SECRET]
else:
# Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
else:
app, client = await create_app(self.hass, self.api)
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
setup_smartapp(self.hass, app)
self.app_id = app.app_id
self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id
except APIResponseError as ex:
if ex.is_target_error():
@ -118,58 +136,80 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
_LOGGER.exception(
"API error setting up the SmartApp: %s", ex.raw_error_response
)
return self._show_step_user(errors)
return self._show_step_pat(errors)
except ClientResponseError as ex:
if ex.status == 401:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
_LOGGER.debug(
"Unauthorized error received setting up SmartApp", exc_info=True
)
elif ex.status == HTTP_FORBIDDEN:
errors[CONF_ACCESS_TOKEN] = "token_forbidden"
_LOGGER.debug(
"Forbidden error received setting up SmartApp", exc_info=True
)
else:
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_user(errors)
return self._show_step_pat(errors)
except Exception: # pylint:disable=broad-except
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_user(errors)
return self._show_step_pat(errors)
return await self.async_step_wait_install()
return await self.async_step_select_location()
async def async_step_wait_install(self, user_input=None):
"""Wait for SmartApp installation."""
errors = {}
if user_input is None:
return self._show_step_wait_install(errors)
async def async_step_select_location(self, user_input=None):
"""Ask user to select the location to setup."""
if user_input is None or CONF_LOCATION_ID not in user_input:
# Get available locations
existing_locations = [
entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries()
]
locations = await self.api.locations()
locations_options = {
location.location_id: location.name
for location in locations
if location.location_id not in existing_locations
}
if not locations_options:
return self.async_abort(reason="no_available_locations")
# Find installed apps that were authorized
installed_apps = self.hass.data[DOMAIN][CONF_INSTALLED_APPS].copy()
if not installed_apps:
errors["base"] = "app_not_installed"
return self._show_step_wait_install(errors)
self.hass.data[DOMAIN][CONF_INSTALLED_APPS].clear()
# Enrich the data
for installed_app in installed_apps:
installed_app[CONF_APP_ID] = self.app_id
installed_app[CONF_ACCESS_TOKEN] = self.access_token
installed_app[CONF_OAUTH_CLIENT_ID] = self.oauth_client_id
installed_app[CONF_OAUTH_CLIENT_SECRET] = self.oauth_client_secret
# User may have installed the SmartApp in more than one SmartThings
# location. Config flows are created for the additional installations
for installed_app in installed_apps[1:]:
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
DOMAIN, context={"source": "install"}, data=installed_app
)
return self.async_show_form(
step_id="select_location",
data_schema=vol.Schema(
{vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
),
)
# Create config entity for the first one.
return await self.async_step_install(installed_apps[0])
self.location_id = user_input[CONF_LOCATION_ID]
return await self.async_step_authorize()
async def async_step_authorize(self, user_input=None):
"""Wait for the user to authorize the app installation."""
user_input = {} if user_input is None else user_input
self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID)
self.refresh_token = user_input.get(CONF_REFRESH_TOKEN)
if self.installed_app_id is None:
# Launch the external setup URL
url = format_install_url(self.app_id, self.location_id)
return self.async_external_step(step_id="authorize", url=url)
return self.async_external_step_done(next_step_id="install")
def _show_step_pat(self, errors):
if self.access_token is None:
# Get the token from an existing entry to make it easier to setup multiple locations.
self.access_token = next(
(
entry.data.get(CONF_ACCESS_TOKEN)
for entry in self._async_current_entries()
),
None,
)
def _show_step_user(self, errors):
return self.async_show_form(
step_id="user",
step_id="pat",
data_schema=vol.Schema(
{vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str}
),
@ -180,21 +220,18 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
},
)
def _show_step_wait_install(self, errors):
return self.async_show_form(step_id="wait_install", errors=errors)
async def async_step_install(self, data=None):
"""
Create a config entry at completion of a flow.
Launched when the user completes the flow or when the SmartApp
is installed into an additional location.
"""
if not self.api:
# Launched from the SmartApp install event handler
self.api = SmartThings(
async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN]
)
"""Create a config entry at completion of a flow and authorization of the app."""
data = {
CONF_ACCESS_TOKEN: self.access_token,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_OAUTH_CLIENT_ID: self.oauth_client_id,
CONF_OAUTH_CLIENT_SECRET: self.oauth_client_secret,
CONF_LOCATION_ID: self.location_id,
CONF_APP_ID: self.app_id,
CONF_INSTALLED_APP_ID: self.installed_app_id,
}
location = await self.api.location(data[CONF_LOCATION_ID])
return self.async_create_entry(title=location.name, data=data)