diff --git a/CODEOWNERS b/CODEOWNERS index 2a2391186f4..98eaca90076 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -236,6 +236,7 @@ homeassistant/components/*/rfxtrx.py @danielhiversen # S homeassistant/components/simplisafe/* @bachya +homeassistant/components/smartthings/* @andrewsayre # T homeassistant/components/tahoma.py @philklei diff --git a/homeassistant/components/smartthings/.translations/en.json b/homeassistant/components/smartthings/.translations/en.json new file mode 100644 index 00000000000..1fb4e878cb4 --- /dev/null +++ b/homeassistant/components/smartthings/.translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "SmartThings", + "step": { + "user": { + "title": "Enter Personal Access Token", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", + "data": { + "access_token": "Access Token" + } + }, + "wait_install": { + "title": "Install SmartApp", + "description": "Please install the Home Assistant SmartApp in at least one location and click submit." + } + }, + "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.", + "token_already_setup": "The token has already been setup.", + "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.", + "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py new file mode 100644 index 00000000000..c705a3df73e --- /dev/null +++ b/homeassistant/components/smartthings/__init__.py @@ -0,0 +1,213 @@ +"""SmartThings Cloud integration for Home Assistant.""" + +import asyncio +import logging +from typing import Iterable + +from aiohttp.client_exceptions import ( + ClientConnectionError, ClientResponseError) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .config_flow import SmartThingsFlowHandler # noqa +from .const import ( + CONF_APP_ID, CONF_INSTALLED_APP_ID, DATA_BROKERS, DATA_MANAGER, DOMAIN, + SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_PLATFORMS) +from .smartapp import ( + setup_smartapp, setup_smartapp_endpoint, validate_installed_app) + +REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.4.2'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Initialize the SmartThings platform.""" + await setup_smartapp_endpoint(hass) + return True + + +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_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) + if broker and broker.event_handler_disconnect: + broker.event_handler_disconnect() + + tasks = [hass.config_entries.async_forward_entry_unload(entry, component) + for component in SUPPORTED_PLATFORMS] + return all(await asyncio.gather(*tasks)) + + +class DeviceBroker: + """Manages an individual SmartThings config entry.""" + + def __init__(self, hass: HomeAssistantType, devices: Iterable, + installed_app_id: str): + """Create a new instance of the DeviceBroker.""" + self._hass = hass + self._installed_app_id = installed_app_id + self.devices = {device.device_id: device for device in devices} + self.event_handler_disconnect = None + + async def event_handler(self, req, resp, app): + """Broker for incoming events.""" + from pysmartapp.event import EVENT_TYPE_DEVICE + + # Do not process events received from a different installed app + # under the same parent SmartApp (valid use-scenario) + if req.installed_app_id != self._installed_app_id: + return + + updated_devices = set() + for evt in req.events: + if evt.event_type != EVENT_TYPE_DEVICE: + continue + device = self.devices.get(evt.device_id) + if not device: + continue + device.status.apply_attribute_update( + evt.component_id, evt.capability, evt.attribute, evt.value) + updated_devices.add(device.device_id) + _LOGGER.debug("Update received with %s events and updated %s devices", + len(req.events), len(updated_devices)) + + async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, + updated_devices) + + +class SmartThingsEntity(Entity): + """Defines a SmartThings entity.""" + + def __init__(self, device): + """Initialize the instance.""" + self._device = device + self._dispatcher_remove = None + + async def async_added_to_hass(self): + """Device added to hass.""" + async def async_update_state(devices): + """Update device state.""" + if self._device.device_id in devices: + await self.async_update_ha_state(True) + + self._dispatcher_remove = async_dispatcher_connect( + self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect the device when removed.""" + if self._dispatcher_remove: + self._dispatcher_remove() + + @property + def device_info(self): + """Get attributes about the device.""" + return { + 'identifiers': { + (DOMAIN, self._device.device_id) + }, + 'name': self._device.label, + 'model': self._device.device_type_name, + 'manufacturer': 'Unavailable' + } + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device.label + + @property + def should_poll(self) -> bool: + """No polling needed for this device.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._device.device_id diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py new file mode 100644 index 00000000000..b280036a615 --- /dev/null +++ b/homeassistant/components/smartthings/config_flow.py @@ -0,0 +1,179 @@ +"""Config flow to configure SmartThings.""" +import logging + +from aiohttp.client_exceptions import ClientResponseError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, + VAL_UID_MATCHER) +from .smartapp import ( + create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class SmartThingsFlowHandler(config_entries.ConfigFlow): + """ + 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 = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + def __init__(self): + """Create a new instance of the flow handler.""" + self.access_token = None + self.app_id = None + self.api = None + + async def async_step_import(self, user_input=None): + """Occurs when a previously entry setup fails and is re-initiated.""" + return await self.async_step_user(user_input) + + 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_wait_install(self, user_input=None): + """Wait for SmartApp installation.""" + from pysmartthings import InstalledAppStatus + + errors = {} + if user_input is None: + return self._show_step_wait_install(errors) + + # Find installed apps that were authorized + installed_apps = [app for app in await self.api.installed_apps( + installed_app_status=InstalledAppStatus.AUTHORIZED) + if app.app_id == self.app_id] + if not installed_apps: + errors['base'] = 'app_not_installed' + return self._show_step_wait_install(errors) + + # 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={ + CONF_APP_ID: installed_app.app_id, + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_LOCATION_ID: installed_app.location_id, + CONF_ACCESS_TOKEN: self.access_token + })) + + # return entity for the first one. + installed_app = installed_apps[0] + return await self.async_step_install({ + CONF_APP_ID: installed_app.app_id, + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_LOCATION_ID: installed_app.location_id, + CONF_ACCESS_TOKEN: self.access_token + }) + + def _show_step_user(self, errors): + return self.async_show_form( + step_id='user', + 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/components/smartthings/' + } + ) + + 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): + """ + 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. + """ + from pysmartthings import SmartThings + + 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) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py new file mode 100644 index 00000000000..9a6d96bfab9 --- /dev/null +++ b/homeassistant/components/smartthings/const.py @@ -0,0 +1,31 @@ +"""Constants used by the SmartThings component and platforms.""" +import re + +APP_OAUTH_SCOPES = [ + 'r:devices:*' +] +APP_NAME_PREFIX = 'homeassistant.' +CONF_APP_ID = 'app_id' +CONF_INSTALLED_APP_ID = 'installed_app_id' +CONF_INSTANCE_ID = 'instance_id' +CONF_LOCATION_ID = 'location_id' +DATA_MANAGER = 'manager' +DATA_BROKERS = 'brokers' +DOMAIN = 'smartthings' +SIGNAL_SMARTTHINGS_UPDATE = 'smartthings_update' +SIGNAL_SMARTAPP_PREFIX = 'smartthings_smartap_' +SETTINGS_INSTANCE_ID = "hassInstanceId" +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +SUPPORTED_PLATFORMS = [ + 'switch' +] +SUPPORTED_CAPABILITIES = [ + 'colorControl', + 'colorTemperature', + 'switch', + 'switchLevel' +] +VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]" \ + "{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" +VAL_UID_MATCHER = re.compile(VAL_UID) diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py new file mode 100644 index 00000000000..9d9dacf8460 --- /dev/null +++ b/homeassistant/components/smartthings/smartapp.py @@ -0,0 +1,275 @@ +""" +SmartApp functionality to receive cloud-push notifications. + +This module defines the functions to manage the SmartApp integration +within the SmartThings ecosystem in order to receive real-time webhook-based +callbacks when device states change. +""" +import asyncio +import functools +import logging +from uuid import uuid4 + +from aiohttp import web + +from homeassistant.components import webhook +from homeassistant.const import CONF_ACCESS_TOKEN, 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_SCOPES, CONF_APP_ID, CONF_INSTALLED_APP_ID, + CONF_INSTANCE_ID, CONF_LOCATION_ID, DATA_BROKERS, DATA_MANAGER, DOMAIN, + SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION, + SUPPORTED_CAPABILITIES) + +_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. + """ + from pysmartthings import InstalledAppStatus + + 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 _get_app_template(hass: HomeAssistantType): + from pysmartthings import APP_TYPE_WEBHOOK, CLASSIFICATION_AUTOMATION + + return { + 'app_name': APP_NAME_PREFIX + str(uuid4()), + 'display_name': 'Home Assistant', + 'description': "Home Assistant at " + hass.config.api.base_url, + 'webhook_target_url': webhook.async_generate_url( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + '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.""" + from pysmartthings import App, AppOAuth, AppSettings + from pysmartapp.const import SETTINGS_APP_ID + + # Create app from template attributes + template = _get_app_template(hass) + app = App() + for key, value in template.items(): + setattr(app, key, value) + app = (await api.create_app(app))[0] + _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 = 'Home Assistant' + 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 + + +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. + """ + from pysmartapp import Dispatcher, SmartAppManager + + 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() + } + await store.async_save(config) + + # 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)) + manager = SmartAppManager( + webhook.async_generate_path(config[CONF_WEBHOOK_ID]), + dispatcher=dispatcher) + manager.connect_install(functools.partial(smartapp_install, hass)) + manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) + + webhook.async_register(hass, DOMAIN, 'SmartApp', + config[CONF_WEBHOOK_ID], smartapp_webhook) + + hass.data[DOMAIN] = { + DATA_MANAGER: manager, + CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], + DATA_BROKERS: {}, + CONF_WEBHOOK_ID: config[CONF_WEBHOOK_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 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: + _LOGGER.debug("SmartApp '%s' under parent app '%s' was removed", + req.installed_app_id, app.app_id) + # 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) + + +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) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json new file mode 100644 index 00000000000..1fb4e878cb4 --- /dev/null +++ b/homeassistant/components/smartthings/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "title": "SmartThings", + "step": { + "user": { + "title": "Enter Personal Access Token", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}).", + "data": { + "access_token": "Access Token" + } + }, + "wait_install": { + "title": "Install SmartApp", + "description": "Please install the Home Assistant SmartApp in at least one location and click submit." + } + }, + "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.", + "token_already_setup": "The token has already been setup.", + "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.", + "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py new file mode 100644 index 00000000000..1fccfcd3619 --- /dev/null +++ b/homeassistant/components/smartthings/switch.py @@ -0,0 +1,70 @@ +""" +Support for switches through the SmartThings cloud API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/smartthings.switch/ +""" +from homeassistant.components.switch import SwitchDevice + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Platform uses config entry setup.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add switches for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + async_add_entities( + [SmartThingsSwitch(device) for device in broker.devices.values() + if is_switch(device)]) + + +def is_switch(device): + """Determine if the device should be represented as a switch.""" + from pysmartthings import Capability + + # Must be able to be turned on/off. + if Capability.switch not in device.capabilities: + return False + # Must not have a capability represented by other types. + non_switch_capabilities = [ + Capability.color_control, + Capability.color_temperature, + Capability.fan_speed, + Capability.switch_level + ] + if any(capability in device.capabilities + for capability in non_switch_capabilities): + return False + + return True + + +class SmartThingsSwitch(SmartThingsEntity, SwitchDevice): + """Define a SmartThings switch.""" + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + await self._device.switch_off(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + await self._device.switch_on(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.status.switch diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 159f5651c31..9c4c127f52e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -160,6 +160,7 @@ FLOWS = [ 'point', 'rainmachine', 'simplisafe', + 'smartthings', 'smhi', 'sonos', 'tellduslive', diff --git a/requirements_all.txt b/requirements_all.txt index 0d9ea55b502..de8ad8c7914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1224,6 +1224,12 @@ pysher==1.0.1 # homeassistant.components.sensor.sma pysma==0.3.1 +# homeassistant.components.smartthings +pysmartapp==0.3.0 + +# homeassistant.components.smartthings +pysmartthings==0.4.2 + # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47dfaa69727..a56626734eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,6 +210,12 @@ pyotp==2.2.6 # homeassistant.components.qwikswitch pyqwikswitch==0.8 +# homeassistant.components.smartthings +pysmartapp==0.3.0 + +# homeassistant.components.smartthings +pysmartthings==0.4.2 + # homeassistant.components.sonos pysonos==0.0.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 79ba3f8c342..398b2791848 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -90,6 +90,8 @@ TEST_REQUIREMENTS = ( 'pynx584', 'pyopenuv', 'pyotp', + 'pysmartapp', + 'pysmartthings', 'pysonos', 'pyqwikswitch', 'PyRMVtransport', diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py new file mode 100644 index 00000000000..5a3e9135963 --- /dev/null +++ b/tests/components/smartthings/__init__.py @@ -0,0 +1 @@ +"""Tests for the SmartThings component.""" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py new file mode 100644 index 00000000000..56bb5a62888 --- /dev/null +++ b/tests/components/smartthings/conftest.py @@ -0,0 +1,279 @@ +"""Test configuration and mocks for the SmartThings component.""" +from collections import defaultdict +from unittest.mock import Mock, patch +from uuid import uuid4 + +from pysmartthings import ( + CLASSIFICATION_AUTOMATION, AppEntity, AppSettings, DeviceEntity, + InstalledApp, Location) +from pysmartthings.api import Api +import pytest + +from homeassistant.components import webhook +from homeassistant.components.smartthings.const import ( + APP_NAME_PREFIX, CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, + CONF_LOCATION_ID, DOMAIN, SETTINGS_INSTANCE_ID, STORAGE_KEY, + STORAGE_VERSION) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro + + +@pytest.fixture(autouse=True) +async def setup_component(hass, config_file, hass_storage): + """Load the SmartThing component.""" + hass_storage[STORAGE_KEY] = {'data': config_file, + "version": STORAGE_VERSION} + await async_setup_component(hass, 'smartthings', {}) + hass.config.api.base_url = 'https://test.local' + + +def _create_location(): + loc = Location() + loc.apply_data({ + 'name': 'Test Location', + 'locationId': str(uuid4()) + }) + return loc + + +@pytest.fixture(name='location') +def location_fixture(): + """Fixture for a single location.""" + return _create_location() + + +@pytest.fixture(name='locations') +def locations_fixture(location): + """Fixture for 2 locations.""" + return [location, _create_location()] + + +@pytest.fixture(name="app") +def app_fixture(hass, config_file): + """Fixture for a single app.""" + app = AppEntity(Mock()) + app.apply_data({ + 'appName': APP_NAME_PREFIX + str(uuid4()), + 'appId': str(uuid4()), + 'appType': 'WEBHOOK_SMART_APP', + 'classifications': [CLASSIFICATION_AUTOMATION], + 'displayName': 'Home Assistant', + 'description': "Home Assistant at " + hass.config.api.base_url, + 'singleInstance': True, + 'webhookSmartApp': { + 'targetUrl': webhook.async_generate_url( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + 'publicKey': ''} + }) + app.refresh = Mock() + app.refresh.return_value = mock_coro() + app.save = Mock() + app.save.return_value = mock_coro() + settings = AppSettings(app.app_id) + settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID] + app.settings = Mock() + app.settings.return_value = mock_coro(return_value=settings) + return app + + +@pytest.fixture(name='app_settings') +def app_settings_fixture(app, config_file): + """Fixture for an app settings.""" + settings = AppSettings(app.app_id) + settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID] + return settings + + +def _create_installed_app(location_id, app_id): + item = InstalledApp() + item.apply_data(defaultdict(str, { + 'installedAppId': str(uuid4()), + 'installedAppStatus': 'AUTHORIZED', + 'installedAppType': 'UNKNOWN', + 'appId': app_id, + 'locationId': location_id + })) + return item + + +@pytest.fixture(name='installed_app') +def installed_app_fixture(location, app): + """Fixture for a single installed app.""" + return _create_installed_app(location.location_id, app.app_id) + + +@pytest.fixture(name='installed_apps') +def installed_apps_fixture(installed_app, locations, app): + """Fixture for 2 installed apps.""" + return [installed_app, + _create_installed_app(locations[1].location_id, app.app_id)] + + +@pytest.fixture(name='config_file') +def config_file_fixture(): + """Fixture representing the local config file contents.""" + return { + CONF_INSTANCE_ID: str(uuid4()), + CONF_WEBHOOK_ID: webhook.generate_secret() + } + + +@pytest.fixture(name='smartthings_mock') +def smartthings_mock_fixture(locations): + """Fixture to mock smartthings API calls.""" + def _location(location_id): + return mock_coro( + return_value=next(location for location in locations + if location.location_id == location_id)) + + with patch("pysmartthings.SmartThings", autospec=True) as mock: + mock.return_value.location.side_effect = _location + yield mock + + +@pytest.fixture(name='device') +def device_fixture(location): + """Fixture representing devices loaded.""" + item = DeviceEntity(None) + item.status.refresh = Mock() + item.status.refresh.return_value = mock_coro() + item.apply_data({ + "deviceId": "743de49f-036f-4e9c-839a-2f89d57607db", + "name": "GE In-Wall Smart Dimmer", + "label": "Front Porch Lights", + "deviceManufacturerCode": "0063-4944-3038", + "locationId": location.location_id, + "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d", + "deviceTypeName": "Dimmer Switch", + "deviceNetworkType": "ZWAVE", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "indicator", + "version": 1 + }, + { + "id": "sensor", + "version": 1 + }, + { + "id": "actuator", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "light", + "version": 1 + } + ] + } + ], + "dth": { + "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d", + "deviceTypeName": "Dimmer Switch", + "deviceNetworkType": "ZWAVE", + "completedSetup": False + }, + "type": "DTH" + }) + return item + + +@pytest.fixture(name='config_entry') +def config_entry_fixture(hass, installed_app, location): + """Fixture representing a config entry.""" + data = { + CONF_ACCESS_TOKEN: str(uuid4()), + CONF_INSTALLED_APP_ID: installed_app.installed_app_id, + CONF_APP_ID: installed_app.app_id, + CONF_LOCATION_ID: location.location_id + } + return ConfigEntry("1", DOMAIN, location.name, data, SOURCE_USER, + CONN_CLASS_CLOUD_PUSH) + + +@pytest.fixture(name="device_factory") +def device_factory_fixture(): + """Fixture for creating mock devices.""" + api = Mock(spec=Api) + api.post_device_command.return_value = mock_coro(return_value={}) + + def _factory(label, capabilities, status: dict = None): + device_data = { + "deviceId": str(uuid4()), + "name": "Device Type Handler Name", + "label": label, + "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", + "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", + "components": [ + { + "id": "main", + "capabilities": [ + {"id": capability, "version": 1} + for capability in capabilities + ] + } + ], + "dth": { + "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", + "deviceTypeName": "Switch", + "deviceNetworkType": "ZWAVE" + }, + "type": "DTH" + } + device = DeviceEntity(api, data=device_data) + if status: + for attribute, value in status.items(): + device.status.apply_attribute_update( + 'main', '', attribute, value) + return device + return _factory + + +@pytest.fixture(name="event_factory") +def event_factory_fixture(): + """Fixture for creating mock devices.""" + def _factory(device_id, event_type="DEVICE_EVENT"): + event = Mock() + event.event_type = event_type + event.device_id = device_id + event.component_id = 'main' + event.capability = '' + event.attribute = 'Updated' + event.value = 'Value' + return event + return _factory + + +@pytest.fixture(name="event_request_factory") +def event_request_factory_fixture(event_factory): + """Fixture for creating mock smartapp event requests.""" + def _factory(device_ids): + request = Mock() + request.installed_app_id = uuid4() + request.events = [event_factory(id) for id in device_ids] + request.events.append(event_factory(uuid4())) + request.events.append(event_factory(device_ids[0], event_type="OTHER")) + return request + return _factory diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py new file mode 100644 index 00000000000..4d2a43a52c7 --- /dev/null +++ b/tests/components/smartthings/test_config_flow.py @@ -0,0 +1,245 @@ +"""Tests for the SmartThings config flow module.""" +from unittest.mock import patch +from uuid import uuid4 + +from aiohttp.client_exceptions import ClientResponseError + +from homeassistant import data_entry_flow +from homeassistant.components.smartthings.config_flow import ( + SmartThingsFlowHandler) +from homeassistant.config_entries import ConfigEntry + +from tests.common import mock_coro + + +async def test_step_user(hass): + """Test the access token form is shown for a user initiated flow.""" + 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' + + +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() + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_base_url_not_https(hass): + """Test the base_url parameter starts with https://.""" + hass.config.api.base_url = 'http://0.0.0.0' + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_import() + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'base_url_not_https'} + + +async def test_invalid_token_format(hass): + """Test an error is shown for invalid token formats.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + result = await flow.async_step_user({'access_token': '123456789'}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_invalid_format'} + + +async def test_token_already_setup(hass): + """Test an error is shown when the token is already setup.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + token = str(uuid4()) + entries = [ConfigEntry( + version='', domain='', title='', data={'access_token': token}, + source='', connection_class='')] + + with patch.object(hass.config_entries, 'async_entries', + return_value=entries): + 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.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=401)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_unauthorized'} + + +async def test_token_forbidden(hass, smartthings_mock): + """Test an error is shown when the token is forbidden.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=403)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'access_token': 'token_forbidden'} + + +async def test_unknown_api_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=ClientResponseError(None, None, status=500)) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'app_setup_error'} + + +async def test_unknown_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=Exception('Unknown error')) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'app_setup_error'} + + +async def test_app_created_then_show_wait_form(hass, app, smartthings_mock): + """Test SmartApp is created when one does not exist and shows wait form.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + smartthings = smartthings_mock.return_value + smartthings.apps.return_value = mock_coro(return_value=[]) + smartthings.create_app.return_value = mock_coro(return_value=(app, None)) + smartthings.update_app_settings.return_value = mock_coro() + smartthings.update_app_oauth.return_value = mock_coro() + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + + +async def test_app_updated_then_show_wait_form( + hass, app, smartthings_mock): + """Test SmartApp is updated when an existing is already created.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + api = smartthings_mock.return_value + api.apps.return_value = mock_coro(return_value=[app]) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + + +async def test_wait_form_displayed(hass): + """Test the wait for installation form is displayed.""" + 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()) + flow.api = smartthings_mock.return_value + flow.api.installed_apps.return_value = mock_coro(return_value=[]) + + 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 a config entry is created once the app is installed.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.access_token = str(uuid4()) + flow.api = smartthings_mock.return_value + flow.app_id = installed_app.app_id + flow.api.installed_apps.return_value = \ + mock_coro(return_value=[installed_app]) + + result = await flow.async_step_wait_install({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['app_id'] == installed_app.app_id + assert result['data']['installed_app_id'] == \ + installed_app.installed_app_id + assert result['data']['location_id'] == installed_app.location_id + assert result['data']['access_token'] == flow.access_token + 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.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + flow.access_token = str(uuid4()) + flow.app_id = app.app_id + flow.api = smartthings_mock.return_value + flow.api.installed_apps.return_value = \ + mock_coro(return_value=installed_apps) + + result = await flow.async_step_wait_install({}) + + 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['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].title == locations[1].name diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py new file mode 100644 index 00000000000..d20d2d4e047 --- /dev/null +++ b/tests/components/smartthings/test_init.py @@ -0,0 +1,183 @@ +"""Tests for the SmartThings component init module.""" +from unittest.mock import Mock, patch +from uuid import uuid4 + +from aiohttp import ClientConnectionError, ClientResponseError +from pysmartthings import InstalledAppStatus +import pytest + +from homeassistant.components import smartthings +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_PLATFORMS) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from tests.common import mock_coro + + +async def test_unrecoverable_api_errors_create_new_flow( + hass, config_entry, smartthings_mock): + """ + Test a new config flow is initiated when there are API errors. + + 401 (unauthorized): Occurs when the access token is no longer valid. + 403 (forbidden/not found): Occurs when the app or installed app could + not be retrieved/found (likely deleted?) + """ + api = smartthings_mock.return_value + for error_status in (401, 403): + setattr(hass.config_entries, '_entries', [config_entry]) + api.app.return_value = mock_coro( + exception=ClientResponseError(None, None, + status=error_status)) + + # Assert setup returns false + result = await smartthings.async_setup_entry(hass, config_entry) + assert not result + + # Assert entry was removed and new flow created + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]['handler'] == 'smartthings' + assert flows[0]['context'] == {'source': 'import'} + hass.config_entries.flow.async_abort(flows[0]['flow_id']) + + +async def test_recoverable_api_errors_raise_not_ready( + hass, config_entry, smartthings_mock): + """Test config entry not ready raised for recoverable API errors.""" + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro( + exception=ClientResponseError(None, None, status=500)) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_connection_errors_raise_not_ready( + hass, config_entry, smartthings_mock): + """Test config entry not ready raised for connection errors.""" + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro( + exception=ClientConnectionError()) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_base_url_no_longer_https_does_not_load( + hass, config_entry, app, smartthings_mock): + """Test base_url no longer valid creates a new flow.""" + hass.config.api.base_url = 'http://0.0.0.0' + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + + # Assert setup returns false + result = await smartthings.async_setup_entry(hass, config_entry) + assert not result + + +async def test_unauthorized_installed_app_raises_not_ready( + hass, config_entry, app, installed_app, + smartthings_mock): + """Test config entry not ready raised when the app isn't authorized.""" + setattr(hass.config_entries, '_entries', [config_entry]) + setattr(installed_app, '_installed_app_status', + InstalledAppStatus.PENDING) + + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + api.installed_app.return_value = mock_coro(return_value=installed_app) + + with pytest.raises(ConfigEntryNotReady): + await smartthings.async_setup_entry(hass, config_entry) + + +async def test_config_entry_loads_platforms( + hass, config_entry, app, installed_app, + device, smartthings_mock): + """Test config entry loads properly and proxies to platforms.""" + setattr(hass.config_entries, '_entries', [config_entry]) + + api = smartthings_mock.return_value + api.app.return_value = mock_coro(return_value=app) + api.installed_app.return_value = mock_coro(return_value=installed_app) + api.devices.return_value = mock_coro(return_value=[device]) + + with patch.object(hass.config_entries, 'async_forward_entry_setup', + return_value=mock_coro()) as forward_mock: + assert await smartthings.async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) + + +async def test_unload_entry(hass, config_entry): + """Test entries are unloaded correctly.""" + broker = Mock() + broker.event_handler_disconnect = Mock() + hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker + + with patch.object(hass.config_entries, 'async_forward_entry_unload', + return_value=mock_coro( + return_value=True + )) as forward_mock: + assert await smartthings.async_unload_entry(hass, config_entry) + assert broker.event_handler_disconnect.call_count == 1 + assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] + # Assert platforms unloaded + await hass.async_block_till_done() + assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) + + +async def test_event_handler_dispatches_updated_devices( + hass, device_factory, event_request_factory): + """Test the event handler dispatches updated devices.""" + devices = [ + device_factory('Bedroom 1 Switch', ['switch']), + device_factory('Bathroom 1', ['switch']), + device_factory('Sensor', ['motionSensor']), + ] + device_ids = [devices[0].device_id, devices[1].device_id, + devices[2].device_id] + request = event_request_factory(device_ids) + called = False + + def signal(ids): + nonlocal called + called = True + assert device_ids == ids + async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) + broker = smartthings.DeviceBroker( + hass, devices, request.installed_app_id) + + await broker.event_handler(request, None, None) + await hass.async_block_till_done() + + assert called + for device in devices: + assert device.status.attributes['Updated'] == 'Value' + + +async def test_event_handler_ignores_other_installed_app( + hass, device_factory, event_request_factory): + """Test the event handler dispatches updated devices.""" + device = device_factory('Bedroom 1 Switch', ['switch']) + request = event_request_factory([device.device_id]) + called = False + + def signal(ids): + nonlocal called + called = True + async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) + broker = smartthings.DeviceBroker(hass, [device], str(uuid4())) + + await broker.event_handler(request, None, None) + await hass.async_block_till_done() + + assert not called diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py new file mode 100644 index 00000000000..0f517222c4a --- /dev/null +++ b/tests/components/smartthings/test_smartapp.py @@ -0,0 +1,112 @@ +"""Tests for the smartapp module.""" +from unittest.mock import Mock, patch +from uuid import uuid4 + +from pysmartthings import AppEntity + +from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings.const import ( + DATA_MANAGER, DOMAIN, SUPPORTED_CAPABILITIES) + +from tests.common import mock_coro + + +async def test_update_app(hass, app): + """Test update_app does not save if app is current.""" + await smartapp.update_app(hass, app) + assert app.save.call_count == 0 + + +async def test_update_app_updated_needed(hass, app): + """Test update_app updates when an app is needed.""" + mock_app = Mock(spec=AppEntity) + mock_app.app_name = 'Test' + mock_app.refresh.return_value = mock_coro() + mock_app.save.return_value = mock_coro() + + await smartapp.update_app(hass, mock_app) + + assert mock_app.save.call_count == 1 + assert mock_app.app_name == 'Test' + assert mock_app.display_name == app.display_name + assert mock_app.description == app.description + assert mock_app.webhook_target_url == app.webhook_target_url + assert mock_app.app_type == app.app_type + assert mock_app.single_instance == app.single_instance + assert mock_app.classifications == app.classifications + + +async def test_smartapp_install_abort_if_no_other(hass, smartthings_mock): + """Test aborts if no other app was configured already.""" + api = smartthings_mock.return_value + api.create_subscription.return_value = mock_coro() + app = Mock() + app.app_id = uuid4() + request = Mock() + request.installed_app_id = uuid4() + request.auth_token = uuid4() + request.location_id = uuid4() + + await smartapp.smartapp_install(hass, request, None, app) + + entries = hass.config_entries.async_entries('smartthings') + assert not entries + assert api.create_subscription.call_count == \ + len(SUPPORTED_CAPABILITIES) + + +async def test_smartapp_install_creates_flow( + hass, smartthings_mock, config_entry, location): + """Test installation creates flow.""" + # Arrange + setattr(hass.config_entries, '_entries', [config_entry]) + api = smartthings_mock.return_value + api.create_subscription.return_value = mock_coro() + app = Mock() + app.app_id = config_entry.data['app_id'] + request = Mock() + request.installed_app_id = str(uuid4()) + request.auth_token = str(uuid4()) + request.location_id = location.location_id + # Act + await smartapp.smartapp_install(hass, request, None, app) + # Assert + await hass.async_block_till_done() + entries = hass.config_entries.async_entries('smartthings') + assert len(entries) == 2 + assert api.create_subscription.call_count == \ + len(SUPPORTED_CAPABILITIES) + assert entries[1].data['app_id'] == app.app_id + assert entries[1].data['installed_app_id'] == request.installed_app_id + assert entries[1].data['location_id'] == request.location_id + assert entries[1].data['access_token'] == \ + config_entry.data['access_token'] + assert entries[1].title == location.name + + +async def test_smartapp_uninstall(hass, config_entry): + """Test the config entry is unloaded when the app is uninstalled.""" + setattr(hass.config_entries, '_entries', [config_entry]) + app = Mock() + app.app_id = config_entry.data['app_id'] + request = Mock() + request.installed_app_id = config_entry.data['installed_app_id'] + + with patch.object(hass.config_entries, 'async_remove', + return_value=mock_coro()) as remove: + await smartapp.smartapp_uninstall(hass, request, None, app) + assert remove.call_count == 1 + + +async def test_smartapp_webhook(hass): + """Test the smartapp webhook calls the manager.""" + manager = Mock() + manager.handle_request = Mock() + manager.handle_request.return_value = mock_coro(return_value={}) + hass.data[DOMAIN][DATA_MANAGER] = manager + request = Mock() + request.headers = [] + request.json.return_value = mock_coro(return_value={}) + result = await smartapp.smartapp_webhook(hass, '', request) + + assert result.body == b'{}' diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py new file mode 100644 index 00000000000..7bf8b15af51 --- /dev/null +++ b/tests/components/smartthings/test_switch.py @@ -0,0 +1,135 @@ +""" +Test for the SmartThings switch platform. + +The only mocking required is of the underlying SmartThings API object so +real HTTP calls are not initiated during testing. +""" +from pysmartthings import Attribute, Capability + +from homeassistant.components.smartthings import DeviceBroker, switch +from homeassistant.components.smartthings.const import ( + DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry) +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +async def _setup_platform(hass, *devices): + """Set up the SmartThings switch platform and prerequisites.""" + hass.config.components.add(DOMAIN) + broker = DeviceBroker(hass, devices, '') + config_entry = ConfigEntry("1", DOMAIN, "Test", {}, + SOURCE_USER, CONN_CLASS_CLOUD_PUSH) + hass.data[DOMAIN] = { + DATA_BROKERS: { + config_entry.entry_id: broker + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') + await hass.async_block_till_done() + return config_entry + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await switch.async_setup_platform(None, None, None) + + +def test_is_switch(device_factory): + """Test switches are correctly identified.""" + switch_device = device_factory('Switch', [Capability.switch]) + non_switch_devices = [ + device_factory('Light', [Capability.switch, Capability.switch_level]), + device_factory('Fan', [Capability.switch, Capability.fan_speed]), + device_factory('Color Light', [Capability.switch, + Capability.color_control]), + device_factory('Temp Light', [Capability.switch, + Capability.color_temperature]), + device_factory('Unknown', ['Unknown']), + ] + assert switch.is_switch(switch_device) + for non_switch_device in non_switch_devices: + assert not switch.is_switch(non_switch_device) + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'on'}) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + # Act + await _setup_platform(hass, device) + # Assert + entity = entity_registry.async_get('switch.switch_1') + assert entity + assert entity.unique_id == device.device_id + device_entry = device_registry.async_get_device( + {(DOMAIN, device.device_id)}, []) + assert device_entry + assert device_entry.name == device.label + assert device_entry.model == device.device_type_name + assert device_entry.manufacturer == 'Unavailable' + + +async def test_turn_off(hass, device_factory): + """Test the switch turns of successfully.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'on'}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'switch', 'turn_off', {'entity_id': 'switch.switch_1'}, + blocking=True) + # Assert + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'off' + + +async def test_turn_on(hass, device_factory): + """Test the switch turns of successfully.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'off'}) + await _setup_platform(hass, device) + # Act + await hass.services.async_call( + 'switch', 'turn_on', {'entity_id': 'switch.switch_1'}, + blocking=True) + # Assert + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'on' + + +async def test_update_from_signal(hass, device_factory): + """Test the switch updates when receiving a signal.""" + # Arrange + device = device_factory('Switch_1', [Capability.switch], + {Attribute.switch: 'off'}) + await _setup_platform(hass, device) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('switch.switch_1') + assert state is not None + assert state.state == 'on' + + +async def test_unload_config_entry(hass, device_factory): + """Test the switch is removed when the config entry is unloaded.""" + # Arrange + device = device_factory('Switch', [Capability.switch], + {Attribute.switch: 'on'}) + config_entry = await _setup_platform(hass, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, 'switch') + # Assert + assert not hass.states.get('switch.switch_1')