Home Assistant Unofficial Reference 2024.12.1
smartapp.py
Go to the documentation of this file.
1 """SmartApp functionality to receive cloud-push notifications."""
2 
3 import asyncio
4 import functools
5 import logging
6 import secrets
7 from typing import Any
8 from urllib.parse import urlparse
9 from uuid import uuid4
10 
11 from aiohttp import web
12 from pysmartapp import Dispatcher, SmartAppManager
13 from pysmartapp.const import SETTINGS_APP_ID
14 from pysmartthings import (
15  APP_TYPE_WEBHOOK,
16  CAPABILITIES,
17  CLASSIFICATION_AUTOMATION,
18  App,
19  AppEntity,
20  AppOAuth,
21  AppSettings,
22  InstalledAppStatus,
23  SmartThings,
24  SourceType,
25  Subscription,
26  SubscriptionEntity,
27 )
28 
29 from homeassistant.components import cloud, webhook
30 from homeassistant.const import CONF_WEBHOOK_ID
31 from homeassistant.core import HomeAssistant
32 from homeassistant.helpers.aiohttp_client import async_get_clientsession
34  async_dispatcher_connect,
35  async_dispatcher_send,
36 )
37 from homeassistant.helpers.network import NoURLAvailableError, get_url
38 from homeassistant.helpers.storage import Store
39 
40 from .const import (
41  APP_NAME_PREFIX,
42  APP_OAUTH_CLIENT_NAME,
43  APP_OAUTH_SCOPES,
44  CONF_CLOUDHOOK_URL,
45  CONF_INSTALLED_APP_ID,
46  CONF_INSTANCE_ID,
47  CONF_REFRESH_TOKEN,
48  DATA_BROKERS,
49  DATA_MANAGER,
50  DOMAIN,
51  IGNORED_CAPABILITIES,
52  SETTINGS_INSTANCE_ID,
53  SIGNAL_SMARTAPP_PREFIX,
54  STORAGE_KEY,
55  STORAGE_VERSION,
56  SUBSCRIPTION_WARNING_LIMIT,
57 )
58 
59 _LOGGER = logging.getLogger(__name__)
60 
61 
62 def format_unique_id(app_id: str, location_id: str) -> str:
63  """Format the unique id for a config entry."""
64  return f"{app_id}_{location_id}"
65 
66 
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)]:
71  # Load settings to compare instance id
72  settings = await app.settings()
73  if (
74  settings.settings.get(SETTINGS_INSTANCE_ID)
75  == hass.data[DOMAIN][CONF_INSTANCE_ID]
76  ):
77  return app
78  return None
79 
80 
81 async def validate_installed_app(api, installed_app_id: str):
82  """Ensure the specified installed SmartApp is valid and functioning.
83 
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.
86  """
87  installed_app = await api.installed_app(installed_app_id)
88  if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED:
89  raise RuntimeWarning(
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}"
93  )
94  return installed_app
95 
96 
97 def validate_webhook_requirements(hass: HomeAssistant) -> bool:
98  """Ensure Home Assistant is setup properly to receive webhooks."""
99  if cloud.async_active_subscription(hass):
100  return True
101  if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None:
102  return True
103  return get_webhook_url(hass).lower().startswith("https://")
104 
105 
106 def get_webhook_url(hass: HomeAssistant) -> str:
107  """Get the URL of the webhook.
108 
109  Return the cloudhook if available, otherwise local webhook.
110  """
111  cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
112  if cloud.async_active_subscription(hass) and cloudhook_url is not None:
113  return cloudhook_url
114  return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
115 
116 
117 def _get_app_template(hass: HomeAssistant):
118  try:
119  endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}"
120  except NoURLAvailableError:
121  endpoint = ""
122 
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}"
127 
128  return {
129  "app_name": APP_NAME_PREFIX + str(uuid4()),
130  "display_name": "Home Assistant",
131  "description": description,
132  "webhook_target_url": get_webhook_url(hass),
133  "app_type": APP_TYPE_WEBHOOK,
134  "single_instance": True,
135  "classifications": [CLASSIFICATION_AUTOMATION],
136  }
137 
138 
139 async def create_app(hass: HomeAssistant, api):
140  """Create a SmartApp for this instance of hass."""
141  # Create app from template attributes
142  template = _get_app_template(hass)
143  app = App()
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)
148 
149  # Set unique hass id in settings
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)
154  _LOGGER.debug(
155  "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id
156  )
157 
158  # Set oauth scopes
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)
164  return app, client
165 
166 
167 async def update_app(hass: HomeAssistant, app):
168  """Ensure the SmartApp is up-to-date and update if necessary."""
169  template = _get_app_template(hass)
170  template.pop("app_name") # don't update this
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)
176  if update_required:
177  await app.save()
178  _LOGGER.debug(
179  "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id
180  )
181 
182 
183 def setup_smartapp(hass, app):
184  """Configure an individual SmartApp in hass.
185 
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.
189  """
190  manager = hass.data[DOMAIN][DATA_MANAGER]
191  if smartapp := manager.smartapps.get(app.app_id):
192  # already setup
193  return smartapp
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)
198  return smartapp
199 
200 
201 async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool):
202  """Configure the SmartApp webhook in hass.
203 
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.
206  """
207  if hass.data.get(DOMAIN):
208  # already setup
209  if not fresh_install:
210  return
211 
212  # We're doing a fresh install, clean up
213  await unload_smartapp_endpoint(hass)
214 
215  # Get/create config to store a unique id for this hass instance.
216  store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
217 
218  if fresh_install or not (config := await store.async_load()):
219  # Create config
220  config = {
221  CONF_INSTANCE_ID: str(uuid4()),
222  CONF_WEBHOOK_ID: secrets.token_hex(),
223  CONF_CLOUDHOOK_URL: None,
224  }
225  await store.async_save(config)
226 
227  # Register webhook
228  webhook.async_register(
229  hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook
230  )
231 
232  # Create webhook if eligible
233  cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
234  if (
235  cloudhook_url is None
236  and cloud.async_active_subscription(hass)
237  and not hass.config_entries.async_entries(DOMAIN)
238  ):
239  cloudhook_url = await cloud.async_create_cloudhook(
240  hass, config[CONF_WEBHOOK_ID]
241  )
242  config[CONF_CLOUDHOOK_URL] = cloudhook_url
243  await store.async_save(config)
244  _LOGGER.debug("Created cloudhook '%s'", cloudhook_url)
245 
246  # SmartAppManager uses a dispatcher to invoke callbacks when push events
247  # occur. Use hass' implementation instead of the built-in one.
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),
252  )
253  # Path is used in digital signature validation
254  path = (
255  urlparse(cloudhook_url).path
256  if cloudhook_url
257  else webhook.async_generate_path(config[CONF_WEBHOOK_ID])
258  )
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))
263 
264  hass.data[DOMAIN] = {
265  DATA_MANAGER: manager,
266  CONF_INSTANCE_ID: config[CONF_INSTANCE_ID],
267  DATA_BROKERS: {},
268  CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
269  # Will not be present if not enabled
270  CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
271  }
272  _LOGGER.debug(
273  "Setup endpoint for %s",
274  cloudhook_url
275  if cloudhook_url
276  else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]),
277  )
278 
279 
280 async def unload_smartapp_endpoint(hass: HomeAssistant):
281  """Tear down the component configuration."""
282  if DOMAIN not in hass.data:
283  return
284  # Remove the cloudhook if it was created
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])
288  # Remove cloudhook from storage
289  store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
290  await store.async_save(
291  {
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,
295  }
296  )
297  _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url)
298  # Remove the webhook
299  webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
300  # Disconnect all brokers
301  for broker in hass.data[DOMAIN][DATA_BROKERS].values():
302  broker.disconnect()
303  # Remove all handlers from manager
304  hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
305  # Remove the component data
306  hass.data.pop(DOMAIN)
307 
308 
310  hass: HomeAssistant,
311  auth_token: str,
312  location_id: str,
313  installed_app_id: str,
314  devices,
315 ):
316  """Synchronize subscriptions of an installed up."""
317  api = SmartThings(async_get_clientsession(hass), auth_token)
318  tasks = []
319 
320  async def create_subscription(target: str):
321  sub = Subscription()
322  sub.installed_app_id = installed_app_id
323  sub.location_id = location_id
324  sub.source_type = SourceType.CAPABILITY
325  sub.capability = target
326  try:
327  await api.create_subscription(sub)
328  _LOGGER.debug(
329  "Created subscription for '%s' under app '%s'", target, installed_app_id
330  )
331  except Exception as error: # noqa: BLE001
332  _LOGGER.error(
333  "Failed to create subscription for '%s' under app '%s': %s",
334  target,
335  installed_app_id,
336  error,
337  )
338 
339  async def delete_subscription(sub: SubscriptionEntity):
340  try:
341  await api.delete_subscription(installed_app_id, sub.subscription_id)
342  _LOGGER.debug(
343  (
344  "Removed subscription for '%s' under app '%s' because it was no"
345  " longer needed"
346  ),
347  sub.capability,
348  installed_app_id,
349  )
350  except Exception as error: # noqa: BLE001
351  _LOGGER.error(
352  "Failed to remove subscription for '%s' under app '%s': %s",
353  sub.capability,
354  installed_app_id,
355  error,
356  )
357 
358  # Build set of capabilities and prune unsupported ones
359  capabilities = set()
360  for device in devices:
361  capabilities.update(device.capabilities)
362  # Remove items not defined in the library
363  capabilities.intersection_update(CAPABILITIES)
364  # Remove unused capabilities
365  capabilities.difference_update(IGNORED_CAPABILITIES)
366  capability_count = len(capabilities)
367  if capability_count > SUBSCRIPTION_WARNING_LIMIT:
368  _LOGGER.warning(
369  (
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"
373  ),
374  installed_app_id,
375  capability_count,
376  SUBSCRIPTION_WARNING_LIMIT,
377  )
378  _LOGGER.debug(
379  "Synchronizing subscriptions for %s capabilities under app '%s': %s",
380  capability_count,
381  installed_app_id,
382  capabilities,
383  )
384 
385  # Get current subscriptions and find differences
386  subscriptions = await api.subscriptions(installed_app_id)
387  for subscription in subscriptions:
388  if subscription.capability in capabilities:
389  capabilities.remove(subscription.capability)
390  else:
391  # Delete the subscription
392  tasks.append(delete_subscription(subscription))
393 
394  # Remaining capabilities need subscriptions created
395  tasks.extend([create_subscription(c) for c in capabilities])
396 
397  if tasks:
398  await asyncio.gather(*tasks)
399  else:
400  _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id)
401 
402 
403 async def _continue_flow(
404  hass: HomeAssistant,
405  app_id: str,
406  location_id: str,
407  installed_app_id: str,
408  refresh_token: str,
409 ):
410  """Continue a config flow if one is in progress for the specific installed app."""
411  unique_id = format_unique_id(app_id, location_id)
412  flow = next(
413  (
414  flow
415  for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
416  if flow["context"].get("unique_id") == unique_id
417  ),
418  None,
419  )
420  if flow is not None:
421  await hass.config_entries.flow.async_configure(
422  flow["flow_id"],
423  {
424  CONF_INSTALLED_APP_ID: installed_app_id,
425  CONF_REFRESH_TOKEN: refresh_token,
426  },
427  )
428  _LOGGER.debug(
429  "Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
430  flow["flow_id"],
431  installed_app_id,
432  app_id,
433  )
434 
435 
436 async def smartapp_install(hass: HomeAssistant, req, resp, app):
437  """Handle a SmartApp installation and continue the config flow."""
438  await _continue_flow(
439  hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
440  )
441  _LOGGER.debug(
442  "Installed SmartApp '%s' under parent app '%s'",
443  req.installed_app_id,
444  app.app_id,
445  )
446 
447 
448 async def smartapp_update(hass: HomeAssistant, req, resp, app):
449  """Handle a SmartApp update and either update the entry or continue the flow."""
450  entry = next(
451  (
452  entry
453  for entry in hass.config_entries.async_entries(DOMAIN)
454  if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
455  ),
456  None,
457  )
458  if entry:
459  hass.config_entries.async_update_entry(
460  entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
461  )
462  _LOGGER.debug(
463  "Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
464  entry.entry_id,
465  req.installed_app_id,
466  app.app_id,
467  )
468 
469  await _continue_flow(
470  hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
471  )
472  _LOGGER.debug(
473  "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
474  )
475 
476 
477 async def smartapp_uninstall(hass: HomeAssistant, req, resp, app):
478  """Handle when a SmartApp is removed from a location by the user.
479 
480  Find and delete the config entry representing the integration.
481  """
482  entry = next(
483  (
484  entry
485  for entry in hass.config_entries.async_entries(DOMAIN)
486  if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
487  ),
488  None,
489  )
490  if entry:
491  # Add as job not needed because the current coroutine was invoked
492  # from the dispatcher and is not being awaited.
493  await hass.config_entries.async_remove(entry.entry_id)
494 
495  _LOGGER.debug(
496  "Uninstalled SmartApp '%s' under parent app '%s'",
497  req.installed_app_id,
498  app.app_id,
499  )
500 
501 
502 async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request):
503  """Handle a smartapp lifecycle event callback from SmartThings.
504 
505  Requests from SmartThings are digitally signed and the SmartAppManager
506  validates the signature for authenticity.
507  """
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)
Definition: view.py:88
def smartapp_install(HomeAssistant hass, req, resp, app)
Definition: smartapp.py:436
def setup_smartapp_endpoint(HomeAssistant hass, bool fresh_install)
Definition: smartapp.py:201
def _get_app_template(HomeAssistant hass)
Definition: smartapp.py:117
def _continue_flow(HomeAssistant hass, str app_id, str location_id, str installed_app_id, str refresh_token)
Definition: smartapp.py:409
def smartapp_uninstall(HomeAssistant hass, req, resp, app)
Definition: smartapp.py:477
bool validate_webhook_requirements(HomeAssistant hass)
Definition: smartapp.py:97
def smartapp_sync_subscriptions(HomeAssistant hass, str auth_token, str location_id, str installed_app_id, devices)
Definition: smartapp.py:315
AppEntity|None find_app(HomeAssistant hass, SmartThings api)
Definition: smartapp.py:67
def validate_installed_app(api, str installed_app_id)
Definition: smartapp.py:81
str get_webhook_url(HomeAssistant hass)
Definition: smartapp.py:106
str format_unique_id(str app_id, str location_id)
Definition: smartapp.py:62
def smartapp_update(HomeAssistant hass, req, resp, app)
Definition: smartapp.py:448
def smartapp_webhook(HomeAssistant hass, str webhook_id, request)
Definition: smartapp.py:502
def create_app(HomeAssistant hass, api)
Definition: smartapp.py:139
def unload_smartapp_endpoint(HomeAssistant hass)
Definition: smartapp.py:280
def update_app(HomeAssistant hass, app)
Definition: smartapp.py:167
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)