Add SmartThings Light platform (#20652)

* Add SmartThings Light platform and tests

* Cleaned a few awk comments

* Updates per review feedback

* Switched to super

* Changes per review feedback
This commit is contained in:
Andrew Sayre 2019-02-02 09:12:24 -06:00 committed by Paulus Schoutsen
parent ca143f8a35
commit a24da611c5
4 changed files with 518 additions and 8 deletions

View File

@ -18,6 +18,7 @@ SETTINGS_INSTANCE_ID = "hassInstanceId"
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
SUPPORTED_PLATFORMS = [
'light',
'switch'
]
SUPPORTED_CAPABILITIES = [

View File

@ -0,0 +1,215 @@
"""
Support for lights through the SmartThings cloud API.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/smartthings.light/
"""
import asyncio
from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION,
Light)
import homeassistant.util.color as color_util
from . import SmartThingsEntity
from .const import DATA_BROKERS, DOMAIN
DEPENDENCIES = ['smartthings']
async def async_setup_platform(
hass, config, async_add_entities, discovery_info=None):
"""Platform uses config entry setup."""
pass
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add lights for a config entry."""
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)
def is_light(device):
"""Determine if the device should be represented as a light."""
from pysmartthings import Capability
# 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
# Must have one of these
light_capabilities = [
Capability.color_control,
Capability.color_temperature,
Capability.switch_level
]
if any(capability in device.capabilities
for capability in light_capabilities):
return True
return False
def convert_scale(value, value_scale, target_scale, round_digits=4):
"""Convert a value to a different scale."""
return round(value * target_scale / value_scale, round_digits)
class SmartThingsLight(SmartThingsEntity, Light):
"""Define a SmartThings Light."""
def __init__(self, device):
"""Initialize a SmartThingsLight."""
super().__init__(device)
self._brightness = None
self._color_temp = None
self._hs_color = None
self._supported_features = self._determine_features()
def _determine_features(self):
"""Get features supported by the device."""
from pysmartthings.device import Capability
features = 0
# Brightness and transition
if Capability.switch_level in self._device.capabilities:
features |= \
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
# Color Temperature
if Capability.color_temperature in self._device.capabilities:
features |= SUPPORT_COLOR_TEMP
# Color
if Capability.color_control in self._device.capabilities:
features |= SUPPORT_COLOR
return features
async def async_turn_on(self, **kwargs) -> None:
"""Turn the light on."""
tasks = []
# Color temperature
if self._supported_features & SUPPORT_COLOR_TEMP \
and ATTR_COLOR_TEMP in kwargs:
tasks.append(self.async_set_color_temp(
kwargs[ATTR_COLOR_TEMP]))
# Color
if self._supported_features & SUPPORT_COLOR \
and ATTR_HS_COLOR in kwargs:
tasks.append(self.async_set_color(
kwargs[ATTR_HS_COLOR]))
if tasks:
# Set temp/color first
await asyncio.gather(*tasks)
# Switch/brightness/transition
if self._supported_features & SUPPORT_BRIGHTNESS \
and ATTR_BRIGHTNESS in kwargs:
await self.async_set_level(
kwargs[ATTR_BRIGHTNESS],
kwargs.get(ATTR_TRANSITION, 0))
else:
await self._device.switch_on(set_status=True)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_turn_off(self, **kwargs) -> None:
"""Turn the light off."""
# Switch/transition
if self._supported_features & SUPPORT_TRANSITION \
and ATTR_TRANSITION in kwargs:
await self.async_set_level(0, int(kwargs[ATTR_TRANSITION]))
else:
await self._device.switch_off(set_status=True)
# State is set optimistically in the commands above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_update(self):
"""Update entity attributes when the device status has changed."""
# Brightness and transition
if self._supported_features & SUPPORT_BRIGHTNESS:
self._brightness = convert_scale(
self._device.status.level, 100, 255)
# Color Temperature
if self._supported_features & SUPPORT_COLOR_TEMP:
self._color_temp = color_util.color_temperature_kelvin_to_mired(
self._device.status.color_temperature)
# Color
if self._supported_features & SUPPORT_COLOR:
self._hs_color = (
convert_scale(self._device.status.hue, 100, 360),
self._device.status.saturation
)
async def async_set_color(self, hs_color):
"""Set the color of the device."""
hue = convert_scale(float(hs_color[0]), 360, 100)
hue = max(min(hue, 100.0), 0.0)
saturation = max(min(float(hs_color[1]), 100.0), 0.0)
await self._device.set_color(
hue, saturation, set_status=True)
async def async_set_color_temp(self, value: float):
"""Set the color temperature of the device."""
kelvin = color_util.color_temperature_mired_to_kelvin(value)
kelvin = max(min(kelvin, 30000.0), 1.0)
await self._device.set_color_temperature(
kelvin, set_status=True)
async def async_set_level(self, brightness: int, transition: int):
"""Set the brightness of the light over transition."""
level = int(convert_scale(brightness, 255, 100, 0))
# Due to rounding, set level to 1 (one) so we don't inadvertently
# turn off the light when a low brightness is set.
level = 1 if level == 0 and brightness > 0 else level
level = max(min(level, 100), 0)
duration = int(transition)
await self._device.set_level(level, duration, set_status=True)
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
return self._brightness
@property
def color_temp(self):
"""Return the CT color value in mireds."""
return self._color_temp
@property
def hs_color(self):
"""Return the hue and saturation color value [float, float]."""
return self._hs_color
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._device.status.switch
@property
def max_mireds(self):
"""Return the warmest color_temp that this light supports."""
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# lowest kelvin found supported across 20+ handlers.
return 500 # 2000K
@property
def min_mireds(self):
"""Return the coldest color_temp that this light supports."""
# SmartThings does not expose this attribute, instead it's
# implemented within each device-type handler. This value is the
# highest kelvin found supported across 20+ handlers.
return 111 # 9000K
@property
def supported_features(self) -> int:
"""Flag supported features."""
return self._supported_features

View File

@ -0,0 +1,293 @@
"""
Test for the SmartThings light platform.
The only mocking required is of the underlying SmartThings API object so
real HTTP calls are not initiated during testing.
"""
from pysmartthings import Attribute, Capability
import pytest
from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION)
from homeassistant.components.smartthings import DeviceBroker, light
from homeassistant.components.smartthings.const import (
DATA_BROKERS, DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
from homeassistant.config_entries import (
CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
from homeassistant.helpers.dispatcher import async_dispatcher_send
@pytest.fixture(name="light_devices")
def light_devices_fixture(device_factory):
"""Fixture returns a set of mock light devices."""
return [
device_factory(
"Dimmer 1",
capabilities=[Capability.switch, Capability.switch_level],
status={Attribute.switch: 'on', Attribute.level: 100}),
device_factory(
"Color Dimmer 1",
capabilities=[Capability.switch, Capability.switch_level,
Capability.color_control],
status={Attribute.switch: 'off', Attribute.level: 0,
Attribute.hue: 76.0, Attribute.saturation: 55.0}),
device_factory(
"Color Dimmer 2",
capabilities=[Capability.switch, Capability.switch_level,
Capability.color_control,
Capability.color_temperature],
status={Attribute.switch: 'on', Attribute.level: 100,
Attribute.hue: 76.0, Attribute.saturation: 55.0,
Attribute.color_temperature: 4500})
]
async def _setup_platform(hass, *devices):
"""Set up the SmartThings light platform and prerequisites."""
hass.config.components.add(DOMAIN)
broker = DeviceBroker(hass, devices, '')
config_entry = ConfigEntry("1", DOMAIN, "Test", {},
SOURCE_USER, CONN_CLASS_CLOUD_PUSH)
hass.data[DOMAIN] = {
DATA_BROKERS: {
config_entry.entry_id: broker
}
}
await hass.config_entries.async_forward_entry_setup(config_entry, 'light')
await hass.async_block_till_done()
return config_entry
async def test_async_setup_platform():
"""Test setup platform does nothing (it uses config entries)."""
await light.async_setup_platform(None, None, None)
def test_is_light(device_factory, light_devices):
"""Test lights are correctly identified."""
non_lights = [
device_factory('Unknown', ['Unknown']),
device_factory("Fan 1",
[Capability.switch, Capability.switch_level,
Capability.fan_speed]),
device_factory("Switch 1", [Capability.switch]),
device_factory("Can't be turned off",
[Capability.switch_level, Capability.color_control,
Capability.color_temperature])
]
for device in light_devices:
assert light.is_light(device), device.name
for device in non_lights:
assert not light.is_light(device), device.name
async def test_entity_state(hass, light_devices):
"""Tests the state attributes properly match the light types."""
await _setup_platform(hass, *light_devices)
# Dimmer 1
state = hass.states.get('light.dimmer_1')
assert state.state == 'on'
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
assert state.attributes[ATTR_BRIGHTNESS] == 255
# Color Dimmer 1
state = hass.states.get('light.color_dimmer_1')
assert state.state == 'off'
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR
# Color Dimmer 2
state = hass.states.get('light.color_dimmer_2')
assert state.state == 'on'
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR | \
SUPPORT_COLOR_TEMP
assert state.attributes[ATTR_BRIGHTNESS] == 255
assert state.attributes[ATTR_HS_COLOR] == (273.6, 55.0)
assert state.attributes[ATTR_COLOR_TEMP] == 222
async def test_entity_and_device_attributes(hass, device_factory):
"""Test the attributes of the entity are correct."""
# Arrange
device = device_factory(
"Light 1", [Capability.switch, Capability.switch_level])
entity_registry = await hass.helpers.entity_registry.async_get_registry()
device_registry = await hass.helpers.device_registry.async_get_registry()
# Act
await _setup_platform(hass, device)
# Assert
entry = entity_registry.async_get("light.light_1")
assert entry
assert entry.unique_id == device.device_id
entry = device_registry.async_get_device(
{(DOMAIN, device.device_id)}, [])
assert entry
assert entry.name == device.label
assert entry.model == device.device_type_name
assert entry.manufacturer == 'Unavailable'
async def test_turn_off(hass, light_devices):
"""Test the light turns of successfully."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_off', {'entity_id': 'light.color_dimmer_2'},
blocking=True)
# Assert
state = hass.states.get('light.color_dimmer_2')
assert state is not None
assert state.state == 'off'
async def test_turn_off_with_transition(hass, light_devices):
"""Test the light turns of successfully with transition."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_off',
{ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_2")
assert state is not None
assert state.state == 'off'
async def test_turn_on(hass, light_devices):
"""Test the light turns of successfully."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_on', {ATTR_ENTITY_ID: "light.color_dimmer_1"},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_1")
assert state is not None
assert state.state == 'on'
async def test_turn_on_with_brightness(hass, light_devices):
"""Test the light turns on to the specified brightness."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_on',
{ATTR_ENTITY_ID: "light.color_dimmer_1",
ATTR_BRIGHTNESS: 75, ATTR_TRANSITION: 2},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_1")
assert state is not None
assert state.state == 'on'
# round-trip rounding error (expected)
assert state.attributes[ATTR_BRIGHTNESS] == 73.95
async def test_turn_on_with_minimal_brightness(hass, light_devices):
"""
Test lights set to lowest brightness when converted scale would be zero.
SmartThings light brightness is a percentage (0-100), but HASS uses a
0-255 scale. This tests if a really low value (1-2) is passed, we don't
set the level to zero, which turns off the lights in SmartThings.
"""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_on',
{ATTR_ENTITY_ID: "light.color_dimmer_1",
ATTR_BRIGHTNESS: 2},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_1")
assert state is not None
assert state.state == 'on'
# round-trip rounding error (expected)
assert state.attributes[ATTR_BRIGHTNESS] == 2.55
async def test_turn_on_with_color(hass, light_devices):
"""Test the light turns on with color."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_on',
{ATTR_ENTITY_ID: "light.color_dimmer_2",
ATTR_HS_COLOR: (180, 50)},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_2")
assert state is not None
assert state.state == 'on'
assert state.attributes[ATTR_HS_COLOR] == (180, 50)
async def test_turn_on_with_color_temp(hass, light_devices):
"""Test the light turns on with color temp."""
# Arrange
await _setup_platform(hass, *light_devices)
# Act
await hass.services.async_call(
'light', 'turn_on',
{ATTR_ENTITY_ID: "light.color_dimmer_2",
ATTR_COLOR_TEMP: 300},
blocking=True)
# Assert
state = hass.states.get("light.color_dimmer_2")
assert state is not None
assert state.state == 'on'
assert state.attributes[ATTR_COLOR_TEMP] == 300
async def test_update_from_signal(hass, device_factory):
"""Test the light updates when receiving a signal."""
# Arrange
device = device_factory(
"Color Dimmer 2",
capabilities=[Capability.switch, Capability.switch_level,
Capability.color_control, Capability.color_temperature],
status={Attribute.switch: 'off', Attribute.level: 100,
Attribute.hue: 76.0, Attribute.saturation: 55.0,
Attribute.color_temperature: 4500})
await _setup_platform(hass, device)
await device.switch_on(True)
# Act
async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
[device.device_id])
# Assert
await hass.async_block_till_done()
state = hass.states.get('light.color_dimmer_2')
assert state is not None
assert state.state == 'on'
async def test_unload_config_entry(hass, device_factory):
"""Test the light is removed when the config entry is unloaded."""
# Arrange
device = device_factory(
"Color Dimmer 2",
capabilities=[Capability.switch, Capability.switch_level,
Capability.color_control, Capability.color_temperature],
status={Attribute.switch: 'off', Attribute.level: 100,
Attribute.hue: 76.0, Attribute.saturation: 55.0,
Attribute.color_temperature: 4500})
config_entry = await _setup_platform(hass, device)
# Act
await hass.config_entries.async_forward_entry_unload(
config_entry, 'light')
# Assert
assert not hass.states.get('light.color_dimmer_2')

View File

@ -62,15 +62,16 @@ async def test_entity_and_device_attributes(hass, device_factory):
# Act
await _setup_platform(hass, device)
# Assert
entity = entity_registry.async_get('switch.switch_1')
assert entity
assert entity.unique_id == device.device_id
device_entry = device_registry.async_get_device(
entry = entity_registry.async_get('switch.switch_1')
assert entry
assert entry.unique_id == device.device_id
entry = device_registry.async_get_device(
{(DOMAIN, device.device_id)}, [])
assert device_entry
assert device_entry.name == device.label
assert device_entry.model == device.device_type_name
assert device_entry.manufacturer == 'Unavailable'
assert entry
assert entry.name == device.label
assert entry.model == device.device_type_name
assert entry.manufacturer == 'Unavailable'
async def test_turn_off(hass, device_factory):