From c6aaacbb08dd5ce48378e5d527c8b0133c9d6845 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Sun, 6 Aug 2017 19:23:22 +0200 Subject: [PATCH] Add new service `clean_spot` to vacuums (#8862) * Add new service `clean_spot` to vacuums - Add as base component service, with associated support flag to make it optional - Implement on Demo vacuum - Implement on Xiaomi vacuum - Update tests for platforms Demo and Xiaomi - Change default icon for vacuums to `mdi:roomba`, but keep the one for the Xiaomi - (In a polymer PR: add new service to command toolbar in the 'more-info' card) * Add `clean_spot` service description * fix default properties for vacuum component --- homeassistant/components/vacuum/__init__.py | 31 ++++++++++++++++--- homeassistant/components/vacuum/demo.py | 24 ++++++++++---- homeassistant/components/vacuum/services.yaml | 8 +++++ homeassistant/components/vacuum/xiaomi.py | 20 +++++++----- tests/components/vacuum/test_demo.py | 14 ++++++++- tests/components/vacuum/test_xiaomi.py | 17 ++++++---- 6 files changed, 89 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 08cdd6373797..a3d963624336 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -41,6 +41,7 @@ ATTR_FAN_SPEED_LIST = 'fan_speed_list' ATTR_PARAMS = 'params' ATTR_STATUS = 'status' +SERVICE_CLEAN_SPOT = 'clean_spot' SERVICE_LOCATE = 'locate' SERVICE_RETURN_TO_BASE = 'return_to_base' SERVICE_SEND_COMMAND = 'send_command' @@ -67,6 +68,7 @@ SERVICE_TO_METHOD = { SERVICE_TOGGLE: {'method': 'async_toggle'}, SERVICE_START_PAUSE: {'method': 'async_start_pause'}, SERVICE_RETURN_TO_BASE: {'method': 'async_return_to_base'}, + SERVICE_CLEAN_SPOT: {'method': 'async_clean_spot'}, SERVICE_LOCATE: {'method': 'async_locate'}, SERVICE_STOP: {'method': 'async_stop'}, SERVICE_SET_FAN_SPEED: {'method': 'async_set_fan_speed', @@ -76,7 +78,7 @@ SERVICE_TO_METHOD = { } DEFAULT_NAME = 'Vacuum cleaner robot' -DEFAULT_ICON = 'mdi:google-circles-group' +DEFAULT_ICON = 'mdi:roomba' SUPPORT_TURN_ON = 1 SUPPORT_TURN_OFF = 2 @@ -88,7 +90,8 @@ SUPPORT_BATTERY = 64 SUPPORT_STATUS = 128 SUPPORT_SEND_COMMAND = 256 SUPPORT_LOCATE = 512 -SUPPORT_MAP = 1024 +SUPPORT_CLEAN_SPOT = 1024 +SUPPORT_MAP = 2048 @bind_hass @@ -126,6 +129,13 @@ def locate(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_LOCATE, data) +@bind_hass +def clean_spot(hass, entity_id=None): + """Tell all or specified vacuum to perform a spot clean-up.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_CLEAN_SPOT, data) + + @bind_hass def return_to_base(hass, entity_id=None): """Tell all or specified vacuum to return to base.""" @@ -222,12 +232,12 @@ class VacuumDevice(ToggleEntity): @property def status(self): """Return the status of the vacuum cleaner.""" - raise NotImplementedError() + return None @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" - raise NotImplementedError() + return None @property def battery_icon(self): @@ -241,7 +251,7 @@ class VacuumDevice(ToggleEntity): @property def fan_speed(self): """Return the fan speed of the vacuum cleaner.""" - raise NotImplementedError() + return None @property def fan_speed_list(self): @@ -310,6 +320,17 @@ class VacuumDevice(ToggleEntity): """ return self.hass.async_add_job(partial(self.stop, **kwargs)) + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + raise NotImplementedError() + + def async_clean_spot(self, **kwargs): + """Perform a spot clean-up. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.clean_spot, **kwargs)) + def locate(self, **kwargs): """Locate the vacuum cleaner.""" raise NotImplementedError() diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index a3a9bb24314a..54415b59db08 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -7,10 +7,10 @@ https://home-assistant.io/components/demo/ import logging from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, - SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, - SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - VacuumDevice) + ATTR_CLEANED_AREA, DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, + SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, + SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, VacuumDevice) _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,8 @@ SUPPORT_MOST_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP | \ SUPPORT_ALL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_STOP | SUPPORT_RETURN_HOME | \ SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND | \ - SUPPORT_LOCATE | SUPPORT_STATUS | SUPPORT_BATTERY + SUPPORT_LOCATE | SUPPORT_STATUS | SUPPORT_BATTERY | \ + SUPPORT_CLEAN_SPOT FAN_SPEEDS = ['min', 'medium', 'high', 'max'] DEMO_VACUUM_COMPLETE = '0_Ground_floor' @@ -68,7 +69,7 @@ class DemoVacuum(VacuumDevice): @property def icon(self): """Return the icon for the vacuum.""" - return 'mdi:roomba' + return DEFAULT_ICON @property def should_poll(self): @@ -149,6 +150,17 @@ class DemoVacuum(VacuumDevice): self._status = 'Stopping the current task' self.schedule_update_ha_state() + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + self._state = True + self._cleaned_area += 1.32 + self._battery_level -= 1 + self._status = "Cleaning spot" + self.schedule_update_ha_state() + def locate(self, **kwargs): """Turn the vacuum off.""" if self.supported_features & SUPPORT_LOCATE == 0: diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index a866794e3ac4..7f321d055cfa 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -46,6 +46,14 @@ return_to_base: description: Name of the botvac entity. example: 'vacuum.xiaomi_vacuum_cleaner' +clean_spot: + description: Tell the vacuum cleaner to do a spot clean-up. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + send_command: description: Send a raw command to the vacuum cleaner. diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 2e99e94a7d6b..c5fb0d3c003a 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.py @@ -12,11 +12,10 @@ import os import voluptuous as vol from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, DEFAULT_ICON, DOMAIN, PLATFORM_SCHEMA, - SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, - SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, - SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - VACUUM_SERVICE_SCHEMA, VacuumDevice) + ATTR_CLEANED_AREA, DOMAIN, PLATFORM_SCHEMA, SUPPORT_BATTERY, + SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VACUUM_SERVICE_SCHEMA, VacuumDevice) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) @@ -27,7 +26,7 @@ REQUIREMENTS = ['python-mirobo==0.1.2'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' -ICON = DEFAULT_ICON +ICON = 'mdi:google-circles-group' PLATFORM = 'xiaomi' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -76,7 +75,7 @@ SERVICE_TO_METHOD = { SUPPORT_XIAOMI = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_STOP | SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \ SUPPORT_SEND_COMMAND | SUPPORT_LOCATE | \ - SUPPORT_STATUS | SUPPORT_BATTERY + SUPPORT_STATUS | SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT @asyncio.coroutine @@ -283,6 +282,13 @@ class MiroboVacuum(VacuumDevice): if return_home: self._is_on = False + @asyncio.coroutine + def async_clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + yield from self._try_command( + "Unable to start the vacuum for a spot clean-up: %s", + self._vacuum.spot) + @asyncio.coroutine def async_locate(self, **kwargs): """Locate the vacuum cleaner.""" diff --git a/tests/components/vacuum/test_demo.py b/tests/components/vacuum/test_demo.py index 2b8fb34b92a6..fadafdbc15e3 100644 --- a/tests/components/vacuum/test_demo.py +++ b/tests/components/vacuum/test_demo.py @@ -39,7 +39,7 @@ class TestVacuumDemo(unittest.TestCase): def test_supported_features(self): """Test vacuum supported features.""" state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) - self.assertEqual(1023, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertEqual(2047, state.attributes.get(ATTR_SUPPORTED_FEATURES)) self.assertEqual("Charging", state.attributes.get(ATTR_STATUS)) self.assertEqual(100, state.attributes.get(ATTR_BATTERY_LEVEL)) self.assertEqual("medium", state.attributes.get(ATTR_FAN_SPEED)) @@ -141,6 +141,12 @@ class TestVacuumDemo(unittest.TestCase): state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) self.assertEqual(FAN_SPEEDS[-1], state.attributes.get(ATTR_FAN_SPEED)) + vacuum.clean_spot(self.hass, entity_id=ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + self.assertIn("spot", state.attributes.get(ATTR_STATUS)) + self.assertEqual(STATE_ON, state.state) + def test_unsupported_methods(self): """Test service calls for unsupported vacuums.""" self.hass.states.set(ENTITY_VACUUM_NONE, STATE_ON) @@ -189,6 +195,12 @@ class TestVacuumDemo(unittest.TestCase): self.assertNotEqual(FAN_SPEEDS[-1], state.attributes.get(ATTR_FAN_SPEED)) + vacuum.clean_spot(self.hass, entity_id=ENTITY_VACUUM_BASIC) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_BASIC) + self.assertNotIn("spot", state.attributes.get(ATTR_STATUS)) + self.assertEqual(STATE_OFF, state.state) + def test_services(self): """Test vacuum services.""" # Test send_command diff --git a/tests/components/vacuum/test_xiaomi.py b/tests/components/vacuum/test_xiaomi.py index d5c8cda65c3a..0045bbb3b248 100644 --- a/tests/components/vacuum/test_xiaomi.py +++ b/tests/components/vacuum/test_xiaomi.py @@ -8,9 +8,9 @@ import pytest from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, DOMAIN, - SERVICE_LOCATE, SERVICE_RETURN_TO_BASE, SERVICE_SEND_COMMAND, - SERVICE_SET_FAN_SPEED, SERVICE_START_PAUSE, SERVICE_STOP, - SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) + SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_RETURN_TO_BASE, + SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START_PAUSE, + SERVICE_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.components.vacuum.xiaomi import ( ATTR_CLEANED_AREA, ATTR_CLEANING_TIME, ATTR_DO_NOT_DISTURB, ATTR_ERROR, CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, @@ -112,7 +112,7 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 1023 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047 assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_ON assert state.attributes.get(ATTR_ERROR) == 'Error message' assert (state.attributes.get(ATTR_BATTERY_ICON) @@ -159,6 +159,11 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().find()' assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + yield from hass.services.async_call( + DOMAIN, SERVICE_CLEAN_SPOT, {}, blocking=True) + assert str(mock_mirobo_is_off.mock_calls[-2]) == 'call.Vacuum().spot()' + assert str(mock_mirobo_is_off.mock_calls[-1]) == 'call.Vacuum().status()' + # Set speed service: yield from hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": 60}, blocking=True) @@ -193,7 +198,7 @@ def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off): @asyncio.coroutine -def test_xiaomi_vacuum_specific_services(hass, caplog, mock_mirobo_is_on): +def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): """Test vacuum supported features.""" entity_name = 'test_vacuum_cleaner_2' entity_id = '{}.{}'.format(DOMAIN, entity_name) @@ -210,7 +215,7 @@ def test_xiaomi_vacuum_specific_services(hass, caplog, mock_mirobo_is_on): # Check state attributes state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 1023 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047 assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_OFF assert state.attributes.get(ATTR_ERROR) is None assert (state.attributes.get(ATTR_BATTERY_ICON)