Skip to content

Commit

Permalink
SmartThings Component Enhancements/Fixes (home-assistant#21085)
Browse files Browse the repository at this point in the history
* Improve component setup error logging/notification

* Prevent capabilities from being represented my multiple platforms

* Improved logging of received updates

* Updates based on review feedback
  • Loading branch information
andrewsayre authored and MartinHjelmare committed Feb 15, 2019
1 parent 7d0f847 commit 93f84a5
Show file tree
Hide file tree
Showing 20 changed files with 196 additions and 151 deletions.
3 changes: 2 additions & 1 deletion homeassistant/components/smartthings/.translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"token_already_setup": "The token has already been setup.",
"token_forbidden": "The token does not have the required OAuth scopes.",
"token_invalid_format": "The token must be in the UID/GUID format",
"token_unauthorized": "The token is invalid or no longer authorized."
"token_unauthorized": "The token is invalid or no longer authorized.",
"webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the [component requirements]({component_url})."
},
"step": {
"user": {
Expand Down
47 changes: 44 additions & 3 deletions homeassistant/components/smartthings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Support for SmartThings Cloud."""
import asyncio
import importlib
import logging
from typing import Iterable

Expand All @@ -22,7 +23,7 @@
from .smartapp import (
setup_smartapp, setup_smartapp_endpoint, validate_installed_app)

REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.1']
REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.2']
DEPENDENCIES = ['webhook']

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -132,9 +133,41 @@ def __init__(self, hass: HomeAssistantType, devices: Iterable,
"""Create a new instance of the DeviceBroker."""
self._hass = hass
self._installed_app_id = installed_app_id
self.assignments = self._assign_capabilities(devices)
self.devices = {device.device_id: device for device in devices}
self.event_handler_disconnect = None

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(
'.' + platform_name, self.__module__)
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 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."""
from pysmartapp.event import EVENT_TYPE_DEVICE
Expand Down Expand Up @@ -167,10 +200,18 @@ async def event_handler(self, req, resp, app):
}
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,
}
_LOGGER.debug("Push update received: %s", data)

updated_devices.add(device.device_id)
_LOGGER.debug("Update received with %s events and updated %s devices",
len(req.events), len(updated_devices))

async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE,
updated_devices)
Expand Down
15 changes: 12 additions & 3 deletions homeassistant/components/smartthings/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Support for binary sensors through the SmartThings cloud API."""
from typing import Optional, Sequence

from homeassistant.components.binary_sensor import BinarySensorDevice

from . import SmartThingsEntity
Expand Down Expand Up @@ -41,12 +43,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
sensors = []
for device in broker.devices.values():
for capability, attrib in CAPABILITY_TO_ATTRIB.items():
if capability in device.capabilities:
sensors.append(SmartThingsBinarySensor(device, attrib))
for capability in broker.get_assigned(
device.device_id, 'binary_sensor'):
attrib = CAPABILITY_TO_ATTRIB[capability]
sensors.append(SmartThingsBinarySensor(device, attrib))
async_add_entities(sensors)


def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
return [capability for capability in CAPABILITY_TO_ATTRIB
if capability in capabilities]


class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorDevice):
"""Define a SmartThings Binary Sensor."""

Expand Down
36 changes: 22 additions & 14 deletions homeassistant/components/smartthings/climate.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Support for climate devices through the SmartThings cloud API."""
import asyncio
from typing import Optional, Sequence

from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT,
SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_FAN_MODE,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
from homeassistant.const import (
ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT)
Expand Down Expand Up @@ -49,30 +50,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(
[SmartThingsThermostat(device) for device in broker.devices.values()
if is_climate(device)])
if broker.any_assigned(device.device_id, 'climate')])


def is_climate(device):
"""Determine if the device should be represented as a climate entity."""
def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
from pysmartthings import Capability

supported = [
Capability.thermostat,
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode,
Capability.relative_humidity_measurement,
Capability.thermostat_operating_state,
Capability.thermostat_fan_mode
]
# Can have this legacy/deprecated capability
if Capability.thermostat in device.capabilities:
return True
if Capability.thermostat in capabilities:
return supported
# Or must have all of these
climate_capabilities = [
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode]
if all(capability in device.capabilities
if all(capability in capabilities
for capability in climate_capabilities):
return True
# Optional capabilities:
# relative_humidity_measurement -> state attribs
# thermostat_operating_state -> state attribs
# thermostat_fan_mode -> SUPPORT_FAN_MODE
return False
return supported

return None


class SmartThingsThermostat(SmartThingsEntity, ClimateDevice):
Expand Down
13 changes: 11 additions & 2 deletions homeassistant/components/smartthings/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Config flow to configure SmartThings."""
import logging

from aiohttp.client_exceptions import ClientResponseError
from aiohttp import ClientResponseError
import voluptuous as vol

from homeassistant import config_entries
Expand Down Expand Up @@ -50,7 +50,7 @@ async def async_step_import(self, user_input=None):

async def async_step_user(self, user_input=None):
"""Get access token and validate it."""
from pysmartthings import SmartThings
from pysmartthings import APIResponseError, SmartThings

errors = {}
if not self.hass.config.api.base_url.lower().startswith('https://'):
Expand Down Expand Up @@ -87,13 +87,22 @@ async def async_step_user(self, user_input=None):
app = await create_app(self.hass, self.api)
setup_smartapp(self.hass, app)
self.app_id = app.app_id
except APIResponseError as ex:
if ex.is_target_error():
errors['base'] = 'webhook_error'
else:
errors['base'] = "app_setup_error"
_LOGGER.exception("API error setting up the SmartApp: %s",
ex.raw_error_response)
return self._show_step_user(errors)
except ClientResponseError as ex:
if ex.status == 401:
errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
elif ex.status == 403:
errors[CONF_ACCESS_TOKEN] = "token_forbidden"
else:
errors['base'] = "app_setup_error"
_LOGGER.exception("Unexpected error setting up the SmartApp")
return self._show_step_user(errors)
except Exception: # pylint:disable=broad-except
errors['base'] = "app_setup_error"
Expand Down
8 changes: 5 additions & 3 deletions homeassistant/components/smartthings/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
SETTINGS_INSTANCE_ID = "hassInstanceId"
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
# Ordered 'specific to least-specific platform' in order for capabilities
# to be drawn-down and represented by the appropriate platform.
SUPPORTED_PLATFORMS = [
'binary_sensor',
'climate',
'fan',
'light',
'lock',
'sensor',
'switch'
'switch',
'binary_sensor',
'sensor'
]
VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]" \
"{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$"
Expand Down
14 changes: 9 additions & 5 deletions homeassistant/components/smartthings/fan.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Support for fans through the SmartThings cloud API."""
from typing import Optional, Sequence

from homeassistant.components.fan import (
SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED,
FanEntity)
Expand Down Expand Up @@ -29,15 +31,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(
[SmartThingsFan(device) for device in broker.devices.values()
if is_fan(device)])
if broker.any_assigned(device.device_id, 'fan')])


def is_fan(device):
"""Determine if the device should be represented as a fan."""
def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
from pysmartthings import Capability

supported = [Capability.switch, Capability.fan_speed]
# Must have switch and fan_speed
return all(capability in device.capabilities
for capability in [Capability.switch, Capability.fan_speed])
if all(capability in capabilities for capability in supported):
return supported


class SmartThingsFan(SmartThingsEntity, FanEntity):
Expand Down
26 changes: 15 additions & 11 deletions homeassistant/components/smartthings/light.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Support for lights through the SmartThings cloud API."""
import asyncio
from typing import Optional, Sequence

from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
Expand All @@ -24,29 +25,32 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(
[SmartThingsLight(device) for device in broker.devices.values()
if is_light(device)], True)
if broker.any_assigned(device.device_id, 'light')], True)


def is_light(device):
"""Determine if the device should be represented as a light."""
def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
from pysmartthings import Capability

supported = [
Capability.switch,
Capability.switch_level,
Capability.color_control,
Capability.color_temperature,
]
# Must be able to be turned on/off.
if Capability.switch not in device.capabilities:
return False
# Not a fan (which might also have switch_level)
if Capability.fan_speed in device.capabilities:
return False
if Capability.switch not in capabilities:
return None
# Must have one of these
light_capabilities = [
Capability.color_control,
Capability.color_temperature,
Capability.switch_level
]
if any(capability in device.capabilities
if any(capability in capabilities
for capability in light_capabilities):
return True
return False
return supported
return None


def convert_scale(value, value_scale, target_scale, round_digits=4):
Expand Down
13 changes: 9 additions & 4 deletions homeassistant/components/smartthings/lock.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Support for locks through the SmartThings cloud API."""
from typing import Optional, Sequence

from homeassistant.components.lock import LockDevice

from . import SmartThingsEntity
Expand All @@ -25,13 +27,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(
[SmartThingsLock(device) for device in broker.devices.values()
if is_lock(device)])
if broker.any_assigned(device.device_id, 'lock')])


def is_lock(device):
"""Determine if the device supports the lock capability."""
def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
from pysmartthings import Capability
return Capability.lock in device.capabilities

if Capability.lock in capabilities:
return [Capability.lock]
return None


class SmartThingsLock(SmartThingsEntity, LockDevice):
Expand Down
21 changes: 14 additions & 7 deletions homeassistant/components/smartthings/sensor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Support for sensors through the SmartThings cloud API."""
from collections import namedtuple
from typing import Optional, Sequence

from homeassistant.const import (
DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE,
Expand Down Expand Up @@ -164,16 +165,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
sensors = []
for device in broker.devices.values():
for capability, maps in CAPABILITY_TO_SENSORS.items():
if capability in device.capabilities:
sensors.extend([
SmartThingsSensor(
device, m.attribute, m.name, m.default_unit,
m.device_class)
for m in maps])
for capability in broker.get_assigned(device.device_id, 'sensor'):
maps = CAPABILITY_TO_SENSORS[capability]
sensors.extend([
SmartThingsSensor(
device, m.attribute, m.name, m.default_unit,
m.device_class)
for m in maps])
async_add_entities(sensors)


def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]:
"""Return all capabilities supported if minimum required are present."""
return [capability for capability in CAPABILITY_TO_SENSORS
if capability in capabilities]


class SmartThingsSensor(SmartThingsEntity):
"""Define a SmartThings Binary Sensor."""

Expand Down
Loading

0 comments on commit 93f84a5

Please sign in to comment.