diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index bd003f1ad67..8b4031f09ed 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -35,12 +35,27 @@ ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format('all_covers') ENTITY_ID_FORMAT = DOMAIN + '.{}' +# Refer to the cover dev docs for device class descriptions +DEVICE_CLASS_AWNING = 'awning' +DEVICE_CLASS_BLIND = 'blind' +DEVICE_CLASS_CURTAIN = 'curtain' +DEVICE_CLASS_DAMPER = 'damper' +DEVICE_CLASS_DOOR = 'door' +DEVICE_CLASS_GARAGE = 'garage' +DEVICE_CLASS_SHADE = 'shade' +DEVICE_CLASS_SHUTTER = 'shutter' +DEVICE_CLASS_WINDOW = 'window' DEVICE_CLASSES = [ - 'damper', - 'garage', # Garage door control - 'window', # Window control + DEVICE_CLASS_AWNING, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, + DEVICE_CLASS_DAMPER, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_SHADE, + DEVICE_CLASS_SHUTTER, + DEVICE_CLASS_WINDOW ] - DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) SUPPORT_OPEN = 1 diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index d423bcde44f..5da43203e4f 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -25,12 +25,13 @@ 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. +# to be drawn-down and represented by the most appropriate platform. SUPPORTED_PLATFORMS = [ 'climate', 'fan', 'light', 'lock', + 'cover', 'switch', 'binary_sensor', 'sensor' diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py new file mode 100644 index 00000000000..131da75f4fe --- /dev/null +++ b/homeassistant/components/smartthings/cover.py @@ -0,0 +1,153 @@ +"""Support for covers through the SmartThings cloud API.""" +from typing import Optional, Sequence + +from homeassistant.components.cover import ( + ATTR_POSITION, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHADE, + DOMAIN as COVER_DOMAIN, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, + STATE_OPENING, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION, + CoverDevice) +from homeassistant.const import ATTR_BATTERY_LEVEL + +from . import SmartThingsEntity +from .const import DATA_BROKERS, DOMAIN + +DEPENDENCIES = ['smartthings'] + +VALUE_TO_STATE = { + 'closed': STATE_CLOSED, + 'closing': STATE_CLOSING, + 'open': STATE_OPEN, + 'opening': STATE_OPENING, + 'partially open': STATE_OPEN, + 'unknown': None +} + + +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 covers for a config entry.""" + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + async_add_entities( + [SmartThingsCover(device) for device in broker.devices.values() + if broker.any_assigned(device.device_id, COVER_DOMAIN)], True) + + +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" + from pysmartthings import Capability + + min_required = [ + Capability.door_control, + Capability.garage_door_control, + Capability.window_shade + ] + # Must have one of the min_required + if any(capability in capabilities + for capability in min_required): + # Return all capabilities supported/consumed + return min_required + [Capability.battery, Capability.switch_level] + + return None + + +class SmartThingsCover(SmartThingsEntity, CoverDevice): + """Define a SmartThings cover.""" + + def __init__(self, device): + """Initialize the cover class.""" + from pysmartthings import Capability + + super().__init__(device) + self._device_class = None + self._state = None + self._state_attrs = None + self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + if Capability.switch_level in device.capabilities: + self._supported_features |= SUPPORT_SET_POSITION + + async def async_close_cover(self, **kwargs): + """Close cover.""" + # Same command for all 3 supported capabilities + await self._device.close(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_open_cover(self, **kwargs): + """Open the cover.""" + # Same for all capability types + await self._device.open(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_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if not self._supported_features & SUPPORT_SET_POSITION: + return + # Do not set_status=True as device will report progress. + await self._device.set_level(kwargs[ATTR_POSITION], 0) + + async def async_update(self): + """Update the attrs of the cover.""" + from pysmartthings import Attribute, Capability + + value = None + if Capability.door_control in self._device.capabilities: + self._device_class = DEVICE_CLASS_DOOR + value = self._device.status.door + elif Capability.window_shade in self._device.capabilities: + self._device_class = DEVICE_CLASS_SHADE + value = self._device.status.window_shade + elif Capability.garage_door_control in self._device.capabilities: + self._device_class = DEVICE_CLASS_GARAGE + value = self._device.status.door + + self._state = VALUE_TO_STATE.get(value) + + self._state_attrs = {} + battery = self._device.status.attributes[Attribute.battery].value + if battery is not None: + self._state_attrs[ATTR_BATTERY_LEVEL] = battery + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._state == STATE_CLOSED: + return True + return None if self._state is None else False + + @property + def current_cover_position(self): + """Return current position of cover.""" + return self._device.status.level + + @property + def device_class(self): + """Define this cover as a garage door.""" + return self._device_class + + @property + def device_state_attributes(self): + """Get additional state attributes.""" + return self._state_attrs + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 32047c179b4..50beefdb5b2 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -43,8 +43,6 @@ CAPABILITY_TO_SENSORS = { Map('dishwasherJobState', "Dishwasher Job State", None, None), Map('completionTime', "Dishwasher Completion Time", None, DEVICE_CLASS_TIMESTAMP)], - 'doorControl': [ - Map('door', "Door", None, None)], 'dryerMode': [ Map('dryerMode', "Dryer Mode", None, None)], 'dryerOperatingState': [ @@ -62,8 +60,6 @@ CAPABILITY_TO_SENSORS = { 'Equivalent Carbon Dioxide Measurement', 'ppm', None)], 'formaldehydeMeasurement': [ Map('formaldehydeLevel', 'Formaldehyde Measurement', 'ppm', None)], - 'garageDoorControl': [ - Map('door', 'Garage Door', None, None)], 'illuminanceMeasurement': [ Map('illuminance', "Illuminance", 'lux', DEVICE_CLASS_ILLUMINANCE)], 'infraredLevel': [ @@ -143,9 +139,7 @@ CAPABILITY_TO_SENSORS = { Map('machineState', "Washer Machine State", None, None), Map('washerJobState', "Washer Job State", None, None), Map('completionTime', "Washer Completion Time", None, - DEVICE_CLASS_TIMESTAMP)], - 'windowShade': [ - Map('windowShade', 'Window Shade', None, None)] + DEVICE_CLASS_TIMESTAMP)] } UNITS = { diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py new file mode 100644 index 00000000000..6e7844e521c --- /dev/null +++ b/tests/components/smartthings/test_cover.py @@ -0,0 +1,196 @@ +""" +Test for the SmartThings cover 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 + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING) +from homeassistant.components.smartthings import cover +from homeassistant.components.smartthings.const import ( + DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .conftest import setup_platform + + +async def test_async_setup_platform(): + """Test setup platform does nothing (it uses config entries).""" + await cover.async_setup_platform(None, None, None) + + +async def test_entity_and_device_attributes(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'open'}) + 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, COVER_DOMAIN, device) + # Assert + entry = entity_registry.async_get('cover.garage') + 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_open(hass, device_factory): + """Test the cover opens doors, garages, and shades successfully.""" + # Arrange + devices = { + device_factory('Door', [Capability.door_control], + {Attribute.door: 'closed'}), + device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'closed'}), + device_factory('Shade', [Capability.window_shade], + {Attribute.window_shade: 'closed'}) + } + await setup_platform(hass, COVER_DOMAIN, *devices) + entity_ids = [ + 'cover.door', + 'cover.garage', + 'cover.shade' + ] + # Act + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_ids}, + blocking=True) + # Assert + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OPENING + + +async def test_close(hass, device_factory): + """Test the cover closes doors, garages, and shades successfully.""" + # Arrange + devices = { + device_factory('Door', [Capability.door_control], + {Attribute.door: 'open'}), + device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'open'}), + device_factory('Shade', [Capability.window_shade], + {Attribute.window_shade: 'open'}) + } + await setup_platform(hass, COVER_DOMAIN, *devices) + entity_ids = [ + 'cover.door', + 'cover.garage', + 'cover.shade' + ] + # Act + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_ids}, + blocking=True) + # Assert + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_CLOSING + + +async def test_set_cover_position(hass, device_factory): + """Test the cover sets to the specific position.""" + # Arrange + device = device_factory( + 'Shade', + [Capability.window_shade, Capability.battery, + Capability.switch_level], + {Attribute.window_shade: 'opening', Attribute.battery: 95, + Attribute.level: 10}) + await setup_platform(hass, COVER_DOMAIN, device) + # Act + await hass.services.async_call( + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, blocking=True) + + state = hass.states.get('cover.shade') + # Result of call does not update state + assert state.state == STATE_OPENING + assert state.attributes[ATTR_BATTERY_LEVEL] == 95 + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + # Ensure API called + # pylint: disable=protected-access + assert device._api.post_device_command.call_count == 1 # type: ignore + + +async def test_set_cover_position_unsupported(hass, device_factory): + """Test set position does nothing when not supported by device.""" + # Arrange + device = device_factory( + 'Shade', + [Capability.window_shade], + {Attribute.window_shade: 'opening'}) + await setup_platform(hass, COVER_DOMAIN, device) + # Act + await hass.services.async_call( + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, blocking=True) + + # Ensure API was notcalled + # pylint: disable=protected-access + assert device._api.post_device_command.call_count == 0 # type: ignore + + +async def test_update_to_open_from_signal(hass, device_factory): + """Test the cover updates to open when receiving a signal.""" + # Arrange + device = device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'opening'}) + await setup_platform(hass, COVER_DOMAIN, device) + device.status.update_attribute_value(Attribute.door, 'open') + assert hass.states.get('cover.garage').state == STATE_OPENING + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('cover.garage') + assert state is not None + assert state.state == STATE_OPEN + + +async def test_update_to_closed_from_signal(hass, device_factory): + """Test the cover updates to closed when receiving a signal.""" + # Arrange + device = device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'closing'}) + await setup_platform(hass, COVER_DOMAIN, device) + device.status.update_attribute_value(Attribute.door, 'closed') + assert hass.states.get('cover.garage').state == STATE_CLOSING + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, + [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get('cover.garage') + assert state is not None + assert state.state == STATE_CLOSED + + +async def test_unload_config_entry(hass, device_factory): + """Test the lock is removed when the config entry is unloaded.""" + # Arrange + device = device_factory('Garage', [Capability.garage_door_control], + {Attribute.door: 'open'}) + config_entry = await setup_platform(hass, COVER_DOMAIN, device) + # Act + await hass.config_entries.async_forward_entry_unload( + config_entry, COVER_DOMAIN) + # Assert + assert not hass.states.get('cover.garage')