Add support for Dyson Hot+Cool Fan as a climate device (#14598)

* Added support for dyson hot+cool fan as climate device

* Removed decimal place in kelvin units conversion

Minor edits to be consistent with Dyson's internal conversion of temperature from kelvin to celsius. It does not include decimal place to convert between kelvin and celsius.

* made changes according to comments

* Refactored target temp logics, fixed enum issues

* changed name of component to entity

* removed temperature conversion for min/max property

* changed back to 644 permission

* added extra tests for almost-all coverage

* changed assert method to avoid lack of certain method in py35

* added test_setup_component

* shorten line length

* fixed mock spec and added checking of message listener is called

* added doc string and debug msg

* shorten line length

* removed pending target temp
This commit is contained in:
Oscar Tin Lai 2018-10-22 02:35:07 +11:00 committed by Martin Hjelmare
parent 731753b604
commit b6d3a199ce
4 changed files with 537 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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()])