diff --git a/homeassistant/components/climate/dyson.py b/homeassistant/components/climate/dyson.py new file mode 100644 index 00000000000..0b09ec7f0b4 --- /dev/null +++ b/homeassistant/components/climate/dyson.py @@ -0,0 +1,176 @@ +""" +Support for Dyson Pure Hot+Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.dyson/ +""" +import logging + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE + +_LOGGER = logging.getLogger(__name__) + +STATE_DIFFUSE = "Diffuse Mode" +STATE_FOCUS = "Focus Mode" +FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE] +OPERATION_LIST = [STATE_HEAT, STATE_COOL] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink + # Get Dyson Devices from parent component. + add_devices( + [DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink)] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, + self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + from libpurecoollink.dyson_pure_state import DysonPureHotCoolState + + if isinstance(message, DysonPureHotCoolState): + _LOGGER.debug("Message received for climate device %s : %s", + self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format( + temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + from libpurecoollink.const import HeatMode, HeatState + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return STATE_HEAT + return STATE_IDLE + return STATE_COOL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from libpurecoollink.const import FocusMode + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return STATE_FOCUS + return STATE_DIFFUSE + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + from libpurecoollink.const import HeatTarget, HeatMode + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), + heat_mode=HeatMode.HEAT_ON) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + from libpurecoollink.const import FocusMode + if fan_mode == STATE_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == STATE_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode) + from libpurecoollink.const import HeatMode + if operation_mode == STATE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif operation_mode == STATE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant/components/dyson.py b/homeassistant/components/dyson.py index 3989c0bbe3e..791f990d9ad 100644 --- a/homeassistant/components/dyson.py +++ b/homeassistant/components/dyson.py @@ -102,5 +102,6 @@ def setup(hass, config): discovery.load_platform(hass, "sensor", DOMAIN, {}, config) discovery.load_platform(hass, "fan", DOMAIN, {}, config) discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/tests/components/climate/test_dyson.py b/tests/components/climate/test_dyson.py new file mode 100644 index 00000000000..6e8b63d64c4 --- /dev/null +++ b/tests/components/climate/test_dyson.py @@ -0,0 +1,358 @@ +"""Test the Dyson fan component.""" +import unittest +from unittest import mock + +from libpurecoollink.const import (FocusMode, HeatMode, HeatState, HeatTarget, + TiltState) +from libpurecoollink.dyson_pure_state import DysonPureHotCoolState +from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink +from homeassistant.components.climate import dyson +from homeassistant.components import dyson as dyson_parent +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class MockDysonState(DysonPureHotCoolState): + """Mock Dyson state.""" + + def __init__(self): + """Create new Mock Dyson State.""" + pass + + +def _get_device_with_no_state(): + """Return a device with no state.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = None + device.environmental_state = None + return device + + +def _get_device_off(): + """Return a device with state off.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.environmental_state = mock.Mock() + return device + + +def _get_device_focus(): + """Return a device with fan state of focus mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_ON.value + return device + + +def _get_device_diffuse(): + """Return a device with fan state of diffuse mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_OFF.value + return device + + +def _get_device_cool(): + """Return a device with state of cooling.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_OFF.value + device.state.heat_target = HeatTarget.celsius(12) + device.state.heat_mode = HeatMode.HEAT_OFF.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 288 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_off(): + """Return a device with state of heat reached target.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(20) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 293 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_on(): + """Return a device with state of heating.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(23) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_ON.value + device.environmental_state.temperature = 289 + device.environmental_state.humidity = 53 + return device + + +class DysonTest(unittest.TestCase): + """Dyson Climate component test class.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_heat_on(), _get_device_cool()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_setup_component_with_parent_discovery(self, mocked_login, + mocked_devices): + """Test setup_component using discovery.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 2) + self.hass.block_till_done() + for m in mocked_devices.return_value: + assert m.add_message_listener.called + + def test_setup_component_without_devices(self): + """Test setup component with no devices.""" + self.hass.data[dyson.DYSON_DEVICES] = [] + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices) + add_devices.assert_not_called() + + def test_setup_component_with_devices(self): + """Test setup component with valid devices.""" + devices = [ + _get_device_with_no_state(), + _get_device_off(), + _get_device_heat_on() + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + self.assertTrue(add_devices.called) + + def test_setup_component_with_invalid_devices(self): + """Test setup component with invalid devices.""" + devices = [ + None, + "foo_bar" + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + add_devices.assert_called_with([]) + + def test_setup_component(self): + """Test setup component with devices.""" + device_fan = _get_device_heat_on() + device_non_fan = _get_device_off() + + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Device_name" + + self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] + dyson.setup_platform(self.hass, None, _add_device) + + def test_dyson_set_temperature(self): + """Test set climate temperature.""" + device = _get_device_heat_on() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + # Without target temp. + kwargs = {} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_not_called() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + # Should clip the target temperature between 1 and 37 inclusive. + kwargs = {ATTR_TEMPERATURE: 50} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(37)) + + kwargs = {ATTR_TEMPERATURE: -5} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(1)) + + def test_dyson_set_temperature_when_cooling_mode(self): + """Test set climate temperature when heating is off.""" + device = _get_device_cool() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + def test_dyson_set_fan_mode(self): + """Test set fan mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_fan_mode(dyson.STATE_FOCUS) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_ON) + + entity.set_fan_mode(dyson.STATE_DIFFUSE) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_OFF) + + def test_dyson_fan_list(self): + """Test get fan list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.fan_list), 2) + self.assertTrue(dyson.STATE_FOCUS in entity.fan_list) + self.assertTrue(dyson.STATE_DIFFUSE in entity.fan_list) + + def test_dyson_fan_mode_focus(self): + """Test fan focus mode.""" + device = _get_device_focus() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_FOCUS) + + def test_dyson_fan_mode_diffuse(self): + """Test fan diffuse mode.""" + device = _get_device_diffuse() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_DIFFUSE) + + def test_dyson_set_operation_mode(self): + """Test set operation mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_operation_list(self): + """Test get operation list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.operation_list), 2) + self.assertTrue(dyson.STATE_HEAT in entity.operation_list) + self.assertTrue(dyson.STATE_COOL in entity.operation_list) + + def test_dyson_heat_off(self): + """Test turn off heat.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_heat_on(self): + """Test turn on heat.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + def test_dyson_heat_value_on(self): + """Test get heat value on.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_HEAT) + + def test_dyson_heat_value_off(self): + """Test get heat value off.""" + device = _get_device_cool() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_COOL) + + def test_dyson_heat_value_idle(self): + """Test get heat value idle.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_IDLE) + + def test_on_message(self): + """Test when message is received.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + entity.on_message(MockDysonState()) + entity.schedule_update_ha_state.assert_called_with() + + def test_general_properties(self): + """Test properties of entity.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.should_poll, False) + self.assertEqual(entity.supported_features, dyson.SUPPORT_FLAGS) + self.assertEqual(entity.temperature_unit, TEMP_CELSIUS) + + def test_property_current_humidity(self): + """Test properties of current humidity.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, 53) + + def test_property_current_humidity_with_invalid_env_state(self): + """Test properties of current humidity with invalid env state.""" + device = _get_device_off() + device.environmental_state.humidity = 0 + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_humidity_without_env_state(self): + """Test properties of current humidity without env state.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_temperature(self): + """Test properties of current temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + # Result should be in celsius, hence then subtraction of 273. + self.assertEqual(entity.current_temperature, 289 - 273) + + def test_property_target_temperature(self): + """Test properties of target temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.target_temperature, 23) diff --git a/tests/components/test_dyson.py b/tests/components/test_dyson.py index 19c39754eb2..0352551aec9 100644 --- a/tests/components/test_dyson.py +++ b/tests/components/test_dyson.py @@ -87,7 +87,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) @@ -172,7 +172,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()])