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

@ -1,28 +1,39 @@
{ {
"config": { "config": {
"error": { "title": "SmartThings",
"app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.",
"base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.",
"token_already_setup": "The token has already been setup.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements."
},
"step": { "step": {
"user": { "user": {
"title": "Confirm Callback URL",
"description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
},
"pat": {
"title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
"data": { "data": {
"access_token": "Access Token" "access_token": "Access Token"
},
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).",
"title": "Enter Personal Access Token"
},
"wait_install": {
"description": "Please install the Home Assistant SmartApp in at least one location and click submit.",
"title": "Install SmartApp"
} }
}, },
"title": "SmartThings" "select_location": {
"title": "Select Location",
"description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": {
"location_id": "Location"
}
},
"authorize": {
"title": "Authorize Home Assistant"
}
},
"abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant."
},
"error": {
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.",
"webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
}
} }
} }

View file

@ -3,26 +3,30 @@ import logging
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from pysmartthings import APIResponseError, AppOAuth, SmartThings from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
# pylint: disable=unused-import
from .const import ( from .const import (
APP_OAUTH_CLIENT_NAME, APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES, APP_OAUTH_SCOPES,
CONF_APP_ID, CONF_APP_ID,
CONF_INSTALLED_APPS, CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_ID,
CONF_OAUTH_CLIENT_SECRET, CONF_OAUTH_CLIENT_SECRET,
CONF_REFRESH_TOKEN,
DOMAIN, DOMAIN,
VAL_UID_MATCHER, VAL_UID_MATCHER,
) )
from .smartapp import ( from .smartapp import (
create_app, create_app,
find_app, find_app,
get_webhook_url,
setup_smartapp, setup_smartapp,
setup_smartapp_endpoint, setup_smartapp_endpoint,
update_app, update_app,
@ -32,23 +36,8 @@ from .smartapp import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN) class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
class SmartThingsFlowHandler(config_entries.ConfigFlow): """Handle configuration of SmartThings integrations."""
"""
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
"""
VERSION = 2 VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
@ -60,55 +49,84 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
self.api = None self.api = None
self.oauth_client_secret = None self.oauth_client_secret = None
self.oauth_client_id = 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): async def async_step_import(self, user_input=None):
"""Occurs when a previously entry setup fails and is re-initiated.""" """Occurs when a previously entry setup fails and is re-initiated."""
return await self.async_step_user(user_input) return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None): 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 = {} errors = {}
if user_input is None or CONF_ACCESS_TOKEN not in user_input: 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.access_token = user_input[CONF_ACCESS_TOKEN]
self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
# Ensure token is a UUID # Ensure token is a UUID
if not VAL_UID_MATCHER.match(self.access_token): if not VAL_UID_MATCHER.match(self.access_token):
errors[CONF_ACCESS_TOKEN] = "token_invalid_format" errors[CONF_ACCESS_TOKEN] = "token_invalid_format"
return self._show_step_user(errors) return self._show_step_pat(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)
# Setup end-point # Setup end-point
await setup_smartapp_endpoint(self.hass) self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
if not validate_webhook_requirements(self.hass):
errors["base"] = "base_url_not_https"
return self._show_step_user(errors)
try: try:
app = await find_app(self.hass, self.api) app = await find_app(self.hass, self.api)
if app: if app:
await app.refresh() # load all attributes await app.refresh() # load all attributes
await update_app(self.hass, app) await update_app(self.hass, app)
# 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 # Get oauth client id/secret by regenerating it
app_oauth = AppOAuth(app.app_id) app_oauth = AppOAuth(app.app_id)
app_oauth.client_name = APP_OAUTH_CLIENT_NAME app_oauth.client_name = APP_OAUTH_CLIENT_NAME
app_oauth.scope.extend(APP_OAUTH_SCOPES) app_oauth.scope.extend(APP_OAUTH_SCOPES)
client = await self.api.generate_app_oauth(app_oauth) client = await self.api.generate_app_oauth(app_oauth)
else:
app, client = await create_app(self.hass, self.api)
setup_smartapp(self.hass, app)
self.app_id = app.app_id
self.oauth_client_secret = client.client_secret self.oauth_client_secret = client.client_secret
self.oauth_client_id = client.client_id 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
except APIResponseError as ex: except APIResponseError as ex:
if ex.is_target_error(): if ex.is_target_error():
@ -118,58 +136,80 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
_LOGGER.exception( _LOGGER.exception(
"API error setting up the SmartApp: %s", ex.raw_error_response "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: except ClientResponseError as ex:
if ex.status == 401: if ex.status == 401:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized" errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
_LOGGER.debug(
"Unauthorized error received setting up SmartApp", exc_info=True
)
elif ex.status == HTTP_FORBIDDEN: elif ex.status == HTTP_FORBIDDEN:
errors[CONF_ACCESS_TOKEN] = "token_forbidden" errors[CONF_ACCESS_TOKEN] = "token_forbidden"
_LOGGER.debug(
"Forbidden error received setting up SmartApp", exc_info=True
)
else: else:
errors["base"] = "app_setup_error" errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp") _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 except Exception: # pylint:disable=broad-except
errors["base"] = "app_setup_error" errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp") _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): async def async_step_select_location(self, user_input=None):
"""Wait for SmartApp installation.""" """Ask user to select the location to setup."""
errors = {} if user_input is None or CONF_LOCATION_ID not in user_input:
if user_input is None: # Get available locations
return self._show_step_wait_install(errors) 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
)
)
# Create config entity for the first one.
return await self.async_step_install(installed_apps[0])
def _show_step_user(self, errors):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="select_location",
data_schema=vol.Schema(
{vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
),
)
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,
)
return self.async_show_form(
step_id="pat",
data_schema=vol.Schema( data_schema=vol.Schema(
{vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} {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): async def async_step_install(self, data=None):
""" """Create a config entry at completion of a flow and authorization of the app."""
Create a config entry at completion of a flow. data = {
CONF_ACCESS_TOKEN: self.access_token,
Launched when the user completes the flow or when the SmartApp CONF_REFRESH_TOKEN: self.refresh_token,
is installed into an additional location. CONF_OAUTH_CLIENT_ID: self.oauth_client_id,
""" CONF_OAUTH_CLIENT_SECRET: self.oauth_client_secret,
if not self.api: CONF_LOCATION_ID: self.location_id,
# Launched from the SmartApp install event handler CONF_APP_ID: self.app_id,
self.api = SmartThings( CONF_INSTALLED_APP_ID: self.installed_app_id,
async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN] }
)
location = await self.api.location(data[CONF_LOCATION_ID]) location = await self.api.location(data[CONF_LOCATION_ID])
return self.async_create_entry(title=location.name, data=data) return self.async_create_entry(title=location.name, data=data)

View file

@ -8,7 +8,6 @@ APP_NAME_PREFIX = "homeassistant."
CONF_APP_ID = "app_id" CONF_APP_ID = "app_id"
CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_CLOUDHOOK_URL = "cloudhook_url"
CONF_INSTALLED_APP_ID = "installed_app_id" CONF_INSTALLED_APP_ID = "installed_app_id"
CONF_INSTALLED_APPS = "installed_apps"
CONF_INSTANCE_ID = "instance_id" CONF_INSTANCE_ID = "instance_id"
CONF_LOCATION_ID = "location_id" CONF_LOCATION_ID = "location_id"
CONF_OAUTH_CLIENT_ID = "client_id" CONF_OAUTH_CLIENT_ID = "client_id"

View file

@ -3,7 +3,7 @@
"name": "Smartthings", "name": "Smartthings",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smartthings", "documentation": "https://www.home-assistant.io/integrations/smartthings",
"requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.0"], "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.1"],
"dependencies": ["webhook"], "dependencies": ["webhook"],
"after_dependencies": ["cloud"], "after_dependencies": ["cloud"],
"codeowners": ["@andrewsayre"] "codeowners": ["@andrewsayre"]

View file

@ -36,10 +36,8 @@ from .const import (
APP_NAME_PREFIX, APP_NAME_PREFIX,
APP_OAUTH_CLIENT_NAME, APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES, APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_CLOUDHOOK_URL, CONF_CLOUDHOOK_URL,
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_INSTANCE_ID, CONF_INSTANCE_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN,
@ -258,7 +256,6 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType):
CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
# Will not be present if not enabled # Will not be present if not enabled
CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
CONF_INSTALLED_APPS: [],
} }
_LOGGER.debug( _LOGGER.debug(
"Setup endpoint for %s", "Setup endpoint for %s",
@ -370,40 +367,30 @@ async def smartapp_sync_subscriptions(
async def smartapp_install(hass: HomeAssistantType, req, resp, app): async def smartapp_install(hass: HomeAssistantType, req, resp, app):
""" """Handle a SmartApp installation and continue the config flow."""
Handle when a SmartApp is installed by the user into a location. flow = next(
Create a config entry representing the installation if this is not
the first installation under the account, otherwise store the data
for the config flow.
"""
install_data = {
CONF_INSTALLED_APP_ID: req.installed_app_id,
CONF_LOCATION_ID: req.location_id,
CONF_REFRESH_TOKEN: req.refresh_token,
}
# App attributes (client id/secret, etc...) are copied from another entry
# with the same parent app_id. If one is not found, the install data is
# stored for the config flow to retrieve during the wait step.
entry = next(
( (
entry flow
for entry in hass.config_entries.async_entries(DOMAIN) for flow in hass.config_entries.flow.async_progress()
if entry.data[CONF_APP_ID] == app.app_id if flow["handler"] == DOMAIN
), ),
None, None,
) )
if entry: if flow is not None:
data = entry.data.copy() await hass.config_entries.flow.async_configure(
data.update(install_data) flow["flow_id"],
# Add as job not needed because the current coroutine was invoked {
# from the dispatcher and is not being awaited. CONF_INSTALLED_APP_ID: req.installed_app_id,
await hass.config_entries.flow.async_init( CONF_LOCATION_ID: req.location_id,
DOMAIN, context={"source": "install"}, data=data CONF_REFRESH_TOKEN: req.refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
) )
else:
# Store the data where the flow can find it
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data)
_LOGGER.debug( _LOGGER.debug(
"Installed SmartApp '%s' under parent app '%s'", "Installed SmartApp '%s' under parent app '%s'",
@ -413,12 +400,7 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app):
async def smartapp_update(hass: HomeAssistantType, req, resp, app): async def smartapp_update(hass: HomeAssistantType, req, resp, app):
""" """Handle a SmartApp update and either update the entry or continue the flow."""
Handle when a SmartApp is updated (reconfigured) by the user.
Store the refresh token in the config entry.
"""
# Update refresh token in config entry
entry = next( entry = next(
( (
entry entry
@ -431,6 +413,36 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app):
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
) )
_LOGGER.debug(
"Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
entry.entry_id,
req.installed_app_id,
app.app_id,
)
flow = next(
(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["handler"] == DOMAIN
),
None,
)
if flow is not None:
await hass.config_entries.flow.async_configure(
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: req.installed_app_id,
CONF_LOCATION_ID: req.location_id,
CONF_REFRESH_TOKEN: req.refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
)
_LOGGER.debug( _LOGGER.debug(
"Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id

View file

@ -3,26 +3,37 @@
"title": "SmartThings", "title": "SmartThings",
"step": { "step": {
"user": { "user": {
"title": "Confirm Callback URL",
"description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
},
"pat": {
"title": "Enter Personal Access Token", "title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.",
"data": { "data": {
"access_token": "Access Token" "access_token": "Access Token"
} }
}, },
"wait_install": { "select_location": {
"title": "Install SmartApp", "title": "Select Location",
"description": "Please install the Home Assistant SmartApp in at least one location and click submit." "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": {
"location_id": "Location"
} }
}, },
"authorize": {
"title": "Authorize Home Assistant"
}
},
"abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to setup in Home Assistant."
},
"error": { "error": {
"token_invalid_format": "The token must be in the UID/GUID format", "token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized.", "token_unauthorized": "The token is invalid or no longer authorized.",
"token_forbidden": "The token does not have the required OAuth scopes.", "token_forbidden": "The token does not have the required OAuth scopes.",
"token_already_setup": "The token has already been setup.",
"app_setup_error": "Unable to setup the SmartApp. Please try again.", "app_setup_error": "Unable to setup the SmartApp. Please try again.",
"app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
"base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.",
"webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements."
} }
} }
} }

View file

@ -1551,7 +1551,7 @@ pysma==0.3.5
pysmartapp==0.3.2 pysmartapp==0.3.2
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==0.7.0 pysmartthings==0.7.1
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty==0.8 pysmarty==0.8

View file

@ -611,7 +611,7 @@ pysma==0.3.5
pysmartapp==0.3.2 pysmartapp==0.3.2
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==0.7.0 pysmartthings==0.7.1
# homeassistant.components.soma # homeassistant.components.soma
pysoma==0.0.10 pysoma==0.0.10

View file

@ -4,207 +4,266 @@ from uuid import uuid4
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from asynctest import Mock, patch from asynctest import Mock, patch
from pysmartthings import APIResponseError from pysmartthings import APIResponseError
from pysmartthings.installedapp import format_install_url
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings import smartapp
from homeassistant.components.smartthings.config_flow import SmartThingsFlowHandler from homeassistant.components.smartthings.config_flow import SmartThingsFlowHandler
from homeassistant.components.smartthings.const import ( from homeassistant.components.smartthings.const import (
CONF_APP_ID,
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_OAUTH_CLIENT_ID,
CONF_OAUTH_CLIENT_SECRET,
CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN,
DOMAIN, DOMAIN,
) )
from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_NOT_FOUND
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, mock_coro from tests.common import MockConfigEntry, mock_coro
async def test_step_user(hass): async def test_step_import(hass):
"""Test the access token form is shown for a user initiated flow.""" """Test import returns user."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_step_init(hass):
"""Test the access token form is shown for an init flow."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_import() result = await flow.async_step_import()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
async def test_base_url_not_https(hass): async def test_step_user(hass):
"""Test the base_url parameter starts with https://.""" """Test the webhook confirmation is shown."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
async def test_step_user_aborts_invalid_webhook(hass):
"""Test flow aborts if webhook is invalid."""
hass.config.api.base_url = "http://0.0.0.0" hass.config.api.base_url = "http://0.0.0.0"
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
result = await flow.async_step_user({"access_token": str(uuid4())})
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "invalid_webhook_url"
assert result["description_placeholders"][
"webhook_url"
] == smartapp.get_webhook_url(hass)
assert "component_url" in result["description_placeholders"]
async def test_step_user_advances_to_pat(hass):
"""Test user step advances to the pat step."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_user({})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"base": "base_url_not_https"}
async def test_invalid_token_format(hass): async def test_step_pat(hass):
"""Test pat step shows the input form."""
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_pat()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pat"
assert result["errors"] == {}
assert result["data_schema"]({CONF_ACCESS_TOKEN: ""}) == {CONF_ACCESS_TOKEN: ""}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_step_pat_defaults_token(hass):
"""Test pat form defaults the token from another entry."""
token = str(uuid4())
entry = MockConfigEntry(domain=DOMAIN, data={CONF_ACCESS_TOKEN: token})
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_pat()
assert flow.access_token == token
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pat"
assert result["errors"] == {}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_step_pat_invalid_token(hass):
"""Test an error is shown for invalid token formats.""" """Test an error is shown for invalid token formats."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
result = await flow.async_step_user({"access_token": "123456789"}) token = "123456789"
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
assert result["errors"] == {"access_token": "token_invalid_format"} assert result["errors"] == {"access_token": "token_invalid_format"}
assert "token_url" in result["description_placeholders"]
assert "component_url" in result["description_placeholders"]
async def test_token_already_setup(hass): async def test_step_pat_unauthorized(hass, smartthings_mock):
"""Test an error is shown when the token is already setup."""
flow = SmartThingsFlowHandler()
flow.hass = hass
token = str(uuid4())
entry = MockConfigEntry(domain=DOMAIN, data={"access_token": token})
entry.add_to_hass(hass)
result = await flow.async_step_user({"access_token": token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"access_token": "token_already_setup"}
async def test_token_unauthorized(hass, smartthings_mock):
"""Test an error is shown when the token is not authorized.""" """Test an error is shown when the token is not authorized."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
request_info = Mock(real_url="http://example.com") request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError( smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=401 request_info=request_info, history=None, status=401
) )
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"access_token": "token_unauthorized"} assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_token_forbidden(hass, smartthings_mock): async def test_step_pat_forbidden(hass, smartthings_mock):
"""Test an error is shown when the token is forbidden.""" """Test an error is shown when the token is forbidden."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
request_info = Mock(real_url="http://example.com") request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError( smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=HTTP_FORBIDDEN request_info=request_info, history=None, status=HTTP_FORBIDDEN
) )
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"access_token": "token_forbidden"} assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_webhook_error(hass, smartthings_mock): async def test_step_pat_webhook_error(hass, smartthings_mock):
"""Test an error is when there's an error with the webhook endpoint.""" """Test an error is shown when there's an problem with the webhook endpoint."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
data = {"error": {}} data = {"error": {}}
request_info = Mock(real_url="http://example.com") request_info = Mock(real_url="http://example.com")
error = APIResponseError( error = APIResponseError(
request_info=request_info, history=None, data=data, status=422 request_info=request_info, history=None, data=data, status=422
) )
error.is_target_error = Mock(return_value=True) error.is_target_error = Mock(return_value=True)
smartthings_mock.apps.side_effect = error smartthings_mock.apps.side_effect = error
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"base": "webhook_error"} assert result["errors"] == {"base": "webhook_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_api_error(hass, smartthings_mock): async def test_step_pat_api_error(hass, smartthings_mock):
"""Test an error is shown when other API errors occur.""" """Test an error is shown when other API errors occur."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
data = {"error": {}} data = {"error": {}}
request_info = Mock(real_url="http://example.com") request_info = Mock(real_url="http://example.com")
error = APIResponseError( error = APIResponseError(
request_info=request_info, history=None, data=data, status=400 request_info=request_info, history=None, data=data, status=400
) )
smartthings_mock.apps.side_effect = error smartthings_mock.apps.side_effect = error
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"} assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_unknown_api_error(hass, smartthings_mock): async def test_step_pat_unknown_api_error(hass, smartthings_mock):
"""Test an error is shown when there is an unknown API error.""" """Test an error is shown when there is an unknown API error."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
request_info = Mock(real_url="http://example.com") request_info = Mock(real_url="http://example.com")
smartthings_mock.apps.side_effect = ClientResponseError( smartthings_mock.apps.side_effect = ClientResponseError(
request_info=request_info, history=None, status=HTTP_NOT_FOUND request_info=request_info, history=None, status=HTTP_NOT_FOUND
) )
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"} assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_unknown_error(hass, smartthings_mock): async def test_step_pat_unknown_error(hass, smartthings_mock):
"""Test an error is shown when there is an unknown API error.""" """Test an error is shown when there is an unknown API error."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
smartthings_mock.apps.side_effect = Exception("Unknown error") smartthings_mock.apps.side_effect = Exception("Unknown error")
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "pat"
assert result["errors"] == {"base": "app_setup_error"} assert result["errors"] == {"base": "app_setup_error"}
assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token}
async def test_app_created_then_show_wait_form( async def test_step_pat_app_created_webhook(
hass, app, app_oauth_client, smartthings_mock hass, app, app_oauth_client, location, smartthings_mock
): ):
"""Test SmartApp is created when one does not exist and shows wait form.""" """Test SmartApp is created when one does not exist and shows location form."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
smartthings_mock.apps.return_value = [] smartthings_mock.apps.return_value = []
smartthings_mock.create_app.return_value = (app, app_oauth_client) smartthings_mock.create_app.return_value = (app, app_oauth_client)
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install" assert result["step_id"] == "select_location"
async def test_cloudhook_app_created_then_show_wait_form( async def test_step_pat_app_created_cloudhook(
hass, app, app_oauth_client, smartthings_mock hass, app, app_oauth_client, location, smartthings_mock
): ):
"""Test SmartApp is created with a cloudhoko and shows wait form.""" """Test SmartApp is created with a cloudhook and shows location form."""
hass.config.components.add("cloud") hass.config.components.add("cloud")
# Unload the endpoint so we can reload it under the cloud. # Unload the endpoint so we can reload it under the cloud.
@ -224,128 +283,159 @@ async def test_cloudhook_app_created_then_show_wait_form(
flow.hass = hass flow.hass = hass
smartthings_mock.apps.return_value = [] smartthings_mock.apps.return_value = []
smartthings_mock.create_app.return_value = (app, app_oauth_client) smartthings_mock.create_app.return_value = (app, app_oauth_client)
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install" assert result["step_id"] == "select_location"
assert mock_create_cloudhook.call_count == 1 assert mock_create_cloudhook.call_count == 1
async def test_app_updated_then_show_wait_form( async def test_step_pat_app_updated_webhook(
hass, app, app_oauth_client, smartthings_mock hass, app, app_oauth_client, location, smartthings_mock
): ):
"""Test SmartApp is updated when an existing is already created.""" """Test SmartApp is updated then show location form."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
smartthings_mock.apps.return_value = [app] smartthings_mock.apps.return_value = [app]
smartthings_mock.generate_app_oauth.return_value = app_oauth_client smartthings_mock.generate_app_oauth.return_value = app_oauth_client
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_user({"access_token": str(uuid4())}) result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == app_oauth_client.client_secret
assert flow.oauth_client_id == app_oauth_client.client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install" assert result["step_id"] == "select_location"
async def test_wait_form_displayed(hass): async def test_step_pat_app_updated_webhook_from_existing_oauth_client(
"""Test the wait for installation form is displayed.""" hass, app, location, smartthings_mock
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_wait_install(None)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
async def test_wait_form_displayed_after_checking(hass, smartthings_mock):
"""Test error is shown when the user has not installed the app."""
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.access_token = str(uuid4())
result = await flow.async_step_wait_install({})
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "wait_install"
assert result["errors"] == {"base": "app_not_installed"}
async def test_config_entry_created_when_installed(
hass, location, installed_app, smartthings_mock
): ):
"""Test SmartApp is updated from existing then show location form."""
oauth_client_id = str(uuid4())
oauth_client_secret = str(uuid4())
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_APP_ID: app.app_id,
CONF_OAUTH_CLIENT_ID: oauth_client_id,
CONF_OAUTH_CLIENT_SECRET: oauth_client_secret,
CONF_LOCATION_ID: str(uuid4()),
},
)
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
smartthings_mock.apps.return_value = [app]
smartthings_mock.locations.return_value = [location]
token = str(uuid4())
result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token})
assert flow.access_token == token
assert flow.app_id == app.app_id
assert flow.oauth_client_secret == oauth_client_secret
assert flow.oauth_client_id == oauth_client_id
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "select_location"
async def test_step_select_location(hass, location, smartthings_mock):
"""Test select location shows form with available locations."""
smartthings_mock.locations.return_value = [location]
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.api = smartthings_mock
result = await flow.async_step_select_location()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "select_location"
assert result["data_schema"]({CONF_LOCATION_ID: location.location_id}) == {
CONF_LOCATION_ID: location.location_id
}
async def test_step_select_location_aborts(hass, location, smartthings_mock):
"""Test select location aborts if no available locations."""
smartthings_mock.locations.return_value = [location]
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id}
)
entry.add_to_hass(hass)
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.api = smartthings_mock
result = await flow.async_step_select_location()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "no_available_locations"
async def test_step_select_location_advances(hass):
"""Test select location aborts if no available locations."""
location_id = str(uuid4())
app_id = str(uuid4())
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.app_id = app_id
result = await flow.async_step_select_location({CONF_LOCATION_ID: location_id})
assert flow.location_id == location_id
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
assert result["step_id"] == "authorize"
assert result["url"] == format_install_url(app_id, location_id)
async def test_step_authorize_advances(hass):
"""Test authorize step advances when completed."""
installed_app_id = str(uuid4())
refresh_token = str(uuid4())
flow = SmartThingsFlowHandler()
flow.hass = hass
result = await flow.async_step_authorize(
{CONF_INSTALLED_APP_ID: installed_app_id, CONF_REFRESH_TOKEN: refresh_token}
)
assert flow.installed_app_id == installed_app_id
assert flow.refresh_token == refresh_token
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE
assert result["step_id"] == "install"
async def test_step_install_creates_entry(hass, location, smartthings_mock):
"""Test a config entry is created once the app is installed.""" """Test a config entry is created once the app is installed."""
flow = SmartThingsFlowHandler() flow = SmartThingsFlowHandler()
flow.hass = hass flow.hass = hass
flow.access_token = str(uuid4())
flow.app_id = installed_app.app_id
flow.api = smartthings_mock flow.api = smartthings_mock
flow.access_token = str(uuid4())
flow.app_id = str(uuid4())
flow.installed_app_id = str(uuid4())
flow.location_id = location.location_id
flow.oauth_client_id = str(uuid4()) flow.oauth_client_id = str(uuid4())
flow.oauth_client_secret = str(uuid4()) flow.oauth_client_secret = str(uuid4())
data = { flow.refresh_token = str(uuid4())
CONF_REFRESH_TOKEN: str(uuid4()),
CONF_LOCATION_ID: installed_app.location_id,
CONF_INSTALLED_APP_ID: installed_app.installed_app_id,
}
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
result = await flow.async_step_wait_install({}) result = await flow.async_step_install()
assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"]["app_id"] == installed_app.app_id assert result["data"]["app_id"] == flow.app_id
assert result["data"]["installed_app_id"] == installed_app.installed_app_id assert result["data"]["installed_app_id"] == flow.installed_app_id
assert result["data"]["location_id"] == installed_app.location_id assert result["data"]["location_id"] == flow.location_id
assert result["data"]["access_token"] == flow.access_token assert result["data"]["access_token"] == flow.access_token
assert result["data"]["refresh_token"] == data[CONF_REFRESH_TOKEN] assert result["data"]["refresh_token"] == flow.refresh_token
assert result["data"]["client_secret"] == flow.oauth_client_secret assert result["data"]["client_secret"] == flow.oauth_client_secret
assert result["data"]["client_id"] == flow.oauth_client_id assert result["data"]["client_id"] == flow.oauth_client_id
assert result["title"] == location.name assert result["title"] == location.name
async def test_multiple_config_entry_created_when_installed(
hass, app, locations, installed_apps, smartthings_mock
):
"""Test a config entries are created for multiple installs."""
assert await async_setup_component(hass, "persistent_notification", {})
flow = SmartThingsFlowHandler()
flow.hass = hass
flow.access_token = str(uuid4())
flow.app_id = app.app_id
flow.api = smartthings_mock
flow.oauth_client_id = str(uuid4())
flow.oauth_client_secret = str(uuid4())
for installed_app in installed_apps:
data = {
CONF_REFRESH_TOKEN: str(uuid4()),
CONF_LOCATION_ID: installed_app.location_id,
CONF_INSTALLED_APP_ID: installed_app.installed_app_id,
}
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
install_data = hass.data[DOMAIN][CONF_INSTALLED_APPS].copy()
result = await flow.async_step_wait_install({})
assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"]["app_id"] == installed_apps[0].app_id
assert result["data"]["installed_app_id"] == installed_apps[0].installed_app_id
assert result["data"]["location_id"] == installed_apps[0].location_id
assert result["data"]["access_token"] == flow.access_token
assert result["data"]["refresh_token"] == install_data[0][CONF_REFRESH_TOKEN]
assert result["data"]["client_secret"] == flow.oauth_client_secret
assert result["data"]["client_id"] == flow.oauth_client_id
assert result["title"] == locations[0].name
await hass.async_block_till_done()
entries = hass.config_entries.async_entries("smartthings")
assert len(entries) == 1
assert entries[0].data["app_id"] == installed_apps[1].app_id
assert entries[0].data["installed_app_id"] == installed_apps[1].installed_app_id
assert entries[0].data["location_id"] == installed_apps[1].location_id
assert entries[0].data["access_token"] == flow.access_token
assert entries[0].data["client_secret"] == flow.oauth_client_secret
assert entries[0].data["client_id"] == flow.oauth_client_id
assert entries[0].title == locations[1].name

View file

@ -7,7 +7,6 @@ from pysmartthings import AppEntity, Capability
from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings import smartapp
from homeassistant.components.smartthings.const import ( from homeassistant.components.smartthings.const import (
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN,
DATA_MANAGER, DATA_MANAGER,
@ -40,11 +39,11 @@ async def test_update_app_updated_needed(hass, app):
assert mock_app.classifications == app.classifications assert mock_app.classifications == app.classifications
async def test_smartapp_install_store_if_no_other( async def test_smartapp_install_configures_flow(hass):
hass, smartthings_mock, device_factory """Test install event continues an existing flow."""
):
"""Test aborts if no other app was configured already."""
# Arrange # Arrange
flow_id = str(uuid4())
flows = [{"flow_id": flow_id, "handler": DOMAIN}]
app = Mock() app = Mock()
app.app_id = uuid4() app.app_id = uuid4()
request = Mock() request = Mock()
@ -52,50 +51,22 @@ async def test_smartapp_install_store_if_no_other(
request.auth_token = str(uuid4()) request.auth_token = str(uuid4())
request.location_id = str(uuid4()) request.location_id = str(uuid4())
request.refresh_token = str(uuid4()) request.refresh_token = str(uuid4())
# Act
await smartapp.smartapp_install(hass, request, None, app)
# Assert
entries = hass.config_entries.async_entries("smartthings")
assert not entries
data = hass.data[DOMAIN][CONF_INSTALLED_APPS][0]
assert data[CONF_REFRESH_TOKEN] == request.refresh_token
assert data[CONF_LOCATION_ID] == request.location_id
assert data[CONF_INSTALLED_APP_ID] == request.installed_app_id
async def test_smartapp_install_creates_flow(
hass, smartthings_mock, config_entry, location, device_factory
):
"""Test installation creates flow."""
# Arrange
config_entry.add_to_hass(hass)
app = Mock()
app.app_id = config_entry.data["app_id"]
request = Mock()
request.installed_app_id = str(uuid4())
request.auth_token = str(uuid4())
request.refresh_token = str(uuid4())
request.location_id = location.location_id
devices = [
device_factory("", [Capability.battery, "ping"]),
device_factory("", [Capability.switch, Capability.switch_level]),
device_factory("", [Capability.switch]),
]
smartthings_mock.devices.return_value = devices
# Act # Act
with patch.object(
hass.config_entries.flow, "async_progress", return_value=flows
), patch.object(hass.config_entries.flow, "async_configure") as configure_mock:
await smartapp.smartapp_install(hass, request, None, app) await smartapp.smartapp_install(hass, request, None, app)
# Assert
await hass.async_block_till_done() configure_mock.assert_called_once_with(
entries = hass.config_entries.async_entries("smartthings") flow_id,
assert len(entries) == 2 {
assert entries[1].data["app_id"] == app.app_id CONF_INSTALLED_APP_ID: request.installed_app_id,
assert entries[1].data["installed_app_id"] == request.installed_app_id CONF_LOCATION_ID: request.location_id,
assert entries[1].data["location_id"] == request.location_id CONF_REFRESH_TOKEN: request.refresh_token,
assert entries[1].data["access_token"] == config_entry.data["access_token"] },
assert entries[1].data["refresh_token"] == request.refresh_token )
assert entries[1].data["client_secret"] == config_entry.data["client_secret"]
assert entries[1].data["client_id"] == config_entry.data["client_id"]
assert entries[1].title == location.name
async def test_smartapp_update_saves_token( async def test_smartapp_update_saves_token(
@ -121,6 +92,36 @@ async def test_smartapp_update_saves_token(
assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token
async def test_smartapp_update_configures_flow(hass):
"""Test update event continues an existing flow."""
# Arrange
flow_id = str(uuid4())
flows = [{"flow_id": flow_id, "handler": DOMAIN}]
app = Mock()
app.app_id = uuid4()
request = Mock()
request.installed_app_id = str(uuid4())
request.auth_token = str(uuid4())
request.location_id = str(uuid4())
request.refresh_token = str(uuid4())
# Act
with patch.object(
hass.config_entries.flow, "async_progress", return_value=flows
), patch.object(hass.config_entries.flow, "async_configure") as configure_mock:
await smartapp.smartapp_update(hass, request, None, app)
configure_mock.assert_called_once_with(
flow_id,
{
CONF_INSTALLED_APP_ID: request.installed_app_id,
CONF_LOCATION_ID: request.location_id,
CONF_REFRESH_TOKEN: request.refresh_token,
},
)
async def test_smartapp_uninstall(hass, config_entry): async def test_smartapp_uninstall(hass, config_entry):
"""Test the config entry is unloaded when the app is uninstalled.""" """Test the config entry is unloaded when the app is uninstalled."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)