Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions custom_components/smartthings_customize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Validate and setup the app.
#app = await api.app(entry.data[CONF_APP_ID])
smart_app = setup_smartapp(hass, app)
elif not smart_app.public_key and app and app.webhook_public_key:
# The SmartApp was registered during the OAuth setup flow without a
# public key (no PAT available at that time). Now that we have a
# valid access token and the full app info, apply the public key so
# that incoming webhook requests are properly signature-verified.
smart_app.public_key = app.webhook_public_key

# Validate and retrieve the installed app.
installed_app = await validate_installed_app(
Expand Down
249 changes: 100 additions & 149 deletions custom_components/smartthings_customize/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
"""Config flow to configure SmartThings."""

from collections.abc import Mapping
from http import HTTPStatus
import logging
from typing import Any

from aiohttp import ClientResponseError
from .pysmartthings import APIResponseError, AppOAuth, SmartThings
from .pysmartthings import SmartThings
from .pysmartthings.installedapp import format_install_url
import voluptuous as vol

Expand All @@ -18,8 +16,6 @@
from homeassistant.core import callback

from .const import (
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
Expand All @@ -31,31 +27,27 @@
CONF_ENABLE_SYNTAX_PROPERTY,
)
from .smartapp import (
create_app,
find_app,
format_unique_id,
get_webhook_url,
setup_smartapp,
setup_smartapp_for_oauth,
setup_smartapp_endpoint,
update_app,
validate_webhook_requirements,
)

_LOGGER = logging.getLogger(__name__)



class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle configuration of SmartThings integrations."""

VERSION = 2

api: SmartThings
app_id: str
location_id: str

def __init__(self) -> None:
"""Create a new instance of the flow handler."""
self.access_token: str | None = None
self.oauth_client_secret = None
self.oauth_client_id = None
self.installed_app_id = None
Expand Down Expand Up @@ -97,159 +89,104 @@ async def async_step_user(
)

# Show the next screen
return await self.async_step_pat()
return await self.async_step_oauth_credentials()

async def async_step_pat(
async def async_step_oauth_credentials(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Get the Personal Access Token and validate it."""
"""Collect the OAuth credentials created in the SmartThings Developer Portal."""
errors: dict[str, str] = {}
if user_input is None or CONF_ACCESS_TOKEN not in user_input:
return self._show_step_pat(errors)

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_pat(errors)

# Setup end-point
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)
# 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_CLIENT_ID]
self.oauth_client_secret = existing.data[CONF_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

except APIResponseError as ex:
if ex.is_target_error():
errors["base"] = "webhook_error"
else:
errors["base"] = "app_setup_error"
_LOGGER.exception(
"API error setting up the SmartApp: %s", ex.raw_error_response
)
return self._show_step_pat(errors)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
_LOGGER.debug(
"Unauthorized error received setting up SmartApp", exc_info=True
)
elif ex.status == HTTPStatus.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_pat(errors)
except Exception:
errors["base"] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_pat(errors)

return await self.async_step_select_location()

async def async_step_select_location(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""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")
if user_input is None:
return self._show_step_oauth_credentials(errors)

app_id = user_input.get(CONF_APP_ID, "").strip()
client_id = user_input.get(CONF_CLIENT_ID, "").strip()
client_secret = user_input.get(CONF_CLIENT_SECRET, "").strip()

# Validate app_id is UUID format
if not VAL_UID_MATCHER.match(app_id):
errors[CONF_APP_ID] = "app_id_invalid_format"
return self._show_step_oauth_credentials(errors)

self.app_id = app_id
self.oauth_client_id = client_id
self.oauth_client_secret = client_secret

# If an existing entry already uses this app_id, reuse its OAuth credentials
# so the user does not have to re-enter them for a second location.
existing = next(
(
entry
for entry in self._async_current_entries()
if entry.data.get(CONF_APP_ID) == app_id
),
None,
)
if existing:
self.oauth_client_id = existing.data[CONF_CLIENT_ID]
self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET]

return self.async_show_form(
step_id="select_location",
data_schema=vol.Schema(
{vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
),
)
# Store app_id in flow context so the webhook callback can locate this flow.
self.context["app_id"] = app_id

# Register the SmartApp with the webhook manager so incoming lifecycle
# events (INSTALL, PING, etc.) can be dispatched. The public key is not
# yet available because we have no access token; signature verification
# will be enabled once async_setup_entry fetches the full app info.
setup_smartapp_for_oauth(self.hass, app_id)

self.location_id = user_input[CONF_LOCATION_ID]
await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id))
return await self.async_step_authorize()

def _show_step_oauth_credentials(self, errors):
webhook_url = get_webhook_url(self.hass)
return self.async_show_form(
step_id="oauth_credentials",
data_schema=vol.Schema(
{
vol.Required(CONF_APP_ID): str,
vol.Required(CONF_CLIENT_ID): str,
vol.Required(CONF_CLIENT_SECRET): str,
}
),
errors=errors,
description_placeholders={
"developer_url": "https://smartthings.developer.samsung.com/",
"webhook_url": webhook_url,
},
)

async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""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)

# The OAuth callback includes the location chosen by the user in SmartThings.
if CONF_LOCATION_ID in user_input:
self.location_id = user_input[CONF_LOCATION_ID]

if self.installed_app_id is None:
# Launch the external setup URL
url = format_install_url(self.app_id, self.location_id)
# Launch the external setup URL.
# location_id may not be known yet for OAuth flows; SmartThings will
# let the user pick a location on its own authorization page.
location_id = getattr(self, "location_id", "") or ""
url = format_install_url(self.app_id, location_id)
return self.async_external_step(step_id="authorize", url=url)

# Set the unique_id now that we have both app_id and location_id.
if not self.context.get("unique_id"):
await self.async_set_unique_id(
format_unique_id(self.app_id, self.location_id)
)
self._abort_if_unique_id_configured()

next_step_id = "install"
if self.source == SOURCE_REAUTH:
next_step_id = "update"
return self.async_external_step_done(next_step_id=next_step_id)

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(
{vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str}
),
errors=errors,
description_placeholders={
"token_url": "https://account.smartthings.com/tokens",
"component_url": (
"https://www.home-assistant.io/integrations/smartthings/"
),
},
)

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
Expand All @@ -262,8 +199,11 @@ async def async_step_reauth_confirm(
"""Handle re-authentication of an existing config entry."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
self.app_id = self._get_reauth_entry().data[CONF_APP_ID]
self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID]
entry = self._get_reauth_entry()
self.app_id = entry.data[CONF_APP_ID]
self.location_id = entry.data[CONF_LOCATION_ID]
self.oauth_client_id = entry.data[CONF_CLIENT_ID]
self.oauth_client_secret = entry.data[CONF_CLIENT_SECRET]
self._set_confirm_only()
return await self.async_step_authorize()

Expand All @@ -287,20 +227,31 @@ async def async_step_update_confirm(

async def async_step_install(self, data=None):
"""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,
session = async_get_clientsession(self.hass)

# Exchange the refresh token for a fresh access token.
api = SmartThings(session, "")
token = await api.generate_tokens(
self.oauth_client_id,
self.oauth_client_secret,
self.refresh_token,
)

config_data = {
CONF_ACCESS_TOKEN: token.access_token,
CONF_REFRESH_TOKEN: token.refresh_token,
CONF_CLIENT_ID: self.oauth_client_id,
CONF_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])
self.location = location
# Fetch the location name to use as the config entry title.
api = SmartThings(session, token.access_token)
location = await api.location(config_data[CONF_LOCATION_ID])

return self.async_create_entry(title=location.name, data=data)
return self.async_create_entry(title=location.name, data=config_data)

@staticmethod
@callback
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def _init_installed_app(self, installed_app):
async def process(self, app, headers: list = None,
validate_signature: bool = True) -> Response:
"""Process the request with the SmartApp."""
if validate_signature and self._supports_validation:
if validate_signature and self._supports_validation and app.public_key:
try:
verifier = HeaderVerifier(
headers=headers, secret=app.public_key, method='POST',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ def public_key(self):
"""Get the public key of the SmartApp used to verify events."""
return self._public_key

@public_key.setter
def public_key(self, value):
"""Set the public key of the SmartApp used to verify events."""
self._public_key = value


class SmartAppManager(SmartAppBase):
"""Service to support multiple SmartApps at the same end-point."""
Expand Down
Loading