From a24da611c576467c28e711c471f3a5b283f5a1c2 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 2 Feb 2019 09:12:24 -0600 Subject: [PATCH] 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 --- homeassistant/components/smartthings/const.py | 1 + homeassistant/components/smartthings/light.py | 215 +++++++++++++ tests/components/smartthings/test_light.py | 293 ++++++++++++++++++ tests/components/smartthings/test_switch.py | 17 +- 4 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/smartthings/light.py create mode 100644 tests/components/smartthings/test_light.py diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 9a6d96bfab9..a9f47fc7c72 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -18,6 +18,7 @@ SETTINGS_INSTANCE_ID = "hassInstanceId" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 SUPPORTED_PLATFORMS = [ + 'light', 'switch' ] SUPPORTED_CAPABILITIES = [ diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py new file mode 100644 index 00000000000..8495be62a73 --- /dev/null +++ b/homeassistant/components/smartthings/light.py @@ -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 diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py new file mode 100644 index 00000000000..a4f1103f270 --- /dev/null +++ b/tests/components/smartthings/test_light.py @@ -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') diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 7bf8b15af51..a8013105291 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -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):