Add SmartThings component and switch platform (#20148)

* Added SmartThings component and switch platform

* Corrected comment typos.

* Embedded switch platform.

* Replaced custom view usage with webhook component.

* Replaced urls with tokens in strings.

* Fixed line length.

* Use generated webhook id instead of static one.

* Reuse core constant instead of defining again.

* Optimizations in anticipation of future platforms.

* Use async_generate_path instead of hard-coded path.

* Fixed line length.

* Updates per review feedback.

* Updates per latest review feedback.
This commit is contained in:
Andrew Sayre 2019-01-30 19:31:59 -06:00 committed by Paulus Schoutsen
parent 5a0c707a37
commit 69ec7980ad
18 changed files with 1793 additions and 0 deletions

View file

@ -236,6 +236,7 @@ homeassistant/components/*/rfxtrx.py @danielhiversen
# S # S
homeassistant/components/simplisafe/* @bachya homeassistant/components/simplisafe/* @bachya
homeassistant/components/smartthings/* @andrewsayre
# T # T
homeassistant/components/tahoma.py @philklei homeassistant/components/tahoma.py @philklei

View file

@ -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://`."
}
}
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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://`."
}
}
}

View file

@ -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

View file

@ -160,6 +160,7 @@ FLOWS = [
'point', 'point',
'rainmachine', 'rainmachine',
'simplisafe', 'simplisafe',
'smartthings',
'smhi', 'smhi',
'sonos', 'sonos',
'tellduslive', 'tellduslive',

View file

@ -1224,6 +1224,12 @@ pysher==1.0.1
# homeassistant.components.sensor.sma # homeassistant.components.sensor.sma
pysma==0.3.1 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.device_tracker.snmp
# homeassistant.components.sensor.snmp # homeassistant.components.sensor.snmp
# homeassistant.components.switch.snmp # homeassistant.components.switch.snmp

View file

@ -210,6 +210,12 @@ pyotp==2.2.6
# homeassistant.components.qwikswitch # homeassistant.components.qwikswitch
pyqwikswitch==0.8 pyqwikswitch==0.8
# homeassistant.components.smartthings
pysmartapp==0.3.0
# homeassistant.components.smartthings
pysmartthings==0.4.2
# homeassistant.components.sonos # homeassistant.components.sonos
pysonos==0.0.6 pysonos==0.0.6

View file

@ -90,6 +90,8 @@ TEST_REQUIREMENTS = (
'pynx584', 'pynx584',
'pyopenuv', 'pyopenuv',
'pyotp', 'pyotp',
'pysmartapp',
'pysmartthings',
'pysonos', 'pysonos',
'pyqwikswitch', 'pyqwikswitch',
'PyRMVtransport', 'PyRMVtransport',

View file

@ -0,0 +1 @@
"""Tests for the SmartThings component."""

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'{}'

View file

@ -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')