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:
parent
5a0c707a37
commit
69ec7980ad
18 changed files with 1793 additions and 0 deletions
|
@ -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
|
||||||
|
|
27
homeassistant/components/smartthings/.translations/en.json
Normal file
27
homeassistant/components/smartthings/.translations/en.json
Normal 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://`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
213
homeassistant/components/smartthings/__init__.py
Normal file
213
homeassistant/components/smartthings/__init__.py
Normal 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
|
179
homeassistant/components/smartthings/config_flow.py
Normal file
179
homeassistant/components/smartthings/config_flow.py
Normal 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)
|
31
homeassistant/components/smartthings/const.py
Normal file
31
homeassistant/components/smartthings/const.py
Normal 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)
|
275
homeassistant/components/smartthings/smartapp.py
Normal file
275
homeassistant/components/smartthings/smartapp.py
Normal 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)
|
27
homeassistant/components/smartthings/strings.json
Normal file
27
homeassistant/components/smartthings/strings.json
Normal 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://`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
70
homeassistant/components/smartthings/switch.py
Normal file
70
homeassistant/components/smartthings/switch.py
Normal 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
|
|
@ -160,6 +160,7 @@ FLOWS = [
|
||||||
'point',
|
'point',
|
||||||
'rainmachine',
|
'rainmachine',
|
||||||
'simplisafe',
|
'simplisafe',
|
||||||
|
'smartthings',
|
||||||
'smhi',
|
'smhi',
|
||||||
'sonos',
|
'sonos',
|
||||||
'tellduslive',
|
'tellduslive',
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -90,6 +90,8 @@ TEST_REQUIREMENTS = (
|
||||||
'pynx584',
|
'pynx584',
|
||||||
'pyopenuv',
|
'pyopenuv',
|
||||||
'pyotp',
|
'pyotp',
|
||||||
|
'pysmartapp',
|
||||||
|
'pysmartthings',
|
||||||
'pysonos',
|
'pysonos',
|
||||||
'pyqwikswitch',
|
'pyqwikswitch',
|
||||||
'PyRMVtransport',
|
'PyRMVtransport',
|
||||||
|
|
1
tests/components/smartthings/__init__.py
Normal file
1
tests/components/smartthings/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the SmartThings component."""
|
279
tests/components/smartthings/conftest.py
Normal file
279
tests/components/smartthings/conftest.py
Normal 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
|
245
tests/components/smartthings/test_config_flow.py
Normal file
245
tests/components/smartthings/test_config_flow.py
Normal 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
|
183
tests/components/smartthings/test_init.py
Normal file
183
tests/components/smartthings/test_init.py
Normal 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
|
112
tests/components/smartthings/test_smartapp.py
Normal file
112
tests/components/smartthings/test_smartapp.py
Normal 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'{}'
|
135
tests/components/smartthings/test_switch.py
Normal file
135
tests/components/smartthings/test_switch.py
Normal 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')
|
Loading…
Add table
Add a link
Reference in a new issue