hass-core/homeassistant/components/smartthings/__init__.py
Ville Skyttä b4bac0f7a0
Exception chaining and wrapping improvements (#39320)
* Remove unnecessary exception re-wraps

* Preserve exception chains on re-raise

We slap "from cause" to almost all possible cases here. In some cases it
could conceivably be better to do "from None" if we really want to hide
the cause. However those should be in the minority, and "from cause"
should be an improvement over the corresponding raise without a "from"
in all cases anyway.

The only case where we raise from None here is in plex, where the
exception for an original invalid SSL cert is not the root cause for
failure to validate a newly fetched one.

Follow local convention on exception variable names if there is a
consistent one, otherwise `err` to match with majority of codebase.

* Fix mistaken re-wrap in homematicip_cloud/hap.py

Missed the difference between HmipConnectionError and
HmipcConnectionError.

* Do not hide original error on plex new cert validation error

Original is not the cause for the new one, but showing old in the
traceback is useful nevertheless.
2020-08-28 13:50:32 +02:00

459 lines
16 KiB
Python

"""Support for SmartThings Cloud."""
import asyncio
import importlib
import logging
from typing import Iterable
from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
from pysmartapp.event import EVENT_TYPE_DEVICE
from pysmartthings import Attribute, Capability, SmartThings
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
HTTP_FORBIDDEN,
)
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.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .config_flow import SmartThingsFlowHandler # noqa: F401
from .const import (
CONF_APP_ID,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DATA_BROKERS,
DATA_MANAGER,
DOMAIN,
EVENT_BUTTON,
SIGNAL_SMARTTHINGS_UPDATE,
SUPPORTED_PLATFORMS,
TOKEN_REFRESH_INTERVAL,
)
from .smartapp import (
format_unique_id,
setup_smartapp,
setup_smartapp_endpoint,
smartapp_sync_subscriptions,
unload_smartapp_endpoint,
validate_installed_app,
validate_webhook_requirements,
)
_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_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry):
"""Handle migration of a previous version config entry.
A config entry created under a previous version must go through the
integration setup again so we can properly retrieve the needed data
elements. Force this by removing the entry and triggering a new flow.
"""
# Remove the entry which will invoke the callback to delete the app.
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 because it could not be migrated.
return False
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
"""Initialize config entry which represents an installed SmartApp."""
# For backwards compat
if entry.unique_id is None:
hass.config_entries.async_update_entry(
entry,
unique_id=format_unique_id(
entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID]
),
)
if not validate_webhook_requirements(hass):
_LOGGER.warning(
"The 'base_url' of the 'http' integration 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 scenes
scenes = await async_get_entry_scenes(entry, api)
# Get SmartApp token to sync subscriptions
token = await api.generate_tokens(
entry.data[CONF_CLIENT_ID],
entry.data[CONF_CLIENT_SECRET],
entry.data[CONF_REFRESH_TOKEN],
)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token}
)
# 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 excluded",
device.label,
device.device_id,
exc_info=True,
)
devices.remove(device)
await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy()))
# Sync device subscriptions
await smartapp_sync_subscriptions(
hass,
token.access_token,
installed_app.location_id,
installed_app.installed_app_id,
devices,
)
# Setup device broker
broker = DeviceBroker(hass, entry, token, smart_app, devices, scenes)
broker.connect()
hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
except ClientResponseError as ex:
if ex.status in (401, HTTP_FORBIDDEN):
_LOGGER.exception(
"Unable to setup configuration entry '%s' - please reconfigure the integration",
entry.title,
)
remove_entry = True
else:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
except (ClientConnectionError, RuntimeWarning) as ex:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
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_get_entry_scenes(entry: ConfigEntry, api):
"""Get the scenes within an integration."""
try:
return await api.scenes(location_id=entry.data[CONF_LOCATION_ID])
except ClientResponseError as ex:
if ex.status == HTTP_FORBIDDEN:
_LOGGER.exception(
"Unable to load scenes for configuration entry '%s' because the access token does not have the required access",
entry.title,
)
else:
raise
return []
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:
broker.disconnect()
tasks = [
hass.config_entries.async_forward_entry_unload(entry, component)
for component in SUPPORTED_PLATFORMS
]
return all(await asyncio.gather(*tasks))
async def async_remove_entry(hass: HomeAssistantType, entry: ConfigEntry) -> None:
"""Perform clean-up when entry is being removed."""
api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
# Remove the installed_app, which if already removed raises a HTTP_FORBIDDEN error.
installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
try:
await api.delete_installed_app(installed_app_id)
except ClientResponseError as ex:
if ex.status == HTTP_FORBIDDEN:
_LOGGER.debug(
"Installed app %s has already been removed",
installed_app_id,
exc_info=True,
)
else:
raise
_LOGGER.debug("Removed installed app %s", installed_app_id)
# Remove the app if not referenced by other entries, which if already
# removed raises a HTTP_FORBIDDEN error.
all_entries = hass.config_entries.async_entries(DOMAIN)
app_id = entry.data[CONF_APP_ID]
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 configuration entries",
app_id,
)
return
# Remove the app
try:
await api.delete_app(app_id)
except ClientResponseError as ex:
if ex.status == HTTP_FORBIDDEN:
_LOGGER.debug("App %s has already been removed", app_id, exc_info=True)
else:
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."""
def __init__(
self,
hass: HomeAssistantType,
entry: ConfigEntry,
token,
smart_app,
devices: Iterable,
scenes: Iterable,
):
"""Create a new instance of the DeviceBroker."""
self._hass = hass
self._entry = entry
self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
self._smart_app = smart_app
self._token = token
self._event_disconnect = None
self._regenerate_token_remove = None
self._assignments = self._assign_capabilities(devices)
self.devices = {device.device_id: device for device in devices}
self.scenes = {scene.scene_id: scene for scene in scenes}
def _assign_capabilities(self, devices: Iterable):
"""Assign platforms to capabilities."""
assignments = {}
for device in devices:
capabilities = device.capabilities.copy()
slots = {}
for platform_name in SUPPORTED_PLATFORMS:
platform = importlib.import_module(f".{platform_name}", self.__module__)
if not hasattr(platform, "get_capabilities"):
continue
assigned = platform.get_capabilities(capabilities)
if not assigned:
continue
# Draw-down capabilities and set slot assignment
for capability in assigned:
if capability not in capabilities:
continue
capabilities.remove(capability)
slots[capability] = platform_name
assignments[device.device_id] = slots
return assignments
def connect(self):
"""Connect handlers/listeners for device/lifecycle events."""
# Setup interval to regenerate the refresh token on a periodic basis.
# Tokens expire in 30 days and once expired, cannot be recovered.
async def regenerate_refresh_token(now):
"""Generate a new refresh token and update the config entry."""
await self._token.refresh(
self._entry.data[CONF_CLIENT_ID],
self._entry.data[CONF_CLIENT_SECRET],
)
self._hass.config_entries.async_update_entry(
self._entry,
data={
**self._entry.data,
CONF_REFRESH_TOKEN: self._token.refresh_token,
},
)
_LOGGER.debug(
"Regenerated refresh token for installed app: %s",
self._installed_app_id,
)
self._regenerate_token_remove = async_track_time_interval(
self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL
)
# Connect handler to incoming device events
self._event_disconnect = self._smart_app.connect_event(self._event_handler)
def disconnect(self):
"""Disconnects handlers/listeners for device/lifecycle events."""
if self._regenerate_token_remove:
self._regenerate_token_remove()
if self._event_disconnect:
self._event_disconnect()
def get_assigned(self, device_id: str, platform: str):
"""Get the capabilities assigned to the platform."""
slots = self._assignments.get(device_id, {})
return [key for key, value in slots.items() if value == platform]
def any_assigned(self, device_id: str, platform: str):
"""Return True if the platform has any assigned capabilities."""
slots = self._assignments.get(device_id, {})
return any(value for value in slots.values() if value == platform)
async def _event_handler(self, req, resp, app):
"""Broker for incoming events."""
# 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,
data=evt.data,
)
# Fire events for buttons
if (
evt.capability == Capability.button
and evt.attribute == Attribute.button
):
data = {
"component_id": evt.component_id,
"device_id": evt.device_id,
"location_id": evt.location_id,
"value": evt.value,
"name": device.label,
"data": evt.data,
}
self._hass.bus.async_fire(EVENT_BUTTON, data)
_LOGGER.debug("Fired button event: %s", data)
else:
data = {
"location_id": evt.location_id,
"device_id": evt.device_id,
"component_id": evt.component_id,
"capability": evt.capability,
"attribute": evt.attribute,
"value": evt.value,
"data": evt.data,
}
_LOGGER.debug("Push update received: %s", data)
updated_devices.add(device.device_id)
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