1 """SmartApp functionality to receive cloud-push notifications."""
8 from urllib.parse
import urlparse
11 from aiohttp
import web
12 from pysmartapp
import Dispatcher, SmartAppManager
13 from pysmartapp.const
import SETTINGS_APP_ID
14 from pysmartthings
import (
17 CLASSIFICATION_AUTOMATION,
34 async_dispatcher_connect,
35 async_dispatcher_send,
42 APP_OAUTH_CLIENT_NAME,
45 CONF_INSTALLED_APP_ID,
53 SIGNAL_SMARTAPP_PREFIX,
56 SUBSCRIPTION_WARNING_LIMIT,
59 _LOGGER = logging.getLogger(__name__)
63 """Format the unique id for a config entry."""
64 return f
"{app_id}_{location_id}"
67 async
def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity |
None:
68 """Find an existing SmartApp for this installation of hass."""
69 apps = await api.apps()
70 for app
in [app
for app
in apps
if app.app_name.startswith(APP_NAME_PREFIX)]:
72 settings = await app.settings()
74 settings.settings.get(SETTINGS_INSTANCE_ID)
75 == hass.data[DOMAIN][CONF_INSTANCE_ID]
82 """Ensure the specified installed SmartApp is valid and functioning.
84 Query the API for the installed SmartApp and validate that it is tied to
85 the specified app_id and is in an authorized state.
87 installed_app = await api.installed_app(installed_app_id)
88 if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED:
90 f
"Installed SmartApp instance '{installed_app.display_name}' "
91 f
"({installed_app.installed_app_id}) is not AUTHORIZED "
92 f
"but instead {installed_app.installed_app_status}"
98 """Ensure Home Assistant is setup properly to receive webhooks."""
99 if cloud.async_active_subscription(hass):
101 if hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
is not None:
107 """Get the URL of the webhook.
109 Return the cloudhook if available, otherwise local webhook.
111 cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
112 if cloud.async_active_subscription(hass)
and cloudhook_url
is not None:
114 return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
119 endpoint = f
"at {get_url(hass, allow_cloud=False, prefer_external=True)}"
120 except NoURLAvailableError:
123 cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
124 if cloudhook_url
is not None:
125 endpoint =
"via Nabu Casa"
126 description = f
"{hass.config.location_name} {endpoint}"
129 "app_name": APP_NAME_PREFIX +
str(uuid4()),
130 "display_name":
"Home Assistant",
131 "description": description,
133 "app_type": APP_TYPE_WEBHOOK,
134 "single_instance":
True,
135 "classifications": [CLASSIFICATION_AUTOMATION],
140 """Create a SmartApp for this instance of hass."""
144 for key, value
in template.items():
145 setattr(app, key, value)
146 app, client = await api.create_app(app)
147 _LOGGER.debug(
"Created SmartApp '%s' (%s)", app.app_name, app.app_id)
150 settings = AppSettings(app.app_id)
151 settings.settings[SETTINGS_APP_ID] = app.app_id
152 settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID]
153 await api.update_app_settings(settings)
155 "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id
159 oauth = AppOAuth(app.app_id)
160 oauth.client_name = APP_OAUTH_CLIENT_NAME
161 oauth.scope.extend(APP_OAUTH_SCOPES)
162 await api.update_app_oauth(oauth)
163 _LOGGER.debug(
"Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id)
168 """Ensure the SmartApp is up-to-date and update if necessary."""
170 template.pop(
"app_name")
171 update_required =
False
172 for key, value
in template.items():
173 if getattr(app, key) != value:
174 update_required =
True
175 setattr(app, key, value)
179 "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id
184 """Configure an individual SmartApp in hass.
186 Register the SmartApp with the SmartAppManager so that hass will service
187 lifecycle events (install, event, etc...). A unique SmartApp is created
188 for each SmartThings account that is configured in hass.
190 manager = hass.data[DOMAIN][DATA_MANAGER]
191 if smartapp := manager.smartapps.get(app.app_id):
194 smartapp = manager.register(app.app_id, app.webhook_public_key)
195 smartapp.name = app.display_name
196 smartapp.description = app.description
197 smartapp.permissions.extend(APP_OAUTH_SCOPES)
202 """Configure the SmartApp webhook in hass.
204 SmartApps are an extension point within the SmartThings ecosystem and
205 is used to receive push updates (i.e. device updates) from the cloud.
207 if hass.data.get(DOMAIN):
209 if not fresh_install:
216 store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
218 if fresh_install
or not (config := await store.async_load()):
221 CONF_INSTANCE_ID:
str(uuid4()),
222 CONF_WEBHOOK_ID: secrets.token_hex(),
223 CONF_CLOUDHOOK_URL:
None,
225 await store.async_save(config)
228 webhook.async_register(
229 hass, DOMAIN,
"SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook
233 cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
235 cloudhook_url
is None
236 and cloud.async_active_subscription(hass)
237 and not hass.config_entries.async_entries(DOMAIN)
239 cloudhook_url = await cloud.async_create_cloudhook(
240 hass, config[CONF_WEBHOOK_ID]
242 config[CONF_CLOUDHOOK_URL] = cloudhook_url
243 await store.async_save(config)
244 _LOGGER.debug(
"Created cloudhook '%s'", cloudhook_url)
248 dispatcher = Dispatcher(
249 signal_prefix=SIGNAL_SMARTAPP_PREFIX,
250 connect=functools.partial(async_dispatcher_connect, hass),
251 send=functools.partial(async_dispatcher_send, hass),
255 urlparse(cloudhook_url).path
257 else webhook.async_generate_path(config[CONF_WEBHOOK_ID])
259 manager = SmartAppManager(path, dispatcher=dispatcher)
260 manager.connect_install(functools.partial(smartapp_install, hass))
261 manager.connect_update(functools.partial(smartapp_update, hass))
262 manager.connect_uninstall(functools.partial(smartapp_uninstall, hass))
264 hass.data[DOMAIN] = {
265 DATA_MANAGER: manager,
266 CONF_INSTANCE_ID: config[CONF_INSTANCE_ID],
268 CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
270 CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
273 "Setup endpoint for %s",
276 else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]),
281 """Tear down the component configuration."""
282 if DOMAIN
not in hass.data:
285 cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
286 if cloudhook_url
and cloud.async_is_logged_in(hass):
287 await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
289 store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
290 await store.async_save(
292 CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID],
293 CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID],
294 CONF_CLOUDHOOK_URL:
None,
297 _LOGGER.debug(
"Cloudhook '%s' was removed", cloudhook_url)
299 webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
301 for broker
in hass.data[DOMAIN][DATA_BROKERS].values():
304 hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
306 hass.data.pop(DOMAIN)
313 installed_app_id: str,
316 """Synchronize subscriptions of an installed up."""
320 async
def create_subscription(target: str):
322 sub.installed_app_id = installed_app_id
323 sub.location_id = location_id
324 sub.source_type = SourceType.CAPABILITY
325 sub.capability = target
327 await api.create_subscription(sub)
329 "Created subscription for '%s' under app '%s'", target, installed_app_id
331 except Exception
as error:
333 "Failed to create subscription for '%s' under app '%s': %s",
339 async
def delete_subscription(sub: SubscriptionEntity):
341 await api.delete_subscription(installed_app_id, sub.subscription_id)
344 "Removed subscription for '%s' under app '%s' because it was no"
350 except Exception
as error:
352 "Failed to remove subscription for '%s' under app '%s': %s",
360 for device
in devices:
361 capabilities.update(device.capabilities)
363 capabilities.intersection_update(CAPABILITIES)
365 capabilities.difference_update(IGNORED_CAPABILITIES)
366 capability_count = len(capabilities)
367 if capability_count > SUBSCRIPTION_WARNING_LIMIT:
370 "Some device attributes may not receive push updates and there may be"
371 " subscription creation failures under app '%s' because %s"
372 " subscriptions are required but there is a limit of %s per app"
376 SUBSCRIPTION_WARNING_LIMIT,
379 "Synchronizing subscriptions for %s capabilities under app '%s': %s",
386 subscriptions = await api.subscriptions(installed_app_id)
387 for subscription
in subscriptions:
388 if subscription.capability
in capabilities:
389 capabilities.remove(subscription.capability)
392 tasks.append(delete_subscription(subscription))
395 tasks.extend([create_subscription(c)
for c
in capabilities])
398 await asyncio.gather(*tasks)
400 _LOGGER.debug(
"Subscriptions for app '%s' are up-to-date", installed_app_id)
407 installed_app_id: str,
410 """Continue a config flow if one is in progress for the specific installed app."""
415 for flow
in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
416 if flow[
"context"].
get(
"unique_id") == unique_id
421 await hass.config_entries.flow.async_configure(
424 CONF_INSTALLED_APP_ID: installed_app_id,
425 CONF_REFRESH_TOKEN: refresh_token,
429 "Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
437 """Handle a SmartApp installation and continue the config flow."""
439 hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
442 "Installed SmartApp '%s' under parent app '%s'",
443 req.installed_app_id,
449 """Handle a SmartApp update and either update the entry or continue the flow."""
453 for entry
in hass.config_entries.async_entries(DOMAIN)
454 if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
459 hass.config_entries.async_update_entry(
460 entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
463 "Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
465 req.installed_app_id,
470 hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
473 "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
478 """Handle when a SmartApp is removed from a location by the user.
480 Find and delete the config entry representing the integration.
485 for entry
in hass.config_entries.async_entries(DOMAIN)
486 if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
493 await hass.config_entries.async_remove(entry.entry_id)
496 "Uninstalled SmartApp '%s' under parent app '%s'",
497 req.installed_app_id,
503 """Handle a smartapp lifecycle event callback from SmartThings.
505 Requests from SmartThings are digitally signed and the SmartAppManager
506 validates the signature for authenticity.
508 manager = hass.data[DOMAIN][DATA_MANAGER]
509 data = await request.json()
510 result = await manager.handle_request(data, request.headers)
511 return web.json_response(result)
web.Response get(self, web.Request request, str config_key)
def smartapp_install(HomeAssistant hass, req, resp, app)
def setup_smartapp_endpoint(HomeAssistant hass, bool fresh_install)
def _get_app_template(HomeAssistant hass)
def _continue_flow(HomeAssistant hass, str app_id, str location_id, str installed_app_id, str refresh_token)
def smartapp_uninstall(HomeAssistant hass, req, resp, app)
bool validate_webhook_requirements(HomeAssistant hass)
def smartapp_sync_subscriptions(HomeAssistant hass, str auth_token, str location_id, str installed_app_id, devices)
AppEntity|None find_app(HomeAssistant hass, SmartThings api)
def setup_smartapp(hass, app)
def validate_installed_app(api, str installed_app_id)
str get_webhook_url(HomeAssistant hass)
str format_unique_id(str app_id, str location_id)
def smartapp_update(HomeAssistant hass, req, resp, app)
def smartapp_webhook(HomeAssistant hass, str webhook_id, request)
def create_app(HomeAssistant hass, api)
def unload_smartapp_endpoint(HomeAssistant hass)
def update_app(HomeAssistant hass, app)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)