Add support to Dyson 360 Eye robot vacuum using new vacuum platform (#8852)

* Add support to Dyson 360 Eye robot vacuum using new vacuum platform

* Fix tests with Python 3.5

* Code review

* Code review - v2

* Code review - v3
pull/8866/head
Charles Blonde 2017-08-06 13:08:46 +02:00 committed by Martin Hjelmare
parent 82a7dffc03
commit 83afd12807
11 changed files with 468 additions and 25 deletions

View File

@ -13,7 +13,7 @@ from homeassistant.helpers import discovery
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, \
CONF_DEVICES
REQUIREMENTS = ['libpurecoollink==0.4.1']
REQUIREMENTS = ['libpurecoollink==0.4.2']
_LOGGER = logging.getLogger(__name__)
@ -69,14 +69,17 @@ def setup(hass, config):
dyson_device = next((d for d in dyson_devices if
d.serial == device["device_id"]), None)
if dyson_device:
connected = dyson_device.connect(None, device["device_ip"],
timeout, retry)
if connected:
_LOGGER.info("Connected to device %s", dyson_device)
hass.data[DYSON_DEVICES].append(dyson_device)
else:
_LOGGER.warning("Unable to connect to device %s",
dyson_device)
try:
connected = dyson_device.connect(device["device_ip"])
if connected:
_LOGGER.info("Connected to device %s", dyson_device)
hass.data[DYSON_DEVICES].append(dyson_device)
else:
_LOGGER.warning("Unable to connect to device %s",
dyson_device)
except OSError as ose:
_LOGGER.error("Unable to connect to device %s: %s",
str(dyson_device.network_device), str(ose))
else:
_LOGGER.warning(
"Unable to find device %s in Dyson account",
@ -86,7 +89,7 @@ def setup(hass, config):
for device in dyson_devices:
_LOGGER.info("Trying to connect to device %s with timeout=%i "
"and retry=%i", device, timeout, retry)
connected = device.connect(None, None, timeout, retry)
connected = device.auto_connect(timeout, retry)
if connected:
_LOGGER.info("Connected to device %s", device)
hass.data[DYSON_DEVICES].append(device)
@ -98,5 +101,6 @@ def setup(hass, config):
_LOGGER.debug("Starting sensor/fan components")
discovery.load_platform(hass, "sensor", DOMAIN, {}, config)
discovery.load_platform(hass, "fan", DOMAIN, {}, config)
discovery.load_platform(hass, "vacuum", DOMAIN, {}, config)
return True

View File

@ -36,7 +36,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
hass.data[DYSON_FAN_DEVICES] = []
# Get Dyson Devices from parent component
for device in hass.data[DYSON_DEVICES]:
from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink
for device in [d for d in hass.data[DYSON_DEVICES] if
isinstance(d, DysonPureCoolLink)]:
dyson_entity = DysonPureCoolLinkDevice(hass, device)
hass.data[DYSON_FAN_DEVICES].append(dyson_entity)

View File

@ -29,7 +29,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
devices = []
unit = hass.config.units.temperature_unit
# Get Dyson Devices from parent component
for device in hass.data[DYSON_DEVICES]:
from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink
for device in [d for d in hass.data[DYSON_DEVICES] if
isinstance(d, DysonPureCoolLink)]:
devices.append(DysonFilterLifeSensor(hass, device))
devices.append(DysonDustSensor(hass, device))
devices.append(DysonHumiditySensor(hass, device))

View File

@ -168,9 +168,6 @@ def send_command(hass, command, params=None, entity_id=None):
@asyncio.coroutine
def async_setup(hass, config):
"""Set up the vacuum component."""
if not config[DOMAIN]:
return False
component = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_VACUUMS)

View File

@ -0,0 +1,213 @@
"""
Support for the Dyson 360 eye vacuum cleaner robot.
For more details about this platform, please refer to the documentation
https://home-assistant.io/components/vacuum.dyson/
"""
import asyncio
import logging
from homeassistant.components.dyson import DYSON_DEVICES
from homeassistant.components.vacuum import (SUPPORT_BATTERY,
SUPPORT_FAN_SPEED, SUPPORT_PAUSE,
SUPPORT_RETURN_HOME,
SUPPORT_STATUS, SUPPORT_STOP,
SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
VacuumDevice)
from homeassistant.util.icon import icon_for_battery_level
ATTR_FULL_CLEAN_TYPE = "full_clean_type"
ATTR_CLEAN_ID = "clean_id"
ATTR_POSITION = "position"
DEPENDENCIES = ['dyson']
_LOGGER = logging.getLogger(__name__)
DYSON_360_EYE_DEVICES = "dyson_360_eye_devices"
ICON = "mdi:roomba"
SUPPORT_DYSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \
SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | SUPPORT_STATUS | \
SUPPORT_BATTERY | SUPPORT_STOP
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Dyson 360 Eye robot vacuum platform."""
_LOGGER.info("Creating new Dyson 360 Eye robot vacuum")
if DYSON_360_EYE_DEVICES not in hass.data:
hass.data[DYSON_360_EYE_DEVICES] = []
# Get Dyson Devices from parent component
from libpurecoollink.dyson_360_eye import Dyson360Eye
for device in [d for d in hass.data[DYSON_DEVICES] if
isinstance(d, Dyson360Eye)]:
dyson_entity = Dyson360EyeDevice(device)
hass.data[DYSON_360_EYE_DEVICES].append(dyson_entity)
add_devices(hass.data[DYSON_360_EYE_DEVICES])
return True
class Dyson360EyeDevice(VacuumDevice):
"""Dyson 360 Eye robot vacuum device."""
def __init__(self, device):
"""Dyson 360 Eye robot vacuum device."""
_LOGGER.info("Creating device %s", device.name)
self._device = device
self._icon = ICON
@asyncio.coroutine
def async_added_to_hass(self):
"""Callback when entity is added to hass."""
self.hass.async_add_job(
self._device.add_message_listener, self.on_message)
def on_message(self, message):
"""Called when new messages received from the vacuum."""
_LOGGER.debug("Message received for %s device: %s", self.name, message)
self.schedule_update_ha_state()
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
False if entity pushes its state to HA.
"""
return False
@property
def name(self):
"""Return the name of the device."""
return self._device.name
@property
def icon(self):
"""Return the icon to use for device."""
return self._icon
@property
def status(self):
"""Return the status of the vacuum cleaner."""
from libpurecoollink.const import Dyson360EyeMode
dyson_labels = {
Dyson360EyeMode.INACTIVE_CHARGING: "Stopped - Charging",
Dyson360EyeMode.INACTIVE_CHARGED: "Stopped - Charged",
Dyson360EyeMode.FULL_CLEAN_PAUSED: "Paused",
Dyson360EyeMode.FULL_CLEAN_RUNNING: "Cleaning",
Dyson360EyeMode.FULL_CLEAN_ABORTED: "Returning home",
Dyson360EyeMode.FULL_CLEAN_INITIATED: "Start cleaning",
Dyson360EyeMode.FAULT_USER_RECOVERABLE: "Error - device blocked",
Dyson360EyeMode.FAULT_REPLACE_ON_DOCK:
"Error - Replace device on dock",
Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished",
Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging"
}
return dyson_labels.get(self._device.state.state,
self._device.state.state)
@property
def battery_level(self):
"""Return the battery level of the vacuum cleaner."""
return self._device.state.battery_level
@property
def fan_speed(self):
"""Return the fan speed of the vacuum cleaner."""
from libpurecoollink.const import PowerMode
speed_labels = {
PowerMode.MAX: "Max",
PowerMode.QUIET: "Quiet"
}
return speed_labels[self._device.state.power_mode]
@property
def fan_speed_list(self):
"""Get the list of available fan speed steps of the vacuum cleaner."""
return ["Quiet", "Max"]
@property
def device_state_attributes(self):
"""Return the specific state attributes of this vacuum cleaner."""
return {
ATTR_POSITION: str(self._device.state.position)
}
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
from libpurecoollink.const import Dyson360EyeMode
return self._device.state.state in [
Dyson360EyeMode.FULL_CLEAN_INITIATED,
Dyson360EyeMode.FULL_CLEAN_ABORTED,
Dyson360EyeMode.FULL_CLEAN_RUNNING
]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def supported_features(self):
"""Flag vacuum cleaner robot features that are supported."""
return SUPPORT_DYSON
@property
def battery_icon(self):
"""Return the battery icon for the vacuum cleaner."""
from libpurecoollink.const import Dyson360EyeMode
charging = self._device.state.state in [
Dyson360EyeMode.INACTIVE_CHARGING]
return icon_for_battery_level(
battery_level=self.battery_level, charging=charging)
def turn_on(self, **kwargs):
"""Turn the vacuum on."""
_LOGGER.debug("Turn on device %s", self.name)
from libpurecoollink.const import Dyson360EyeMode
if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]:
self._device.resume()
else:
self._device.start()
def turn_off(self, **kwargs):
"""Turn the vacuum off and return to home."""
_LOGGER.debug("Turn off device %s", self.name)
self._device.pause()
def stop(self, **kwargs):
"""Stop the vacuum cleaner."""
_LOGGER.debug("Stop device %s", self.name)
self._device.pause()
def set_fan_speed(self, fan_speed, **kwargs):
"""Set fan speed."""
_LOGGER.debug("Set fan speed %s on device %s", fan_speed, self.name)
from libpurecoollink.const import PowerMode
power_modes = {
"Quiet": PowerMode.QUIET,
"Max": PowerMode.MAX
}
self._device.set_power_mode(power_modes[fan_speed])
def start_pause(self, **kwargs):
"""Start, pause or resume the cleaning task."""
from libpurecoollink.const import Dyson360EyeMode
if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]:
_LOGGER.debug("Resume device %s", self.name)
self._device.resume()
elif self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGED,
Dyson360EyeMode.INACTIVE_CHARGING]:
_LOGGER.debug("Start device %s", self.name)
self._device.start()
else:
_LOGGER.debug("Pause device %s", self.name)
self._device.pause()
def return_to_base(self, **kwargs):
"""Set the vacuum cleaner to return to the dock."""
_LOGGER.debug("Return to base device %s", self.name)
self._device.abort()

View File

@ -354,7 +354,7 @@ knxip==0.5
libnacl==1.5.2
# homeassistant.components.dyson
libpurecoollink==0.4.1
libpurecoollink==0.4.2
# homeassistant.components.device_tracker.mikrotik
librouteros==1.0.2

View File

@ -62,7 +62,7 @@ holidays==0.8.1
influxdb==3.0.0
# homeassistant.components.dyson
libpurecoollink==0.4.1
libpurecoollink==0.4.2
# homeassistant.components.media_player.soundtouch
libsoundtouch==0.7.2

View File

@ -7,6 +7,7 @@ from homeassistant.components.fan import dyson
from tests.common import get_test_home_assistant
from libpurecoollink.const import FanSpeed, FanMode, NightMode, Oscillation
from libpurecoollink.dyson_pure_state import DysonPureCoolState
from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink
class MockDysonState(DysonPureCoolState):
@ -49,7 +50,7 @@ def _get_device_auto():
def _get_device_on():
"""Return a valid state on."""
device = mock.Mock()
device = mock.Mock(spec=DysonPureCoolLink)
device.name = "Device_name"
device.state = mock.Mock()
device.state.fan_mode = "FAN"
@ -84,8 +85,10 @@ class DysonTest(unittest.TestCase):
assert len(devices) == 1
assert devices[0].name == "Device_name"
device = _get_device_on()
self.hass.data[dyson.DYSON_DEVICES] = [device]
device_fan = _get_device_on()
device_non_fan = _get_device_off()
self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan]
dyson.setup_platform(self.hass, None, _add_device)
def test_dyson_set_speed(self):

View File

@ -6,11 +6,12 @@ from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, \
STATE_OFF
from homeassistant.components.sensor import dyson
from tests.common import get_test_home_assistant
from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink
def _get_device_without_state():
"""Return a valid device provide by Dyson web services."""
device = mock.Mock()
device = mock.Mock(spec=DysonPureCoolLink)
device.name = "Device_name"
device.state = None
device.environmental_state = None
@ -75,8 +76,9 @@ class DysonTest(unittest.TestCase):
assert devices[3].name == "Device_name temperature"
assert devices[4].name == "Device_name air quality"
device = _get_device_without_state()
self.hass.data[dyson.DYSON_DEVICES] = [device]
device_fan = _get_device_without_state()
device_non_fan = _get_with_state()
self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan]
dyson.setup_platform(self.hass, None, _add_device)
def test_dyson_filter_life_sensor(self):

View File

@ -11,6 +11,7 @@ def _get_dyson_account_device_available():
device = mock.Mock()
device.serial = "XX-XXXXX-XX"
device.connect = mock.Mock(return_value=True)
device.auto_connect = mock.Mock(return_value=True)
return device
@ -19,6 +20,15 @@ def _get_dyson_account_device_not_available():
device = mock.Mock()
device.serial = "XX-XXXXX-XX"
device.connect = mock.Mock(return_value=False)
device.auto_connect = mock.Mock(return_value=False)
return device
def _get_dyson_account_device_error():
"""Return an invalid device raising OSError while connecting."""
device = mock.Mock()
device.serial = "XX-XXXXX-XX"
device.connect = mock.Mock(side_effect=OSError("Network error"))
return device
@ -77,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, 2)
self.assertEqual(mocked_discovery.call_count, 3)
@mock.patch('libpurecoollink.dyson.DysonAccount.devices',
return_value=[_get_dyson_account_device_not_available()])
@ -100,6 +110,27 @@ class DysonTest(unittest.TestCase):
self.assertEqual(mocked_devices.call_count, 1)
self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 0)
@mock.patch('libpurecoollink.dyson.DysonAccount.devices',
return_value=[_get_dyson_account_device_error()])
@mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True)
def test_dyson_custom_conf_device_error(self, mocked_login,
mocked_devices):
"""Test device connection with device raising an exception."""
dyson.setup(self.hass, {dyson.DOMAIN: {
dyson.CONF_USERNAME: "email",
dyson.CONF_PASSWORD: "password",
dyson.CONF_LANGUAGE: "FR",
dyson.CONF_DEVICES: [
{
"device_id": "XX-XXXXX-XX",
"device_ip": "192.168.0.1"
}
]
}})
self.assertEqual(mocked_login.call_count, 1)
self.assertEqual(mocked_devices.call_count, 1)
self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 0)
@mock.patch('homeassistant.helpers.discovery.load_platform')
@mock.patch('libpurecoollink.dyson.DysonAccount.devices',
return_value=[_get_dyson_account_device_available()])
@ -141,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, 2)
self.assertEqual(mocked_discovery.call_count, 3)
@mock.patch('libpurecoollink.dyson.DysonAccount.devices',
return_value=[_get_dyson_account_device_not_available()])

View File

@ -0,0 +1,189 @@
"""Test the Dyson 360 eye robot vacuum component."""
import unittest
from unittest import mock
from libpurecoollink.dyson_360_eye import Dyson360Eye
from libpurecoollink.const import Dyson360EyeMode, PowerMode
from homeassistant.components.vacuum import dyson
from homeassistant.components.vacuum.dyson import Dyson360EyeDevice
from tests.common import get_test_home_assistant
def _get_non_vacuum_device():
"""Return a non vacuum device."""
device = mock.Mock()
device.name = "Device_Fan"
device.state = None
return device
def _get_vacuum_device_cleaning():
"""Return a vacuum device running."""
device = mock.Mock(spec=Dyson360Eye)
device.name = "Device_Vacuum"
device.state = mock.MagicMock()
device.state.state = Dyson360EyeMode.FULL_CLEAN_RUNNING
device.state.battery_level = 85
device.state.power_mode = PowerMode.QUIET
device.state.position = (0, 0)
return device
def _get_vacuum_device_charging():
"""Return a vacuum device charging."""
device = mock.Mock(spec=Dyson360Eye)
device.name = "Device_Vacuum"
device.state = mock.MagicMock()
device.state.state = Dyson360EyeMode.INACTIVE_CHARGING
device.state.battery_level = 40
device.state.power_mode = PowerMode.QUIET
device.state.position = (0, 0)
return device
def _get_vacuum_device_pause():
"""Return a vacuum device in pause."""
device = mock.MagicMock(spec=Dyson360Eye)
device.name = "Device_Vacuum"
device.state = mock.MagicMock()
device.state.state = Dyson360EyeMode.FULL_CLEAN_PAUSED
device.state.battery_level = 40
device.state.power_mode = PowerMode.QUIET
device.state.position = (0, 0)
return device
def _get_vacuum_device_unknown_state():
"""Return a vacuum device with unknown state."""
device = mock.Mock(spec=Dyson360Eye)
device.name = "Device_Vacuum"
device.state = mock.MagicMock()
device.state.state = "Unknown"
return device
class DysonTest(unittest.TestCase):
"""Dyson 360 eye robot vacuum component test class."""
def setUp(self): # pylint: disable=invalid-name
"""Setup 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()
def test_setup_component_with_no_devices(self):
"""Test setup component with no devices."""
self.hass.data[dyson.DYSON_DEVICES] = []
add_devices = mock.MagicMock()
dyson.setup_platform(self.hass, {}, add_devices)
add_devices.assert_called_with([])
def test_setup_component(self):
"""Test setup component with devices."""
def _add_device(devices):
assert len(devices) == 1
assert devices[0].name == "Device_Vacuum"
device_vacuum = _get_vacuum_device_cleaning()
device_non_vacuum = _get_non_vacuum_device()
self.hass.data[dyson.DYSON_DEVICES] = [device_vacuum,
device_non_vacuum]
dyson.setup_platform(self.hass, {}, _add_device)
def test_on_message(self):
"""Test when message is received."""
device = _get_vacuum_device_cleaning()
component = Dyson360EyeDevice(device)
component.entity_id = "entity_id"
component.schedule_update_ha_state = mock.Mock()
component.on_message(mock.Mock())
self.assertTrue(component.schedule_update_ha_state.called)
def test_should_poll(self):
"""Test polling is disable."""
device = _get_vacuum_device_cleaning()
component = Dyson360EyeDevice(device)
self.assertFalse(component.should_poll)
def test_properties(self):
"""Test component properties."""
device1 = _get_vacuum_device_cleaning()
device2 = _get_vacuum_device_unknown_state()
device3 = _get_vacuum_device_charging()
component = Dyson360EyeDevice(device1)
component2 = Dyson360EyeDevice(device2)
component3 = Dyson360EyeDevice(device3)
self.assertEqual(component.name, "Device_Vacuum")
self.assertTrue(component.is_on)
self.assertEqual(component.icon, "mdi:roomba")
self.assertEqual(component.status, "Cleaning")
self.assertEqual(component2.status, "Unknown")
self.assertEqual(component.battery_level, 85)
self.assertEqual(component.fan_speed, "Quiet")
self.assertEqual(component.fan_speed_list, ["Quiet", "Max"])
self.assertEqual(component.device_state_attributes['position'],
'(0, 0)')
self.assertTrue(component.available)
self.assertEqual(component.supported_features, 255)
self.assertEqual(component.battery_icon, "mdi:battery-80")
self.assertEqual(component3.battery_icon, "mdi:battery-charging-40")
def test_turn_on(self):
"""Test turn on vacuum."""
device1 = _get_vacuum_device_charging()
component1 = Dyson360EyeDevice(device1)
component1.turn_on()
self.assertTrue(device1.start.called)
device2 = _get_vacuum_device_pause()
component2 = Dyson360EyeDevice(device2)
component2.turn_on()
self.assertTrue(device2.resume.called)
def test_turn_off(self):
"""Test turn off vacuum."""
device1 = _get_vacuum_device_cleaning()
component1 = Dyson360EyeDevice(device1)
component1.turn_off()
self.assertTrue(device1.pause.called)
def test_stop(self):
"""Test stop vacuum."""
device1 = _get_vacuum_device_cleaning()
component1 = Dyson360EyeDevice(device1)
component1.stop()
self.assertTrue(device1.pause.called)
def test_set_fan_speed(self):
"""Test set fan speed vacuum."""
device1 = _get_vacuum_device_cleaning()
component1 = Dyson360EyeDevice(device1)
component1.set_fan_speed("Max")
device1.set_power_mode.assert_called_with(PowerMode.MAX)
def test_start_pause(self):
"""Test start/pause."""
device1 = _get_vacuum_device_charging()
component1 = Dyson360EyeDevice(device1)
component1.start_pause()
self.assertTrue(device1.start.called)
device2 = _get_vacuum_device_pause()
component2 = Dyson360EyeDevice(device2)
component2.start_pause()
self.assertTrue(device2.resume.called)
device3 = _get_vacuum_device_cleaning()
component3 = Dyson360EyeDevice(device3)
component3.start_pause()
self.assertTrue(device3.pause.called)
def test_return_to_base(self):
"""Test return to base."""
device = _get_vacuum_device_pause()
component = Dyson360EyeDevice(device)
component.return_to_base()
self.assertTrue(device.abort.called)