"""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 ( 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 ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.typing import HomeAssistantType from .const import ( 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() 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() 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: raise RuntimeWarning( "Installed SmartApp instance '{}' ({}) is not AUTHORIZED but instead {}".format( 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 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 = f"{hass.config.location_name} {endpoint}" return { "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 settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] await api.update_app_settings(settings) _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) _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) 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() _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(), CONF_CLOUDHOOK_URL: None, } await store.async_save(config) # Register webhook webhook.async_register( hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook ) # Create webhook if eligible cloudhook_url = config.get(CONF_CLOUDHOOK_URL) 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( 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), send=functools.partial(async_dispatcher_send, hass), ) # Path is used in digital signature validation 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), CONF_INSTALLED_APPS: [], } _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): 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) 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( 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) _LOGGER.debug( "Created subscription for '%s' under app '%s'", target, installed_app_id ) except Exception as error: # pylint:disable=broad-except _LOGGER.error( "Failed to create subscription for '%s' under app '%s': %s", target, installed_app_id, error, ) async def delete_subscription(sub: SubscriptionEntity): try: await api.delete_subscription(installed_app_id, sub.subscription_id) _LOGGER.debug( "Removed subscription for '%s' under app '%s' because it was no longer needed", sub.capability, installed_app_id, ) except Exception as error: # pylint:disable=broad-except _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: _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, 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 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( 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) _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 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) _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. """ 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) _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)