Add SmartThings Cover platform and add cover device classes (#21192)

* Add additional device classes to Cover component; Add SmartThings cover platform; Improve lock test coverage

* Enhance cover platform to support position and battery level reporting.

* Add additional classes

* Removed device class descriptions

* Updates based on review feedback

* Add test case for closed
pull/21439/head
Andrew Sayre 2019-02-25 12:13:34 -06:00 committed by Paulus Schoutsen
parent 0ccbf61aea
commit d3f1ee4a89
5 changed files with 371 additions and 12 deletions

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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 = {

View File

@ -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')