core/homeassistant/components/smartthings/smartapp.py

473 lines
16 KiB
Python
Raw Normal View History

"""SmartApp functionality to receive cloud-push notifications."""
import asyncio
import functools
import logging
from urllib.parse import urlparse
from uuid import uuid4
from aiohttp import web
from pysmartapp import Dispatcher, SmartAppManager
from pysmartapp.const import SETTINGS_APP_ID
from pysmartthings import (
2019-07-31 19:25:30 +00:00
APP_TYPE_WEBHOOK,
CAPABILITIES,
CLASSIFICATION_AUTOMATION,
App,
AppOAuth,
AppSettings,
InstalledAppStatus,
SmartThings,
SourceType,
Subscription,
SubscriptionEntity,
)
from homeassistant.components import cloud, webhook
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
2019-07-31 19:25:30 +00:00
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
2019-07-31 19:25:30 +00:00
APP_NAME_PREFIX,
APP_OAUTH_CLIENT_NAME,
APP_OAUTH_SCOPES,
CONF_APP_ID,
CONF_CLOUDHOOK_URL,
CONF_INSTALLED_APP_ID,
CONF_INSTALLED_APPS,
CONF_INSTANCE_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DATA_BROKERS,
DATA_MANAGER,
DOMAIN,
SETTINGS_INSTANCE_ID,
SIGNAL_SMARTAPP_PREFIX,
STORAGE_KEY,
STORAGE_VERSION,
)
_LOGGER = logging.getLogger(__name__)
async def find_app(hass: HomeAssistantType, api):
"""Find an existing SmartApp for this installation of hass."""
apps = await api.apps()
2019-07-31 19:25:30 +00:00
for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]:
# Load settings to compare instance id
settings = await app.settings()
2019-07-31 19:25:30 +00:00
if (
settings.settings.get(SETTINGS_INSTANCE_ID)
== hass.data[DOMAIN][CONF_INSTANCE_ID]
):
return app
async def validate_installed_app(api, installed_app_id: str):
"""
Ensure the specified installed SmartApp is valid and functioning.
Query the API for the installed SmartApp and validate that it is tied to
the specified app_id and is in an authorized state.
"""
installed_app = await api.installed_app(installed_app_id)
if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED:
2019-07-31 19:25:30 +00:00
raise RuntimeWarning(
2019-08-27 23:08:09 +00:00
"Installed SmartApp instance '{}' ({}) is not AUTHORIZED but instead {}".format(
2019-07-31 19:25:30 +00:00
installed_app.display_name,
installed_app.installed_app_id,
installed_app.installed_app_status,
)
)
return installed_app
def validate_webhook_requirements(hass: HomeAssistantType) -> bool:
"""Ensure HASS is setup properly to receive webhooks."""
if cloud.async_active_subscription(hass):
return True
if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None:
return True
2019-07-31 19:25:30 +00:00
return get_webhook_url(hass).lower().startswith("https://")
def get_webhook_url(hass: HomeAssistantType) -> str:
"""
Get the URL of the webhook.
Return the cloudhook if available, otherwise local webhook.
"""
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass) and cloudhook_url is not None:
return cloudhook_url
return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
def _get_app_template(hass: HomeAssistantType):
endpoint = "at " + hass.config.api.base_url
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url is not None:
endpoint = "via Nabu Casa"
description = "{} {}".format(hass.config.location_name, endpoint)
return {
2019-07-31 19:25:30 +00:00
"app_name": APP_NAME_PREFIX + str(uuid4()),
"display_name": "Home Assistant",
"description": description,
"webhook_target_url": get_webhook_url(hass),
"app_type": APP_TYPE_WEBHOOK,
"single_instance": True,
"classifications": [CLASSIFICATION_AUTOMATION],
}
async def create_app(hass: HomeAssistantType, api):
"""Create a SmartApp for this instance of hass."""
# Create app from template attributes
template = _get_app_template(hass)
app = App()
for key, value in template.items():
setattr(app, key, value)
app, client = await api.create_app(app)
_LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id)
# Set unique hass id in settings
settings = AppSettings(app.app_id)
settings.settings[SETTINGS_APP_ID] = app.app_id
2019-07-31 19:25:30 +00:00
settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID]
await api.update_app_settings(settings)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id
)
# Set oauth scopes
oauth = AppOAuth(app.app_id)
oauth.client_name = APP_OAUTH_CLIENT_NAME
oauth.scope.extend(APP_OAUTH_SCOPES)
await api.update_app_oauth(oauth)
2019-07-31 19:25:30 +00:00
_LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id)
return app, client
async def update_app(hass: HomeAssistantType, app):
"""Ensure the SmartApp is up-to-date and update if necessary."""
template = _get_app_template(hass)
2019-07-31 19:25:30 +00:00
template.pop("app_name") # don't update this
update_required = False
for key, value in template.items():
if getattr(app, key) != value:
update_required = True
setattr(app, key, value)
if update_required:
await app.save()
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id
)
def setup_smartapp(hass, app):
"""
Configure an individual SmartApp in hass.
Register the SmartApp with the SmartAppManager so that hass will service
lifecycle events (install, event, etc...). A unique SmartApp is created
for each SmartThings account that is configured in hass.
"""
manager = hass.data[DOMAIN][DATA_MANAGER]
smartapp = manager.smartapps.get(app.app_id)
if smartapp:
# already setup
return smartapp
smartapp = manager.register(app.app_id, app.webhook_public_key)
smartapp.name = app.display_name
smartapp.description = app.description
smartapp.permissions.extend(APP_OAUTH_SCOPES)
return smartapp
async def setup_smartapp_endpoint(hass: HomeAssistantType):
"""
Configure the SmartApp webhook in hass.
SmartApps are an extension point within the SmartThings ecosystem and
is used to receive push updates (i.e. device updates) from the cloud.
"""
data = hass.data.get(DOMAIN)
if data:
# already setup
return
# Get/create config to store a unique id for this hass instance.
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
config = await store.async_load()
if not config:
# Create config
config = {
CONF_INSTANCE_ID: str(uuid4()),
CONF_WEBHOOK_ID: webhook.generate_secret(),
2019-07-31 19:25:30 +00:00
CONF_CLOUDHOOK_URL: None,
}
await store.async_save(config)
# Register webhook
2019-07-31 19:25:30 +00:00
webhook.async_register(
hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook
)
# Create webhook if eligible
cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
2019-07-31 19:25:30 +00:00
if (
cloudhook_url is None
and cloud.async_active_subscription(hass)
and not hass.config_entries.async_entries(DOMAIN)
):
cloudhook_url = await cloud.async_create_cloudhook(
2019-07-31 19:25:30 +00:00
hass, config[CONF_WEBHOOK_ID]
)
config[CONF_CLOUDHOOK_URL] = cloudhook_url
await store.async_save(config)
_LOGGER.debug("Created cloudhook '%s'", cloudhook_url)
# SmartAppManager uses a dispatcher to invoke callbacks when push events
# occur. Use hass' implementation instead of the built-in one.
dispatcher = Dispatcher(
signal_prefix=SIGNAL_SMARTAPP_PREFIX,
connect=functools.partial(async_dispatcher_connect, hass),
2019-07-31 19:25:30 +00:00
send=functools.partial(async_dispatcher_send, hass),
)
# Path is used in digital signature validation
2019-07-31 19:25:30 +00:00
path = (
urlparse(cloudhook_url).path
if cloudhook_url
else webhook.async_generate_path(config[CONF_WEBHOOK_ID])
)
manager = SmartAppManager(path, dispatcher=dispatcher)
manager.connect_install(functools.partial(smartapp_install, hass))
manager.connect_update(functools.partial(smartapp_update, hass))
manager.connect_uninstall(functools.partial(smartapp_uninstall, hass))
hass.data[DOMAIN] = {
DATA_MANAGER: manager,
CONF_INSTANCE_ID: config[CONF_INSTANCE_ID],
DATA_BROKERS: {},
CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
# Will not be present if not enabled
CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
2019-07-31 19:25:30 +00:00
CONF_INSTALLED_APPS: [],
}
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Setup endpoint for %s",
cloudhook_url
if cloudhook_url
else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]),
)
async def unload_smartapp_endpoint(hass: HomeAssistantType):
"""Tear down the component configuration."""
if DOMAIN not in hass.data:
return
# Remove the cloudhook if it was created
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url and cloud.async_is_logged_in(hass):
2019-07-31 19:25:30 +00:00
await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Remove cloudhook from storage
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
2019-07-31 19:25:30 +00:00
await store.async_save(
{
CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID],
CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID],
CONF_CLOUDHOOK_URL: None,
}
)
_LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url)
# Remove the webhook
webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Disconnect all brokers
for broker in hass.data[DOMAIN][DATA_BROKERS].values():
broker.disconnect()
# Remove all handlers from manager
hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
# Remove the component data
hass.data.pop(DOMAIN)
async def smartapp_sync_subscriptions(
2019-07-31 19:25:30 +00:00
hass: HomeAssistantType,
auth_token: str,
location_id: str,
installed_app_id: str,
devices,
):
"""Synchronize subscriptions of an installed up."""
api = SmartThings(async_get_clientsession(hass), auth_token)
tasks = []
async def create_subscription(target: str):
sub = Subscription()
sub.installed_app_id = installed_app_id
sub.location_id = location_id
sub.source_type = SourceType.CAPABILITY
sub.capability = target
try:
await api.create_subscription(sub)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Created subscription for '%s' under app '%s'", target, installed_app_id
)
2019-06-20 20:25:32 +00:00
except Exception as error: # pylint:disable=broad-except
2019-07-31 19:25:30 +00:00
_LOGGER.error(
2019-08-27 23:08:09 +00:00
"Failed to create subscription for '%s' under app '%s': %s",
2019-07-31 19:25:30 +00:00
target,
installed_app_id,
error,
)
async def delete_subscription(sub: SubscriptionEntity):
try:
2019-07-31 19:25:30 +00:00
await api.delete_subscription(installed_app_id, sub.subscription_id)
_LOGGER.debug(
2019-08-27 23:08:09 +00:00
"Removed subscription for '%s' under app '%s' because it was no longer needed",
2019-07-31 19:25:30 +00:00
sub.capability,
installed_app_id,
)
2019-06-20 20:25:32 +00:00
except Exception as error: # pylint:disable=broad-except
2019-07-31 19:25:30 +00:00
_LOGGER.error(
"Failed to remove subscription for '%s' under app " "'%s': %s",
sub.capability,
installed_app_id,
error,
)
# Build set of capabilities and prune unsupported ones
capabilities = set()
for device in devices:
capabilities.update(device.capabilities)
capabilities.intersection_update(CAPABILITIES)
# Get current subscriptions and find differences
subscriptions = await api.subscriptions(installed_app_id)
for subscription in subscriptions:
if subscription.capability in capabilities:
capabilities.remove(subscription.capability)
else:
# Delete the subscription
tasks.append(delete_subscription(subscription))
# Remaining capabilities need subscriptions created
tasks.extend([create_subscription(c) for c in capabilities])
if tasks:
await asyncio.gather(*tasks)
else:
2019-07-31 19:25:30 +00:00
_LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id)
async def smartapp_install(hass: HomeAssistantType, req, resp, app):
"""
Handle when a SmartApp is installed by the user into a location.
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,
2019-07-31 19:25:30 +00:00
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.
2019-07-31 19:25:30 +00:00
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data[CONF_APP_ID] == app.app_id
),
None,
)
if entry:
data = entry.data.copy()
data.update(install_data)
# Add as job not needed because the current coroutine was invoked
# from the dispatcher and is not being awaited.
await hass.config_entries.flow.async_init(
2019-07-31 19:25:30 +00:00
DOMAIN, context={"source": "install"}, data=data
)
else:
# Store the data where the flow can find it
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Installed SmartApp '%s' under parent app '%s'",
req.installed_app_id,
app.app_id,
)
async def smartapp_update(hass: HomeAssistantType, req, resp, app):
"""
Handle when a SmartApp is updated (reconfigured) by the user.
Store the refresh token in the config entry.
"""
# Update refresh token in config entry
2019-07-31 19:25:30 +00:00
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
),
None,
)
if entry:
entry.data[CONF_REFRESH_TOKEN] = req.refresh_token
hass.config_entries.async_update_entry(entry)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
)
async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app):
"""
Handle when a SmartApp is removed from a location by the user.
Find and delete the config entry representing the integration.
"""
2019-07-31 19:25:30 +00:00
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
),
None,
)
if entry:
# Add as job not needed because the current coroutine was invoked
# from the dispatcher and is not being awaited.
await hass.config_entries.async_remove(entry.entry_id)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Uninstalled SmartApp '%s' under parent app '%s'",
req.installed_app_id,
app.app_id,
)
async def smartapp_webhook(hass: HomeAssistantType, webhook_id: str, request):
"""
Handle a smartapp lifecycle event callback from SmartThings.
Requests from SmartThings are digitally signed and the SmartAppManager
validates the signature for authenticity.
"""
manager = hass.data[DOMAIN][DATA_MANAGER]
data = await request.json()
result = await manager.handle_request(data, request.headers)
return web.json_response(result)