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 closedpull/21439/head
parent
0ccbf61aea
commit
d3f1ee4a89
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
@ -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')
|
Loading…
Reference in New Issue