Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for SmartThings Cloud."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Iterable
7 from http import HTTPStatus
8 import importlib
9 import logging
10 
11 from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
12 from pysmartapp.event import EVENT_TYPE_DEVICE
13 from pysmartthings import Attribute, Capability, SmartThings
14 
15 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
16 from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
17 from homeassistant.core import HomeAssistant
18 from homeassistant.exceptions import ConfigEntryNotReady
19 from homeassistant.helpers import config_validation as cv
20 from homeassistant.helpers.aiohttp_client import async_get_clientsession
21 from homeassistant.helpers.dispatcher import async_dispatcher_send
22 from homeassistant.helpers.event import async_track_time_interval
23 from homeassistant.helpers.typing import ConfigType
24 from homeassistant.loader import async_get_loaded_integration
25 from homeassistant.setup import SetupPhases, async_pause_setup
26 
27 from .config_flow import SmartThingsFlowHandler # noqa: F401
28 from .const import (
29  CONF_APP_ID,
30  CONF_INSTALLED_APP_ID,
31  CONF_LOCATION_ID,
32  CONF_REFRESH_TOKEN,
33  DATA_BROKERS,
34  DATA_MANAGER,
35  DOMAIN,
36  EVENT_BUTTON,
37  PLATFORMS,
38  SIGNAL_SMARTTHINGS_UPDATE,
39  TOKEN_REFRESH_INTERVAL,
40 )
41 from .smartapp import (
42  format_unique_id,
43  setup_smartapp,
44  setup_smartapp_endpoint,
45  smartapp_sync_subscriptions,
46  unload_smartapp_endpoint,
47  validate_installed_app,
48  validate_webhook_requirements,
49 )
50 
51 _LOGGER = logging.getLogger(__name__)
52 
53 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
54 
55 
56 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
57  """Initialize the SmartThings platform."""
58  await setup_smartapp_endpoint(hass, False)
59  return True
60 
61 
62 async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
63  """Handle migration of a previous version config entry.
64 
65  A config entry created under a previous version must go through the
66  integration setup again so we can properly retrieve the needed data
67  elements. Force this by removing the entry and triggering a new flow.
68  """
69  # Remove the entry which will invoke the callback to delete the app.
70  hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
71  # only create new flow if there isn't a pending one for SmartThings.
72  if not hass.config_entries.flow.async_progress_by_handler(DOMAIN):
73  hass.async_create_task(
74  hass.config_entries.flow.async_init(
75  DOMAIN, context={"source": SOURCE_IMPORT}
76  )
77  )
78 
79  # Return False because it could not be migrated.
80  return False
81 
82 
83 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
84  """Initialize config entry which represents an installed SmartApp."""
85  # For backwards compat
86  if entry.unique_id is None:
87  hass.config_entries.async_update_entry(
88  entry,
89  unique_id=format_unique_id(
90  entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID]
91  ),
92  )
93 
94  if not validate_webhook_requirements(hass):
95  _LOGGER.warning(
96  "The 'base_url' of the 'http' integration must be configured and start with"
97  " 'https://'"
98  )
99  return False
100 
101  api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
102 
103  # Ensure platform modules are loaded since the DeviceBroker will
104  # import them below and we want them to be cached ahead of time
105  # so the integration does not do blocking I/O in the event loop
106  # to import the modules.
107  await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS)
108 
109  remove_entry = False
110  try:
111  # See if the app is already setup. This occurs when there are
112  # installs in multiple SmartThings locations (valid use-case)
113  manager = hass.data[DOMAIN][DATA_MANAGER]
114  smart_app = manager.smartapps.get(entry.data[CONF_APP_ID])
115  if not smart_app:
116  # Validate and setup the app.
117  app = await api.app(entry.data[CONF_APP_ID])
118  smart_app = setup_smartapp(hass, app)
119 
120  # Validate and retrieve the installed app.
121  installed_app = await validate_installed_app(
122  api, entry.data[CONF_INSTALLED_APP_ID]
123  )
124 
125  # Get scenes
126  scenes = await async_get_entry_scenes(entry, api)
127 
128  # Get SmartApp token to sync subscriptions
129  token = await api.generate_tokens(
130  entry.data[CONF_CLIENT_ID],
131  entry.data[CONF_CLIENT_SECRET],
132  entry.data[CONF_REFRESH_TOKEN],
133  )
134  hass.config_entries.async_update_entry(
135  entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token}
136  )
137 
138  # Get devices and their current status
139  devices = await api.devices(location_ids=[installed_app.location_id])
140 
141  async def retrieve_device_status(device):
142  try:
143  await device.status.refresh()
144  except ClientResponseError:
145  _LOGGER.debug(
146  (
147  "Unable to update status for device: %s (%s), the device will"
148  " be excluded"
149  ),
150  device.label,
151  device.device_id,
152  exc_info=True,
153  )
154  devices.remove(device)
155 
156  await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy()))
157 
158  # Sync device subscriptions
160  hass,
161  token.access_token,
162  installed_app.location_id,
163  installed_app.installed_app_id,
164  devices,
165  )
166 
167  # Setup device broker
168  with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
169  # DeviceBroker has a side effect of importing platform
170  # modules when its created. In the future this should be
171  # refactored to not do this.
172  broker = await hass.async_add_import_executor_job(
173  DeviceBroker, hass, entry, token, smart_app, devices, scenes
174  )
175  broker.connect()
176  hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
177 
178  except ClientResponseError as ex:
179  if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
180  _LOGGER.exception(
181  (
182  "Unable to setup configuration entry '%s' - please reconfigure the"
183  " integration"
184  ),
185  entry.title,
186  )
187  remove_entry = True
188  else:
189  _LOGGER.debug(ex, exc_info=True)
190  raise ConfigEntryNotReady from ex
191  except (ClientConnectionError, RuntimeWarning) as ex:
192  _LOGGER.debug(ex, exc_info=True)
193  raise ConfigEntryNotReady from ex
194 
195  if remove_entry:
196  hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
197  # only create new flow if there isn't a pending one for SmartThings.
198  if not hass.config_entries.flow.async_progress_by_handler(DOMAIN):
199  hass.async_create_task(
200  hass.config_entries.flow.async_init(
201  DOMAIN, context={"source": SOURCE_IMPORT}
202  )
203  )
204  return False
205 
206  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
207  return True
208 
209 
210 async def async_get_entry_scenes(entry: ConfigEntry, api):
211  """Get the scenes within an integration."""
212  try:
213  return await api.scenes(location_id=entry.data[CONF_LOCATION_ID])
214  except ClientResponseError as ex:
215  if ex.status == HTTPStatus.FORBIDDEN:
216  _LOGGER.exception(
217  (
218  "Unable to load scenes for configuration entry '%s' because the"
219  " access token does not have the required access"
220  ),
221  entry.title,
222  )
223  else:
224  raise
225  return []
226 
227 
228 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
229  """Unload a config entry."""
230  broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None)
231  if broker:
232  broker.disconnect()
233 
234  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
235 
236 
237 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
238  """Perform clean-up when entry is being removed."""
239  api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
240 
241  # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error.
242  installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
243  try:
244  await api.delete_installed_app(installed_app_id)
245  except ClientResponseError as ex:
246  if ex.status == HTTPStatus.FORBIDDEN:
247  _LOGGER.debug(
248  "Installed app %s has already been removed",
249  installed_app_id,
250  exc_info=True,
251  )
252  else:
253  raise
254  _LOGGER.debug("Removed installed app %s", installed_app_id)
255 
256  # Remove the app if not referenced by other entries, which if already
257  # removed raises a HTTPStatus.FORBIDDEN error.
258  all_entries = hass.config_entries.async_entries(DOMAIN)
259  app_id = entry.data[CONF_APP_ID]
260  app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id)
261  if app_count > 1:
262  _LOGGER.debug(
263  (
264  "App %s was not removed because it is in use by other configuration"
265  " entries"
266  ),
267  app_id,
268  )
269  return
270  # Remove the app
271  try:
272  await api.delete_app(app_id)
273  except ClientResponseError as ex:
274  if ex.status == HTTPStatus.FORBIDDEN:
275  _LOGGER.debug("App %s has already been removed", app_id, exc_info=True)
276  else:
277  raise
278  _LOGGER.debug("Removed app %s", app_id)
279 
280  if len(all_entries) == 1:
281  await unload_smartapp_endpoint(hass)
282 
283 
285  """Manages an individual SmartThings config entry."""
286 
287  def __init__(
288  self,
289  hass: HomeAssistant,
290  entry: ConfigEntry,
291  token,
292  smart_app,
293  devices: Iterable,
294  scenes: Iterable,
295  ) -> None:
296  """Create a new instance of the DeviceBroker."""
297  self._hass_hass = hass
298  self._entry_entry = entry
299  self._installed_app_id_installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
300  self._smart_app_smart_app = smart_app
301  self._token_token = token
302  self._event_disconnect_event_disconnect = None
303  self._regenerate_token_remove_regenerate_token_remove = None
304  self._assignments_assignments = self._assign_capabilities_assign_capabilities(devices)
305  self.devicesdevices = {device.device_id: device for device in devices}
306  self.scenesscenes = {scene.scene_id: scene for scene in scenes}
307 
308  def _assign_capabilities(self, devices: Iterable):
309  """Assign platforms to capabilities."""
310  assignments = {}
311  for device in devices:
312  capabilities = device.capabilities.copy()
313  slots = {}
314  for platform in PLATFORMS:
315  platform_module = importlib.import_module(
316  f".{platform}", self.__module__
317  )
318  if not hasattr(platform_module, "get_capabilities"):
319  continue
320  assigned = platform_module.get_capabilities(capabilities)
321  if not assigned:
322  continue
323  # Draw-down capabilities and set slot assignment
324  for capability in assigned:
325  if capability not in capabilities:
326  continue
327  capabilities.remove(capability)
328  slots[capability] = platform
329  assignments[device.device_id] = slots
330  return assignments
331 
332  def connect(self):
333  """Connect handlers/listeners for device/lifecycle events."""
334 
335  # Setup interval to regenerate the refresh token on a periodic basis.
336  # Tokens expire in 30 days and once expired, cannot be recovered.
337  async def regenerate_refresh_token(now):
338  """Generate a new refresh token and update the config entry."""
339  await self._token_token.refresh(
340  self._entry_entry.data[CONF_CLIENT_ID],
341  self._entry_entry.data[CONF_CLIENT_SECRET],
342  )
343  self._hass_hass.config_entries.async_update_entry(
344  self._entry_entry,
345  data={
346  **self._entry_entry.data,
347  CONF_REFRESH_TOKEN: self._token_token.refresh_token,
348  },
349  )
350  _LOGGER.debug(
351  "Regenerated refresh token for installed app: %s",
352  self._installed_app_id_installed_app_id,
353  )
354 
355  self._regenerate_token_remove_regenerate_token_remove = async_track_time_interval(
356  self._hass_hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL
357  )
358 
359  # Connect handler to incoming device events
360  self._event_disconnect_event_disconnect = self._smart_app_smart_app.connect_event(self._event_handler_event_handler)
361 
362  def disconnect(self):
363  """Disconnects handlers/listeners for device/lifecycle events."""
364  if self._regenerate_token_remove_regenerate_token_remove:
365  self._regenerate_token_remove_regenerate_token_remove()
366  if self._event_disconnect_event_disconnect:
367  self._event_disconnect_event_disconnect()
368 
369  def get_assigned(self, device_id: str, platform: str):
370  """Get the capabilities assigned to the platform."""
371  slots = self._assignments_assignments.get(device_id, {})
372  return [key for key, value in slots.items() if value == platform]
373 
374  def any_assigned(self, device_id: str, platform: str):
375  """Return True if the platform has any assigned capabilities."""
376  slots = self._assignments_assignments.get(device_id, {})
377  return any(value for value in slots.values() if value == platform)
378 
379  async def _event_handler(self, req, resp, app):
380  """Broker for incoming events."""
381  # Do not process events received from a different installed app
382  # under the same parent SmartApp (valid use-scenario)
383  if req.installed_app_id != self._installed_app_id_installed_app_id:
384  return
385 
386  updated_devices = set()
387  for evt in req.events:
388  if evt.event_type != EVENT_TYPE_DEVICE:
389  continue
390  if not (device := self.devicesdevices.get(evt.device_id)):
391  continue
392  device.status.apply_attribute_update(
393  evt.component_id,
394  evt.capability,
395  evt.attribute,
396  evt.value,
397  data=evt.data,
398  )
399 
400  # Fire events for buttons
401  if (
402  evt.capability == Capability.button
403  and evt.attribute == Attribute.button
404  ):
405  data = {
406  "component_id": evt.component_id,
407  "device_id": evt.device_id,
408  "location_id": evt.location_id,
409  "value": evt.value,
410  "name": device.label,
411  "data": evt.data,
412  }
413  self._hass_hass.bus.async_fire(EVENT_BUTTON, data)
414  _LOGGER.debug("Fired button event: %s", data)
415  else:
416  data = {
417  "location_id": evt.location_id,
418  "device_id": evt.device_id,
419  "component_id": evt.component_id,
420  "capability": evt.capability,
421  "attribute": evt.attribute,
422  "value": evt.value,
423  "data": evt.data,
424  }
425  _LOGGER.debug("Push update received: %s", data)
426 
427  updated_devices.add(device.device_id)
428 
429  async_dispatcher_send(self._hass_hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices)
def get_assigned(self, str device_id, str platform)
Definition: __init__.py:369
def _assign_capabilities(self, Iterable devices)
Definition: __init__.py:308
None __init__(self, HomeAssistant hass, ConfigEntry entry, token, smart_app, Iterable devices, Iterable scenes)
Definition: __init__.py:295
def any_assigned(self, str device_id, str platform)
Definition: __init__.py:374
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
def format_unique_id(creds, mac_address)
Definition: __init__.py:166
def setup_smartapp_endpoint(HomeAssistant hass, bool fresh_install)
Definition: smartapp.py:201
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
def validate_installed_app(api, str installed_app_id)
Definition: smartapp.py:81
def unload_smartapp_endpoint(HomeAssistant hass)
Definition: smartapp.py:280
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:56
bool async_migrate_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:62
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:83
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:237
def async_get_entry_scenes(ConfigEntry entry, api)
Definition: __init__.py:210
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:228
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)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
list[EntityPlatform] async_get_platforms(HomeAssistant hass, str integration_name)
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
Integration async_get_loaded_integration(HomeAssistant hass, str domain)
Definition: loader.py:1341
Generator[None] async_pause_setup(core.HomeAssistant hass, SetupPhases phase)
Definition: setup.py:691