async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): """Handle migration of a previous version config entry. A config entry created under a previous version must go through the integration setup again so we can properly retrieve the needed data elements. Force this by removing the entry and triggering a new flow. """ from pysmartthings import SmartThings # Remove the installed_app, which if already removed raises a 403 error. api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) installed_app_id = entry.data[CONF_INSTALLED_APP_ID] try: await api.delete_installed_app(installed_app_id) except ClientResponseError as ex: if ex.status == 403: _LOGGER.exception("Installed app %s has already been removed", installed_app_id) else: raise _LOGGER.debug("Removed installed app %s", installed_app_id) # Delete the entry hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) # only create new flow if there isn't a pending one for SmartThings. flows = hass.config_entries.flow.async_progress() if not [flow for flow in flows if flow['handler'] == DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={'source': 'import'})) # Return False because it could not be migrated. return False
async def smartapp_sync_subscriptions( hass: HomeAssistantType, auth_token: str, location_id: str, installed_app_id: str, devices): """Synchronize subscriptions of an installed up.""" from pysmartthings import ( CAPABILITIES, SmartThings, SourceType, Subscription, SubscriptionEntity ) 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: # pylint:disable=broad-except _LOGGER.exception("Failed to create subscription for '%s' under " "app '%s'", target, installed_app_id) 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: # pylint:disable=broad-except _LOGGER.exception("Failed to remove subscription for '%s' under " "app '%s'", sub.capability, installed_app_id) # 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. Setup subscriptions using the access token SmartThings provided in the event. An explicit subscription is required for each 'capability' in order to receive the related attribute updates. Finally, create a config entry representing the installation if this is not the first installation under the account. """ from pysmartthings import SmartThings, Subscription, SourceType # This access token is a temporary 'SmartApp token' that expires in 5 min # and is used to create subscriptions only. api = SmartThings(async_get_clientsession(hass), req.auth_token) async def create_subscription(target): sub = Subscription() sub.installed_app_id = req.installed_app_id sub.location_id = req.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, req.installed_app_id) except Exception: # pylint:disable=broad-except _LOGGER.exception( "Failed to create subscription for '%s' under " "app '%s'", target, req.installed_app_id) tasks = [create_subscription(c) for c in SUPPORTED_CAPABILITIES] await asyncio.gather(*tasks) _LOGGER.debug("SmartApp '%s' under parent app '%s' was installed", req.installed_app_id, app.app_id) # The permanent access token is copied from another config flow with the # same parent app_id. If one is not found, that means the user is within # the initial config flow and the entry at the conclusion. access_token = next((entry.data.get(CONF_ACCESS_TOKEN) for entry in hass.config_entries.async_entries(DOMAIN) if entry.data[CONF_APP_ID] == app.app_id), None) if access_token: # 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={ CONF_APP_ID: app.app_id, CONF_INSTALLED_APP_ID: req.installed_app_id, CONF_LOCATION_ID: req.location_id, CONF_ACCESS_TOKEN: access_token })
async def async_step_user(self, user_input=None): """Get access token and validate it.""" from pysmartthings import SmartThings errors = {} if not self.hass.config.api.base_url.lower().startswith('https://'): errors['base'] = "base_url_not_https" return self._show_step_user(errors) if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_user(errors) self.access_token = user_input.get(CONF_ACCESS_TOKEN, '') self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) # Ensure token is a UUID if not VAL_UID_MATCHER.match(self.access_token): errors[CONF_ACCESS_TOKEN] = "token_invalid_format" return self._show_step_user(errors) # Check not already setup in another entry if any( entry.data.get(CONF_ACCESS_TOKEN) == self.access_token for entry in self.hass.config_entries.async_entries(DOMAIN)): errors[CONF_ACCESS_TOKEN] = "token_already_setup" return self._show_step_user(errors) # Setup end-point await setup_smartapp_endpoint(self.hass) try: app = await find_app(self.hass, self.api) if app: await app.refresh() # load all attributes await update_app(self.hass, app) else: app = await create_app(self.hass, self.api) setup_smartapp(self.hass, app) self.app_id = app.app_id except ClientResponseError as ex: if ex.status == 401: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" elif ex.status == 403: errors[CONF_ACCESS_TOKEN] = "token_forbidden" else: errors['base'] = "app_setup_error" return self._show_step_user(errors) except Exception: # pylint:disable=broad-except errors['base'] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_user(errors) return await self.async_step_wait_install()
async def async_step_install(self, data=None): """ Create a config entry at completion of a flow. Launched when the user completes the flow or when the SmartApp is installed into an additional location. """ if not self.api: # Launched from the SmartApp install event handler self.api = SmartThings(async_get_clientsession(self.hass), data[CONF_ACCESS_TOKEN]) location = await self.api.location(data[CONF_LOCATION_ID]) return self.async_create_entry(title=location.name, data=data)
async def async_remove_entry(hass: HomeAssistantType, entry: ConfigEntry) -> None: """Perform clean-up when entry is being removed.""" api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) # Remove the installed_app, which if already removed raises a 403 error. installed_app_id = entry.data[CONF_INSTALLED_APP_ID] try: await api.delete_installed_app(installed_app_id) except ClientResponseError as ex: if ex.status == 403: _LOGGER.debug( "Installed app %s has already been removed", installed_app_id, exc_info=True, ) else: raise _LOGGER.debug("Removed installed app %s", installed_app_id) # Remove the app if not referenced by other entries, which if already # removed raises a 403 error. all_entries = hass.config_entries.async_entries(DOMAIN) app_id = entry.data[CONF_APP_ID] app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) if app_count > 1: _LOGGER.debug( "App %s was not removed because it is in use by other" "config entries", app_id, ) return # Remove the app try: await api.delete_app(app_id) except ClientResponseError as ex: if ex.status == 403: _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) else: raise _LOGGER.debug("Removed app %s", app_id) if len(all_entries) == 1: await unload_smartapp_endpoint(hass)
async def remove_apps(token: str): """Remove Home Assistant apps and installed apps.""" async with aiohttp.ClientSession() as session: api = SmartThings(session, token) apps = await api.apps() installed_apps = await api.installed_apps() for app in apps: if not app.app_name.startswith('homeassistant.'): continue # Remove installed apps first for installed_app in installed_apps: if installed_app.app_id == app.app_id: await api.delete_installed_app( installed_app.installed_app_id) print("Removed installed app '{}' ({})".format( installed_app.display_name, installed_app.installed_app_id)) # Remove the app itself await api.delete_app(app.app_id) print("Removed app '{}' ({})".format(app.app_name, app.app_id))
async def smartapp_sync_subscriptions( hass: HomeAssistantType, auth_token: str, location_id: str, installed_app_id: str, *, skip_delete=False): """Synchronize subscriptions of an installed up.""" from pysmartthings import ( CAPABILITIES, SmartThings, SourceType, Subscription) api = SmartThings(async_get_clientsession(hass), auth_token) devices = await api.devices(location_ids=[location_id]) # Build set of capabilities and prune unsupported ones capabilities = set() for device in devices: capabilities.update(device.capabilities) capabilities.intersection_update(CAPABILITIES) # Remove all (except for installs) if not skip_delete: await api.delete_subscriptions(installed_app_id) # Create for each capability async def create_subscription(target): 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: # pylint:disable=broad-except _LOGGER.exception("Failed to create subscription for '%s' under " "app '%s'", target, installed_app_id) tasks = [create_subscription(c) for c in capabilities] await asyncio.gather(*tasks)
async def smartapp_sync_subscriptions( hass: HomeAssistant, 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) # Remove items not defined in the library capabilities.intersection_update(CAPABILITIES) # Remove unused capabilities capabilities.difference_update(IGNORED_CAPABILITIES) capability_count = len(capabilities) if capability_count > SUBSCRIPTION_WARNING_LIMIT: _LOGGER.warning( "Some device attributes may not receive push updates and there may be subscription " "creation failures under app '%s' because %s subscriptions are required but " "there is a limit of %s per app", installed_app_id, capability_count, SUBSCRIPTION_WARNING_LIMIT, ) _LOGGER.debug( "Synchronizing subscriptions for %s capabilities under app '%s': %s", capability_count, installed_app_id, 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 async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" from pysmartthings import SmartThings if not hass.config.api.base_url.lower().startswith('https://'): _LOGGER.warning("The 'base_url' of the 'http' component must be " "configured and start with 'https://'") return False api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) manager = hass.data[DOMAIN][DATA_MANAGER] smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) if not smart_app: # Validate and setup the app. app = await api.app(entry.data[CONF_APP_ID]) smart_app = setup_smartapp(hass, app) # Validate and retrieve the installed app. installed_app = await validate_installed_app( api, entry.data[CONF_INSTALLED_APP_ID]) # Get devices and their current status devices = await api.devices( location_ids=[installed_app.location_id]) async def retrieve_device_status(device): try: await device.status.refresh() except ClientResponseError: _LOGGER.debug("Unable to update status for device: %s (%s), " "the device will be ignored", device.label, device.device_id, exc_info=True) devices.remove(device) await asyncio.gather(*[retrieve_device_status(d) for d in devices.copy()]) # Setup device broker broker = DeviceBroker(hass, devices, installed_app.installed_app_id) broker.event_handler_disconnect = \ smart_app.connect_event(broker.event_handler) hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker except ClientResponseError as ex: if ex.status in (401, 403): _LOGGER.exception("Unable to setup config entry '%s' - please " "reconfigure the integration", entry.title) remove_entry = True else: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady if remove_entry: hass.async_create_task( hass.config_entries.async_remove(entry.entry_id)) # only create new flow if there isn't a pending one for SmartThings. flows = hass.config_entries.flow.async_progress() if not [flow for flow in flows if flow['handler'] == DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={'source': 'import'})) return False for component in SUPPORTED_PLATFORMS: hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, component)) return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" if not validate_webhook_requirements(hass): _LOGGER.warning( "The 'base_url' of the 'http' integration must be configured and start with 'https://'" ) return False api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) manager = hass.data[DOMAIN][DATA_MANAGER] smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) if not smart_app: # Validate and setup the app. app = await api.app(entry.data[CONF_APP_ID]) smart_app = setup_smartapp(hass, app) # Validate and retrieve the installed app. installed_app = await validate_installed_app( api, entry.data[CONF_INSTALLED_APP_ID] ) # Get scenes scenes = await async_get_entry_scenes(entry, api) # Get SmartApp token to sync subscriptions token = await api.generate_tokens( entry.data[CONF_OAUTH_CLIENT_ID], entry.data[CONF_OAUTH_CLIENT_SECRET], entry.data[CONF_REFRESH_TOKEN], ) hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} ) # Get devices and their current status devices = await api.devices(location_ids=[installed_app.location_id]) async def retrieve_device_status(device): try: await device.status.refresh() except ClientResponseError: _LOGGER.debug( "Unable to update status for device: %s (%s), the device will be excluded", device.label, device.device_id, exc_info=True, ) devices.remove(device) await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) # Sync device subscriptions await smartapp_sync_subscriptions( hass, token.access_token, installed_app.location_id, installed_app.installed_app_id, devices, ) # Setup device broker broker = DeviceBroker(hass, entry, token, smart_app, devices, scenes) broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker except ClientResponseError as ex: if ex.status in (401, HTTP_FORBIDDEN): _LOGGER.exception( "Unable to setup configuration entry '%s' - please reconfigure the integration", entry.title, ) remove_entry = True else: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady if remove_entry: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) # only create new flow if there isn't a pending one for SmartThings. flows = hass.config_entries.flow.async_progress() if not [flow for flow in flows if flow["handler"] == DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"} ) ) return False for component in SUPPORTED_PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) return True
async def async_step_user(self, user_input=None): """Get access token and validate it.""" errors = {} if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_user(errors) self.access_token = user_input.get(CONF_ACCESS_TOKEN, '') self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) # Ensure token is a UUID if not VAL_UID_MATCHER.match(self.access_token): errors[CONF_ACCESS_TOKEN] = "token_invalid_format" return self._show_step_user(errors) # Check not already setup in another entry if any( entry.data.get(CONF_ACCESS_TOKEN) == self.access_token for entry in self.hass.config_entries.async_entries(DOMAIN)): errors[CONF_ACCESS_TOKEN] = "token_already_setup" return self._show_step_user(errors) # Setup end-point await setup_smartapp_endpoint(self.hass) if not validate_webhook_requirements(self.hass): errors['base'] = "base_url_not_https" return self._show_step_user(errors) try: app = await find_app(self.hass, self.api) if app: await app.refresh() # load all attributes await update_app(self.hass, app) # Get oauth client id/secret by regenerating it app_oauth = AppOAuth(app.app_id) app_oauth.client_name = APP_OAUTH_CLIENT_NAME app_oauth.scope.extend(APP_OAUTH_SCOPES) client = await self.api.generate_app_oauth(app_oauth) 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_id = client.client_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_user(errors) except ClientResponseError as ex: if ex.status == 401: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" elif ex.status == 403: errors[CONF_ACCESS_TOKEN] = "token_forbidden" else: errors['base'] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_user(errors) except Exception: # pylint:disable=broad-except errors['base'] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_user(errors) return await self.async_step_wait_install()
async def async_step_pat(self, user_input=None): """Get the Personal Access Token and validate it.""" errors = {} if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_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 == HTTP_UNAUTHORIZED: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" _LOGGER.debug( "Unauthorized error received setting up SmartApp", exc_info=True) elif ex.status == HTTP_FORBIDDEN: errors[CONF_ACCESS_TOKEN] = "token_forbidden" _LOGGER.debug("Forbidden error received setting up SmartApp", exc_info=True) else: errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_pat(errors) except Exception: # pylint:disable=broad-except errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_pat(errors) return await self.async_step_select_location()