Add SmartThings Climate platform (#20963)

* Add SmartThings Climate platform

* Add SmartThings Climate platform
This commit is contained in:
Andrew Sayre 2019-02-12 01:11:36 -06:00 committed by Martin Hjelmare
parent 0d98f9783f
commit e8ed56ca52
7 changed files with 493 additions and 4 deletions

View file

@ -23,7 +23,7 @@ from .const import (
from .smartapp import (
setup_smartapp, setup_smartapp_endpoint, validate_installed_app)
REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.0']
REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.1']
DEPENDENCIES = ['webhook']
_LOGGER = logging.getLogger(__name__)

View file

@ -0,0 +1,221 @@
"""
Support for climate entities/thermostats through the SmartThings cloud API.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/smartthings.climate/
"""
import asyncio
from homeassistant.components.climate import (
ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE, STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_OFF,
SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW,
ClimateDevice)
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from . import SmartThingsEntity
from .const import DATA_BROKERS, DOMAIN
DEPENDENCIES = ['smartthings']
ATTR_OPERATION_STATE = 'operation_state'
MODE_TO_STATE = {
'auto': STATE_AUTO,
'cool': STATE_COOL,
'eco': STATE_ECO,
'rush hour': STATE_ECO,
'emergency heat': STATE_HEAT,
'heat': STATE_HEAT,
'off': STATE_OFF
}
STATE_TO_MODE = {
STATE_AUTO: 'auto',
STATE_COOL: 'cool',
STATE_ECO: 'eco',
STATE_HEAT: 'heat',
STATE_OFF: 'off'
}
UNIT_MAP = {
'C': TEMP_CELSIUS,
'F': TEMP_FAHRENHEIT
}
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 climate entities for a config entry."""
broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
async_add_entities(
[SmartThingsThermostat(device) for device in broker.devices.values()
if is_climate(device)])
def is_climate(device):
"""Determine if the device should be represented as a climate entity."""
from pysmartthings import Capability
# Can have this legacy/deprecated capability
if Capability.thermostat in device.capabilities:
return True
# Or must have all of these
climate_capabilities = [
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode]
if all(capability in device.capabilities
for capability in climate_capabilities):
return True
# Optional capabilities:
# relative_humidity_measurement -> state attribs
# thermostat_operating_state -> state attribs
# thermostat_fan_mode -> SUPPORT_FAN_MODE
return False
class SmartThingsThermostat(SmartThingsEntity, ClimateDevice):
"""Define a SmartThings climate entities."""
def __init__(self, device):
"""Init the class."""
super().__init__(device)
self._supported_features = self._determine_features()
def _determine_features(self):
from pysmartthings import Capability
flags = SUPPORT_OPERATION_MODE \
| SUPPORT_TARGET_TEMPERATURE \
| SUPPORT_TARGET_TEMPERATURE_LOW \
| SUPPORT_TARGET_TEMPERATURE_HIGH
if self._device.get_capability(
Capability.thermostat_fan_mode, Capability.thermostat):
flags |= SUPPORT_FAN_MODE
return flags
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
await self._device.set_thermostat_fan_mode(fan_mode, set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_set_operation_mode(self, operation_mode):
"""Set new target operation mode."""
mode = STATE_TO_MODE[operation_mode]
await self._device.set_thermostat_mode(mode, set_status=True)
# State is set optimistically in the command above, therefore update
# the entity state ahead of receiving the confirming push updates
self.async_schedule_update_ha_state(True)
async def async_set_temperature(self, **kwargs):
"""Set new operation mode and target temperatures."""
# Operation state
operation_state = kwargs.get(ATTR_OPERATION_MODE)
if operation_state:
mode = STATE_TO_MODE[operation_state]
await self._device.set_thermostat_mode(mode, set_status=True)
# Heat/cool setpoint
heating_setpoint = None
cooling_setpoint = None
if self.current_operation == STATE_HEAT:
heating_setpoint = kwargs.get(ATTR_TEMPERATURE)
elif self.current_operation == STATE_COOL:
cooling_setpoint = kwargs.get(ATTR_TEMPERATURE)
else:
heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW)
cooling_setpoint = kwargs.get(ATTR_TARGET_TEMP_HIGH)
tasks = []
if heating_setpoint is not None:
tasks.append(self._device.set_heating_setpoint(
round(heating_setpoint, 3), set_status=True))
if cooling_setpoint is not None:
tasks.append(self._device.set_cooling_setpoint(
round(cooling_setpoint, 3), set_status=True))
await asyncio.gather(*tasks)
# 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)
@property
def current_fan_mode(self):
"""Return the fan setting."""
return self._device.status.thermostat_fan_mode
@property
def current_humidity(self):
"""Return the current humidity."""
return self._device.status.humidity
@property
def current_operation(self):
"""Return current operation ie. heat, cool, idle."""
return MODE_TO_STATE[self._device.status.thermostat_mode]
@property
def current_temperature(self):
"""Return the current temperature."""
return self._device.status.temperature
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
return {
ATTR_OPERATION_STATE:
self._device.status.thermostat_operating_state
}
@property
def fan_list(self):
"""Return the list of available fan modes."""
return self._device.status.supported_thermostat_fan_modes
@property
def operation_list(self):
"""Return the list of available operation modes."""
return {MODE_TO_STATE[mode] for mode in
self._device.status.supported_thermostat_modes}
@property
def supported_features(self):
"""Return the supported features."""
return self._supported_features
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
if self.current_operation == STATE_COOL:
return self._device.status.cooling_setpoint
if self.current_operation == STATE_HEAT:
return self._device.status.heating_setpoint
return None
@property
def target_temperature_high(self):
"""Return the highbound target temperature we try to reach."""
if self.current_operation == STATE_AUTO:
return self._device.status.cooling_setpoint
return None
@property
def target_temperature_low(self):
"""Return the lowbound target temperature we try to reach."""
if self.current_operation == STATE_AUTO:
return self._device.status.heating_setpoint
return None
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return UNIT_MAP.get(
self._device.status.attributes['temperature'].unit)

View file

@ -20,6 +20,7 @@ STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
SUPPORTED_PLATFORMS = [
'binary_sensor',
'climate',
'fan',
'light',
'sensor',

View file

@ -1240,7 +1240,7 @@ pysma==0.3.1
pysmartapp==0.3.0
# homeassistant.components.smartthings
pysmartthings==0.6.0
pysmartthings==0.6.1
# homeassistant.components.device_tracker.snmp
# homeassistant.components.sensor.snmp

View file

@ -217,7 +217,7 @@ pyqwikswitch==0.8
pysmartapp==0.3.0
# homeassistant.components.smartthings
pysmartthings==0.6.0
pysmartthings==0.6.1
# homeassistant.components.sonos
pysonos==0.0.6

View file

@ -235,7 +235,8 @@ def config_entry_fixture(hass, installed_app, location):
def device_factory_fixture():
"""Fixture for creating mock devices."""
api = Mock(spec=Api)
api.post_device_command.return_value = mock_coro(return_value={})
api.post_device_command.side_effect = \
lambda *args, **kwargs: mock_coro(return_value={})
def _factory(label, capabilities, status: dict = None):
device_data = {

View file

@ -0,0 +1,266 @@
"""
Test for the SmartThings climate 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 pysmartthings.device import Status
import pytest
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_LIST,
ATTR_FAN_MODE, ATTR_OPERATION_LIST, ATTR_OPERATION_MODE,
ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE,
STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
from homeassistant.components.smartthings import climate
from homeassistant.components.smartthings.const import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE)
from .conftest import setup_platform
@pytest.fixture(name="legacy_thermostat")
def legacy_thermostat_fixture(device_factory):
"""Fixture returns a legacy thermostat."""
device = device_factory(
"Legacy Thermostat",
capabilities=[Capability.thermostat],
status={
Attribute.cooling_setpoint: 74,
Attribute.heating_setpoint: 68,
Attribute.thermostat_fan_mode: 'auto',
Attribute.supported_thermostat_fan_modes: ['auto', 'on'],
Attribute.thermostat_mode: 'auto',
Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(),
Attribute.thermostat_operating_state: 'idle'
}
)
device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
return device
@pytest.fixture(name="basic_thermostat")
def basic_thermostat_fixture(device_factory):
"""Fixture returns a basic thermostat."""
device = device_factory(
"Basic Thermostat",
capabilities=[
Capability.temperature_measurement,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode],
status={
Attribute.cooling_setpoint: 74,
Attribute.heating_setpoint: 68,
Attribute.thermostat_mode: 'off',
Attribute.supported_thermostat_modes:
['off', 'auto', 'heat', 'cool']
}
)
device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
return device
@pytest.fixture(name="thermostat")
def thermostat_fixture(device_factory):
"""Fixture returns a fully-featured thermostat."""
device = device_factory(
"Thermostat",
capabilities=[
Capability.temperature_measurement,
Capability.relative_humidity_measurement,
Capability.thermostat_cooling_setpoint,
Capability.thermostat_heating_setpoint,
Capability.thermostat_mode,
Capability.thermostat_operating_state,
Capability.thermostat_fan_mode],
status={
Attribute.cooling_setpoint: 74,
Attribute.heating_setpoint: 68,
Attribute.thermostat_fan_mode: 'on',
Attribute.supported_thermostat_fan_modes: ['auto', 'on'],
Attribute.thermostat_mode: 'heat',
Attribute.supported_thermostat_modes:
['auto', 'heat', 'cool', 'off', 'eco'],
Attribute.thermostat_operating_state: 'fan only',
Attribute.humidity: 34
}
)
device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
return device
async def test_async_setup_platform():
"""Test setup platform does nothing (it uses config entries)."""
await climate.async_setup_platform(None, None, None)
def test_is_climate(device_factory, legacy_thermostat,
basic_thermostat, thermostat):
"""Test climate devices are correctly identified."""
other_devices = [
device_factory('Unknown', ['Unknown']),
device_factory("Switch 1", [Capability.switch])
]
for device in [legacy_thermostat, basic_thermostat, thermostat]:
assert climate.is_climate(device), device.name
for device in other_devices:
assert not climate.is_climate(device), device.name
async def test_legacy_thermostat_entity_state(hass, legacy_thermostat):
"""Tests the state attributes properly match the thermostat type."""
await setup_platform(hass, CLIMATE_DOMAIN, legacy_thermostat)
state = hass.states.get('climate.legacy_thermostat')
assert state.state == STATE_AUTO
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \
SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW | \
SUPPORT_TARGET_TEMPERATURE
assert state.attributes[climate.ATTR_OPERATION_STATE] == 'idle'
assert state.attributes[ATTR_OPERATION_LIST] == {
STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_OFF}
assert state.attributes[ATTR_FAN_MODE] == 'auto'
assert state.attributes[ATTR_FAN_LIST] == ['auto', 'on']
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
async def test_basic_thermostat_entity_state(hass, basic_thermostat):
"""Tests the state attributes properly match the thermostat type."""
await setup_platform(hass, CLIMATE_DOMAIN, basic_thermostat)
state = hass.states.get('climate.basic_thermostat')
assert state.state == STATE_OFF
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE_HIGH | \
SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_TARGET_TEMPERATURE
assert state.attributes[climate.ATTR_OPERATION_STATE] is None
assert state.attributes[ATTR_OPERATION_LIST] == {
STATE_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL}
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
async def test_thermostat_entity_state(hass, thermostat):
"""Tests the state attributes properly match the thermostat type."""
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
state = hass.states.get('climate.thermostat')
assert state.state == STATE_HEAT
assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \
SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW | \
SUPPORT_TARGET_TEMPERATURE
assert state.attributes[climate.ATTR_OPERATION_STATE] == 'fan only'
assert state.attributes[ATTR_OPERATION_LIST] == {
STATE_AUTO, STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO}
assert state.attributes[ATTR_FAN_MODE] == 'on'
assert state.attributes[ATTR_FAN_LIST] == ['auto', 'on']
assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34
async def test_set_fan_mode(hass, thermostat):
"""Test the fan mode is set successfully."""
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_FAN_MODE: 'auto'},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.attributes[ATTR_FAN_MODE] == 'auto'
async def test_set_operation_mode(hass, thermostat):
"""Test the operation mode is set successfully."""
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_OPERATION_MODE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_OPERATION_MODE: STATE_ECO},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.state == STATE_ECO
async def test_set_temperature_heat_mode(hass, thermostat):
"""Test the temperature is set successfully when in heat mode."""
thermostat.status.thermostat_mode = 'heat'
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_TEMPERATURE: 21},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.attributes[ATTR_OPERATION_MODE] == STATE_HEAT
assert state.attributes[ATTR_TEMPERATURE] == 21
assert thermostat.status.heating_setpoint == 69.8
async def test_set_temperature_cool_mode(hass, thermostat):
"""Test the temperature is set successfully when in cool mode."""
thermostat.status.thermostat_mode = 'cool'
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_TEMPERATURE: 21},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.attributes[ATTR_TEMPERATURE] == 21
async def test_set_temperature(hass, thermostat):
"""Test the temperature is set successfully."""
thermostat.status.thermostat_mode = 'auto'
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_TARGET_TEMP_HIGH: 25.5,
ATTR_TARGET_TEMP_LOW: 22.2},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2
async def test_set_temperature_with_mode(hass, thermostat):
"""Test the temperature and mode is set successfully."""
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
await hass.services.async_call(
CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
ATTR_ENTITY_ID: 'climate.thermostat',
ATTR_TARGET_TEMP_HIGH: 25.5,
ATTR_TARGET_TEMP_LOW: 22.2,
ATTR_OPERATION_MODE: STATE_AUTO},
blocking=True)
state = hass.states.get('climate.thermostat')
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2
assert state.state == STATE_AUTO
async def test_entity_and_device_attributes(hass, thermostat):
"""Test the attributes of the entries are correct."""
await setup_platform(hass, CLIMATE_DOMAIN, thermostat)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
device_registry = await hass.helpers.device_registry.async_get_registry()
entry = entity_registry.async_get("climate.thermostat")
assert entry
assert entry.unique_id == thermostat.device_id
entry = device_registry.async_get_device(
{(DOMAIN, thermostat.device_id)}, [])
assert entry
assert entry.name == thermostat.label
assert entry.model == thermostat.device_type_name
assert entry.manufacturer == 'Unavailable'