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:
parent
bf33169627
commit
075030f15a
10 changed files with 543 additions and 382 deletions
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue