diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b7b5436da3e..0d86289e11c 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -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__) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py new file mode 100644 index 00000000000..5a79270307c --- /dev/null +++ b/homeassistant/components/smartthings/climate.py @@ -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) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 9391c871b25..df14ab68055 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -20,6 +20,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 SUPPORTED_PLATFORMS = [ 'binary_sensor', + 'climate', 'fan', 'light', 'sensor', diff --git a/requirements_all.txt b/requirements_all.txt index cae373390ba..a1a5cf3a2cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff63edada63..c70de8da50b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c1a1769f04c..ee892fb03b9 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -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 = { diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py new file mode 100644 index 00000000000..0f1102e2ab1 --- /dev/null +++ b/tests/components/smartthings/test_climate.py @@ -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'