1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Add cloudhook support to SmartThings component (#21905)

* Add support for Nabu Casa cloudhooks

* Added tests to cover cloudhook creation and removal

* Remove cloud dependency
This commit is contained in:
Andrew Sayre
2019-03-11 07:33:25 -05:00
committed by Charles Garwood
parent 3fd6aa0ba9
commit c401f35a43
9 changed files with 176 additions and 33 deletions

View File

@@ -25,9 +25,10 @@ from .const import (
TOKEN_REFRESH_INTERVAL)
from .smartapp import (
setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions,
validate_installed_app)
unload_smartapp_endpoint, validate_installed_app,
validate_webhook_requirements)
REQUIREMENTS = ['pysmartapp==0.3.1', 'pysmartthings==0.6.7']
REQUIREMENTS = ['pysmartapp==0.3.2', 'pysmartthings==0.6.7']
DEPENDENCIES = ['webhook']
_LOGGER = logging.getLogger(__name__)
@@ -64,7 +65,7 @@ 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://'):
if not validate_webhook_requirements(hass):
_LOGGER.warning("The 'base_url' of the 'http' component must be "
"configured and start with 'https://'")
return False
@@ -200,8 +201,9 @@ async def async_remove_entry(
# Remove the app if not referenced by other entries, which if already
# removed raises a 403 error.
all_entries = hass.config_entries.async_entries(DOMAIN)
app_id = entry.data[CONF_APP_ID]
app_count = sum(1 for entry in hass.config_entries.async_entries(DOMAIN)
app_count = sum(1 for entry in all_entries
if entry.data[CONF_APP_ID] == app_id)
if app_count > 1:
_LOGGER.debug("App %s was not removed because it is in use by other"
@@ -218,6 +220,9 @@ async def async_remove_entry(
raise
_LOGGER.debug("Removed app %s", app_id)
if len(all_entries) == 1:
await unload_smartapp_endpoint(hass)
class DeviceBroker:
"""Manages an individual SmartThings config entry."""

View File

@@ -13,7 +13,8 @@ from .const import (
CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, DOMAIN,
VAL_UID_MATCHER)
from .smartapp import (
create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app)
create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app,
validate_webhook_requirements)
_LOGGER = logging.getLogger(__name__)
@@ -56,10 +57,6 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
from pysmartthings import APIResponseError, AppOAuth, 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)
@@ -81,6 +78,10 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow):
# Setup end-point
await setup_smartapp_endpoint(self.hass)
if not validate_webhook_requirements(self.hass):
errors['base'] = "base_url_not_https"
return self._show_step_user(errors)
try:
app = await find_app(self.hass, self.api)
if app:

View File

@@ -8,6 +8,7 @@ APP_OAUTH_SCOPES = [
]
APP_NAME_PREFIX = 'homeassistant.'
CONF_APP_ID = 'app_id'
CONF_CLOUDHOOK_URL = 'cloudhook_url'
CONF_INSTALLED_APP_ID = 'installed_app_id'
CONF_INSTALLED_APPS = 'installed_apps'
CONF_INSTANCE_ID = 'instance_id'

View File

@@ -8,11 +8,12 @@ callbacks when device states change.
import asyncio
import functools
import logging
from urllib.parse import urlparse
from uuid import uuid4
from aiohttp import web
from homeassistant.components import webhook
from homeassistant.components import cloud, webhook
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
@@ -21,9 +22,10 @@ from homeassistant.helpers.typing import HomeAssistantType
from .const import (
APP_NAME_PREFIX, APP_OAUTH_CLIENT_NAME, APP_OAUTH_SCOPES, CONF_APP_ID,
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, CONF_INSTANCE_ID,
CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DATA_MANAGER, DOMAIN,
SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION)
CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS,
CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS,
DATA_MANAGER, DOMAIN, SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX,
STORAGE_KEY, STORAGE_VERSION)
_LOGGER = logging.getLogger(__name__)
@@ -59,15 +61,39 @@ async def validate_installed_app(api, installed_app_id: str):
return installed_app
def validate_webhook_requirements(hass: HomeAssistantType) -> bool:
"""Ensure HASS is setup properly to receive webhooks."""
if cloud.async_active_subscription(hass):
return True
return get_webhook_url(hass).lower().startswith('https://')
def get_webhook_url(hass: HomeAssistantType) -> str:
"""
Get the URL of the webhook.
Return the cloudhook if available, otherwise local webhook.
"""
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloud.async_active_subscription(hass) and cloudhook_url is not None:
return cloudhook_url
return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
def _get_app_template(hass: HomeAssistantType):
from pysmartthings import APP_TYPE_WEBHOOK, CLASSIFICATION_AUTOMATION
endpoint = "at " + hass.config.api.base_url
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url is not None:
endpoint = "via Nabu Casa"
description = "{} {}".format(hass.config.location_name, endpoint)
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]),
'description': description,
'webhook_target_url': get_webhook_url(hass),
'app_type': APP_TYPE_WEBHOOK,
'single_instance': True,
'classifications': [CLASSIFICATION_AUTOMATION]
@@ -162,33 +188,80 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType):
# Create config
config = {
CONF_INSTANCE_ID: str(uuid4()),
CONF_WEBHOOK_ID: webhook.generate_secret()
CONF_WEBHOOK_ID: webhook.generate_secret(),
CONF_CLOUDHOOK_URL: None
}
await store.async_save(config)
# Register webhook
webhook.async_register(hass, DOMAIN, 'SmartApp',
config[CONF_WEBHOOK_ID], smartapp_webhook)
# Create webhook if eligible
cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
if cloudhook_url is None \
and cloud.async_active_subscription(hass) \
and not hass.config_entries.async_entries(DOMAIN):
cloudhook_url = await cloud.async_create_cloudhook(
hass, config[CONF_WEBHOOK_ID])
config[CONF_CLOUDHOOK_URL] = cloudhook_url
await store.async_save(config)
_LOGGER.debug("Created cloudhook '%s'", cloudhook_url)
# SmartAppManager uses a dispatcher to invoke callbacks when push events
# occur. Use hass' implementation instead of the built-in one.
dispatcher = Dispatcher(
signal_prefix=SIGNAL_SMARTAPP_PREFIX,
connect=functools.partial(async_dispatcher_connect, hass),
send=functools.partial(async_dispatcher_send, hass))
manager = SmartAppManager(
webhook.async_generate_path(config[CONF_WEBHOOK_ID]),
dispatcher=dispatcher)
# Path is used in digital signature validation
path = urlparse(cloudhook_url).path if cloudhook_url else \
webhook.async_generate_path(config[CONF_WEBHOOK_ID])
manager = SmartAppManager(path, dispatcher=dispatcher)
manager.connect_install(functools.partial(smartapp_install, hass))
manager.connect_update(functools.partial(smartapp_update, hass))
manager.connect_uninstall(functools.partial(smartapp_uninstall, hass))
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],
# Will not be present if not enabled
CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
CONF_INSTALLED_APPS: []
}
_LOGGER.debug("Setup endpoint for %s",
cloudhook_url if cloudhook_url else
webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]))
async def unload_smartapp_endpoint(hass: HomeAssistantType):
"""Tear down the component configuration."""
if DOMAIN not in hass.data:
return
# Remove the cloudhook if it was created
cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
if cloudhook_url and cloud.async_is_logged_in(hass):
await cloud.async_delete_cloudhook(
hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Remove cloudhook from storage
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
await store.async_save({
CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID],
CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID],
CONF_CLOUDHOOK_URL: None
})
_LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url)
# Remove the webhook
webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
# Disconnect all brokers
for broker in hass.data[DOMAIN][DATA_BROKERS].values():
broker.disconnect()
# Remove all handlers from manager
hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
# Remove the component data
hass.data.pop(DOMAIN)
async def smartapp_sync_subscriptions(
@@ -285,6 +358,9 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app):
# Store the data where the flow can find it
hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data)
_LOGGER.debug("Installed SmartApp '%s' under parent app '%s'",
req.installed_app_id, app.app_id)
async def smartapp_update(hass: HomeAssistantType, req, resp, app):
"""
@@ -301,7 +377,7 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app):
entry.data[CONF_REFRESH_TOKEN] = req.refresh_token
hass.config_entries.async_update_entry(entry)
_LOGGER.debug("SmartApp '%s' under parent app '%s' was updated",
_LOGGER.debug("Updated SmartApp '%s' under parent app '%s'",
req.installed_app_id, app.app_id)
@@ -316,12 +392,13 @@ async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app):
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)
_LOGGER.debug("Uninstalled SmartApp '%s' under parent app '%s'",
req.installed_app_id, app.app_id)
async def smartapp_webhook(hass: HomeAssistantType, webhook_id: str, request):
"""