diff --git a/.coveragerc b/.coveragerc index e0b52214de61253b59851a201821b1a26dddc20b..67b4d8be25885cc168bcb898bfecdfd1def40dfc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -535,7 +535,6 @@ omit = homeassistant/components/switch/tplink.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py - homeassistant/components/switch/xiaomi_vacuum.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index bc713c9ec06ab03a4f749b6badd68791b29427db..9989b2799cdab3d218f6fc1947a08373c32309ec 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -66,7 +66,6 @@ ATTR_TYPE_ID = 'type_id' ATTR_VENDOR_NAME = 'vendor_name' ATTR_VENDOR_ID = 'vendor_id' ATTR_DEVICE = 'device' -ATTR_COMMAND = 'command' ATTR_TYPE = 'type' ATTR_KEY = 'key' ATTR_DUR = 'dur' diff --git a/homeassistant/components/switch/xiaomi_vacuum.py b/homeassistant/components/switch/xiaomi_vacuum.py deleted file mode 100644 index 393cabb72b9caced7e2f84670bb14108403b97de..0000000000000000000000000000000000000000 --- a/homeassistant/components/switch/xiaomi_vacuum.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Support for Xiaomi Vacuum cleaner robot. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/switch.xiaomi_vacuum/ -""" -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.const import (DEVICE_DEFAULT_NAME, - CONF_NAME, CONF_HOST, CONF_TOKEN) - -_LOGGER = logging.getLogger(__name__) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), - vol.Optional(CONF_NAME): cv.string, -}) - -REQUIREMENTS = ['python-mirobo==0.1.2'] - - -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Set up the vacuum from config.""" - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - token = config.get(CONF_TOKEN) - - add_devices_callback([MiroboSwitch(name, host, token)], True) - - -class MiroboSwitch(SwitchDevice): - """Representation of a Xiaomi Vacuum.""" - - def __init__(self, name, host, token): - """Initialize the vacuum switch.""" - self._name = name or DEVICE_DEFAULT_NAME - self._icon = 'mdi:broom' - self.host = host - self.token = token - - self._vacuum = None - self._state = None - self._state_attrs = {} - self._is_on = False - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._state is not None - - @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self): - """Return true if switch is on.""" - return self._is_on - - @property - def vacuum(self): - """Property accessor for vacuum object.""" - if not self._vacuum: - from mirobo import Vacuum - _LOGGER.info("initializing with host %s token %s", - self.host, self.token) - self._vacuum = Vacuum(self.host, self.token) - - return self._vacuum - - def turn_on(self, **kwargs): - """Turn the vacuum on.""" - from mirobo import VacuumException - try: - self.vacuum.start() - self._is_on = True - except VacuumException as ex: - _LOGGER.error("Unable to start the vacuum: %s", ex) - - def turn_off(self, **kwargs): - """Turn the vacuum off and return to home.""" - from mirobo import VacuumException - try: - self.vacuum.stop() - self.vacuum.home() - self._is_on = False - except VacuumException as ex: - _LOGGER.error("Unable to turn off and return home: %s", ex) - - def update(self): - """Fetch state from the device.""" - from mirobo import DeviceException - try: - state = self.vacuum.status() - _LOGGER.debug("got state from the vacuum: %s", state) - - self._state_attrs = { - 'Status': state.state, 'Error': state.error, - 'Battery': state.battery, 'Fan': state.fanspeed, - 'Cleaning time': str(state.clean_time), - 'Cleaned area': state.clean_area} - - self._state = state.state_code - self._is_on = state.is_on - except DeviceException as ex: - _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b58bdb8916b8f84c913c55ee3ae832ea8afa57ae..38669ff4ee66ce4f5f3de968006003ba6dfee517 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -15,11 +15,11 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol from homeassistant.components.notify import ( - ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA) + ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_PLATFORM, CONF_API_KEY, CONF_TIMEOUT, ATTR_LATITUDE, ATTR_LONGITUDE, - HTTP_DIGEST_AUTHENTICATION) + ATTR_COMMAND, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, + CONF_PLATFORM, CONF_TIMEOUT, HTTP_DIGEST_AUTHENTICATION) import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform @@ -35,7 +35,6 @@ ATTR_CALLBACK_QUERY_ID = 'callback_query_id' ATTR_CAPTION = 'caption' ATTR_CHAT_ID = 'chat_id' ATTR_CHAT_INSTANCE = 'chat_instance' -ATTR_COMMAND = 'command' ATTR_DISABLE_NOTIF = 'disable_notification' ATTR_DISABLE_WEB_PREV = 'disable_web_page_preview' ATTR_EDITED_MSG = 'edited_message' diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..078be8e259f28d2ed7a05a5ee7c3d698cb9e5fe0 --- /dev/null +++ b/homeassistant/components/vacuum/__init__.py @@ -0,0 +1,364 @@ +""" +Support for vacuum cleaner robots (botvacs). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum/ +""" +import asyncio +from datetime import timedelta +from functools import partial +import logging +import os + +import voluptuous as vol + +from homeassistant.components import group +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.util.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'vacuum' +DEPENDENCIES = ['group'] + +SCAN_INTERVAL = timedelta(seconds=20) + +GROUP_NAME_ALL_VACUUMS = 'all vacuum cleaners' +ENTITY_ID_ALL_VACUUMS = group.ENTITY_ID_FORMAT.format('all_vacuum_cleaners') + +ATTR_BATTERY_ICON = 'battery_icon' +ATTR_CLEANED_AREA = 'cleaned_area' +ATTR_FAN_SPEED = 'fan_speed' +ATTR_FAN_SPEED_LIST = 'fan_speed_list' +ATTR_PARAMS = 'params' +ATTR_STATUS = 'status' + +SERVICE_LOCATE = 'locate' +SERVICE_RETURN_TO_BASE = 'return_to_base' +SERVICE_SEND_COMMAND = 'send_command' +SERVICE_SET_FAN_SPEED = 'set_fan_speed' +SERVICE_START_PAUSE = 'start_pause' +SERVICE_STOP = 'stop' + +VACUUM_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FAN_SPEED): cv.string, +}) + +VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PARAMS): cv.Dict, +}) + +SERVICE_TO_METHOD = { + SERVICE_TURN_ON: {'method': 'async_turn_on'}, + SERVICE_TURN_OFF: {'method': 'async_turn_off'}, + SERVICE_TOGGLE: {'method': 'async_toggle'}, + SERVICE_START_PAUSE: {'method': 'async_start_pause'}, + SERVICE_RETURN_TO_BASE: {'method': 'async_return_to_base'}, + SERVICE_LOCATE: {'method': 'async_locate'}, + SERVICE_STOP: {'method': 'async_stop'}, + SERVICE_SET_FAN_SPEED: {'method': 'async_set_fan_speed', + 'schema': VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA}, + SERVICE_SEND_COMMAND: {'method': 'async_send_command', + 'schema': VACUUM_SEND_COMMAND_SERVICE_SCHEMA}, +} + +DEFAULT_NAME = 'Vacuum cleaner robot' +DEFAULT_ICON = 'mdi:google-circles-group' + +SUPPORT_TURN_ON = 1 +SUPPORT_TURN_OFF = 2 +SUPPORT_PAUSE = 4 +SUPPORT_STOP = 8 +SUPPORT_RETURN_HOME = 16 +SUPPORT_FAN_SPEED = 32 +SUPPORT_BATTERY = 64 +SUPPORT_STATUS = 128 +SUPPORT_SEND_COMMAND = 256 +SUPPORT_LOCATE = 512 +SUPPORT_MAP = 1024 + + +@bind_hass +def is_on(hass, entity_id=None): + """Return if the vacuum is on based on the statemachine.""" + entity_id = entity_id or ENTITY_ID_ALL_VACUUMS + return hass.states.is_state(entity_id, STATE_ON) + + +@bind_hass +def turn_on(hass, entity_id=None): + """Turn all or specified vacuum on.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +@bind_hass +def turn_off(hass, entity_id=None): + """Turn all or specified vacuum off.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +@bind_hass +def toggle(hass, entity_id=None): + """Toggle all or specified vacuum.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + + +@bind_hass +def locate(hass, entity_id=None): + """Locate all or specified vacuum.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_LOCATE, data) + + +@bind_hass +def return_to_base(hass, entity_id=None): + """Tell all or specified vacuum to return to base.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_RETURN_TO_BASE, data) + + +@bind_hass +def start_pause(hass, entity_id=None): + """Tell all or specified vacuum to start or pause the current task.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_START_PAUSE, data) + + +@bind_hass +def stop(hass, entity_id=None): + """Stop all or specified vacuum.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_STOP, data) + + +@bind_hass +def set_fan_speed(hass, fan_speed, entity_id=None): + """Set fan speed for all or specified vacuum.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_FAN_SPEED] = fan_speed + hass.services.call(DOMAIN, SERVICE_SET_FAN_SPEED, data) + + +@bind_hass +def send_command(hass, command, params=None, entity_id=None): + """Send command to all or specified vacuum.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_COMMAND] = command + if params is not None: + data[ATTR_PARAMS] = params + hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data) + + +@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) + + yield from component.async_setup(config) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_handle_vacuum_service(service): + """Map services to methods on VacuumDevice.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + target_vacuums = component.async_extract_from_service(service) + params = service.data.copy() + params.pop(ATTR_ENTITY_ID, None) + + update_tasks = [] + for vacuum in target_vacuums: + yield from getattr(vacuum, method['method'])(**params) + if not vacuum.should_poll: + continue + + update_coro = hass.async_add_job( + vacuum.async_update_ha_state(True)) + if hasattr(vacuum, 'async_update'): + update_tasks.append(update_coro) + else: + yield from update_coro + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service].get( + 'schema', VACUUM_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, service, async_handle_vacuum_service, + descriptions.get(service), schema=schema) + + return True + + +class VacuumDevice(ToggleEntity): + """Representation of a vacuum cleaner robot.""" + + @property + def supported_features(self): + """Flag vacuum cleaner features that are supported.""" + return 0 + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return None + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return None + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = False + if self.status is not None: + charging = 'charg' in self.status.lower() + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging) + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return None + + @property + def fan_speed_list(self) -> list: + """Get the list of available fan speed steps of the vacuum cleaner.""" + return [] + + @property + def state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} + + if self.status is not None: + data[ATTR_STATUS] = self.status + + if self.battery_level is not None: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if self.fan_speed is not None: + data[ATTR_FAN_SPEED] = self.fan_speed + data[ATTR_FAN_SPEED_LIST] = self.fan_speed_list + + return data + + def turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + raise NotImplementedError() + + def async_turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.turn_on, **kwargs)) + + def turn_off(self, **kwargs): + """Turn the vacuum off stopping the cleaning and returning home.""" + raise NotImplementedError() + + def async_turn_off(self, **kwargs): + """Turn the vacuum off stopping the cleaning and returning home. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.turn_off, **kwargs)) + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + raise NotImplementedError() + + def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.return_to_base, **kwargs)) + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + raise NotImplementedError() + + def async_stop(self, **kwargs): + """Stop the vacuum cleaner. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.stop, **kwargs)) + + def locate(self, **kwargs): + """Locate the vacuum cleaner.""" + raise NotImplementedError() + + def async_locate(self, **kwargs): + """Locate the vacuum cleaner. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(partial(self.locate, **kwargs)) + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + raise NotImplementedError() + + def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + partial(self.set_fan_speed, fan_speed, **kwargs)) + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + raise NotImplementedError() + + def async_start_pause(self, **kwargs): + """Start, pause or resume the cleaning task. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + partial(self.start_pause, **kwargs)) + + def send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + raise NotImplementedError() + + def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job( + partial(self.send_command, command, params=params, **kwargs)) diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py new file mode 100644 index 0000000000000000000000000000000000000000..aecf1eb3cf17618058ed0dca27d0b6de57bfbe91 --- /dev/null +++ b/homeassistant/components/vacuum/demo.py @@ -0,0 +1,203 @@ +""" +Demo platform for the vacuum component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import logging + +from homeassistant.components.vacuum import ( + ATTR_CLEANED_AREA, DEFAULT_ICON, 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) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_MINIMAL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +SUPPORT_BASIC_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_STATUS | SUPPORT_BATTERY + +SUPPORT_MOST_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP | \ + SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY + +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 + +FAN_SPEEDS = ['min', 'medium', 'high', 'max'] +DEMO_VACUUM_COMPLETE = '0_Ground_floor' +DEMO_VACUUM_MOST = '1_First_floor' +DEMO_VACUUM_BASIC = '2_Second_floor' +DEMO_VACUUM_MINIMAL = '3_Third_floor' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Demo vacuums.""" + add_devices([ + DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + ]) + + +class DemoVacuum(VacuumDevice): + """Representation of a demo vacuum.""" + + # pylint: disable=no-self-use + def __init__(self, name, supported_features=None): + """Initialize the vacuum.""" + self._name = name + self._supported_features = supported_features + self._state = False + self._status = 'Charging' + self._fan_speed = FAN_SPEEDS[1] + self._cleaned_area = 0 + self._battery_level = 100 + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def icon(self): + """Return the icon for the vacuum.""" + return DEFAULT_ICON + + @property + def should_poll(self): + """No polling needed for a demo vacuum.""" + return False + + @property + def is_on(self): + """Return true if vacuum is on.""" + return self._state + + @property + def status(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_STATUS == 0: + return + + return self._status + + @property + def fan_speed(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return FAN_SPEEDS + + @property + def battery_level(self): + """Return the status of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} + + @property + def supported_features(self): + """Flag supported features.""" + if self._supported_features is not None: + return self._supported_features + + return super().supported_features + + def turn_on(self, **kwargs): + """Turn the vacuum on.""" + if self.supported_features & SUPPORT_TURN_ON == 0: + return + + self._state = True + self._cleaned_area += 5.32 + self._battery_level -= 2 + self._status = 'Cleaning' + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_TURN_OFF == 0: + return + + self._state = False + self._status = 'Charging' + self.schedule_update_ha_state() + + def stop(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + self._state = False + self._status = 'Stopping the current task' + self.schedule_update_ha_state() + + def locate(self, **kwargs): + """Turn the vacuum off.""" + if self.supported_features & SUPPORT_LOCATE == 0: + return + + self._status = "Hi, I'm over here!" + self.schedule_update_ha_state() + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + self._state = not self._state + if self._state: + self._status = 'Resuming the current task' + self._cleaned_area += 1.32 + self._battery_level -= 1 + else: + self._status = 'Pausing the current task' + self.schedule_update_ha_state() + + def set_fan_speed(self, fan_speed, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + if fan_speed in self.fan_speed_list: + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def return_to_base(self, **kwargs): + """Tell the vacuum to return to its dock.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + self._state = False + self._status = 'Returning home...' + self._battery_level += 5 + self.schedule_update_ha_state() + + def send_command(self, command, params=None, **kwargs): + """Send a command to the vacuum.""" + if self.supported_features & SUPPORT_SEND_COMMAND == 0: + return + + self._status = 'Executing {}({})'.format(command, params) + self._state = True + self.schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a866794e3ac476ad8fd5e3f46d79c3ae0e32f47c --- /dev/null +++ b/homeassistant/components/vacuum/services.yaml @@ -0,0 +1,131 @@ +turn_on: + description: Start a new cleaning task. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +turn_off: + description: Stop the current cleaning task and return to home. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +stop: + description: Stop the current cleaning task. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +locate: + description: Locate the vacuum cleaner robot. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +start_pause: + description: Start, pause, or resume the cleaning task. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +return_to_base: + description: Tell the vacuum cleaner to return to its dock. + + 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. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + + command: + description: Command to execute. + example: 'set_dnd_timer' + + params: + description: Parameters for the command. + example: '[22,0,6,0]' + +set_fan_speed: + description: Set the fan speed of the vacuum cleaner. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + + fan_speed: + description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium', or by percentage, between 0 and 100. + example: 'low' + +xiaomi_remote_control_start: + description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +xiaomi_remote_control_stop: + description: Stop remote control mode of the vacuum cleaner. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +xiaomi_remote_control_move: + description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + + velocity: + description: Speed, between -0.29 and 0.29. + example: '0.2' + + rotation: + description: Rotation, between -179 degrees and 179 degrees. + example: '90' + + duration: + description: Duration of the movement? + example: '1500' + +xiaomi_remote_control_move_step: + description: Remote control the vacuum cleaner, only makes one move and then stops. + + fields: + entity_id: + description: Name of the botvac entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + + velocity: + description: Speed, between -0.29 and 0.29. + example: '0.2' + + rotation: + description: Rotation, between -179 degrees and 179 degrees. + example: '90' + + duration: + description: Duration of the movement? + example: '1500' diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py new file mode 100644 index 0000000000000000000000000000000000000000..49508d0dce5a349f1967518cb9a9429afde150ab --- /dev/null +++ b/homeassistant/components/vacuum/xiaomi.py @@ -0,0 +1,354 @@ +""" +Support for the Xiaomi vacuum cleaner robot. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum.xiaomi/ +""" +import asyncio +from functools import partial +import logging +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) +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) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-mirobo==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Vacuum cleaner' +ICON = DEFAULT_ICON +PLATFORM = 'xiaomi' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}, extra=vol.ALLOW_EXTRA) + +SERVICE_MOVE_REMOTE_CONTROL = 'xiaomi_remote_control_move' +SERVICE_MOVE_REMOTE_CONTROL_STEP = 'xiaomi_remote_control_move_step' +SERVICE_START_REMOTE_CONTROL = 'xiaomi_remote_control_start' +SERVICE_STOP_REMOTE_CONTROL = 'xiaomi_remote_control_stop' + +FAN_SPEEDS = { + 'Quiet': 38, + 'Balanced': 60, + 'Turbo': 77, + 'Max': 90} + +ATTR_CLEANING_TIME = 'cleaning_time' +ATTR_DO_NOT_DISTURB = 'do_not_disturb' +ATTR_ERROR = 'error' +ATTR_RC_DURATION = 'duration' +ATTR_RC_ROTATION = 'rotation' +ATTR_RC_VELOCITY = 'velocity' + +SERVICE_SCHEMA_REMOTE_CONTROL = VACUUM_SERVICE_SCHEMA.extend({ + vol.Optional(ATTR_RC_VELOCITY): + vol.All(vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29)), + vol.Optional(ATTR_RC_ROTATION): + vol.All(vol.Coerce(int), vol.Clamp(min=-179, max=179)), + vol.Optional(ATTR_RC_DURATION): cv.positive_int, +}) + +SERVICE_TO_METHOD = { + SERVICE_START_REMOTE_CONTROL: {'method': 'async_remote_control_start'}, + SERVICE_STOP_REMOTE_CONTROL: {'method': 'async_remote_control_stop'}, + SERVICE_MOVE_REMOTE_CONTROL: { + 'method': 'async_remote_control_move', + 'schema': SERVICE_SCHEMA_REMOTE_CONTROL}, + SERVICE_MOVE_REMOTE_CONTROL_STEP: { + 'method': 'async_remote_control_move_step', + 'schema': SERVICE_SCHEMA_REMOTE_CONTROL}, +} + +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 + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Xiaomi vacuum cleaner robot platform.""" + from mirobo import Vacuum + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + # Create handler + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + vacuum = Vacuum(host, token) + + mirobo = MiroboVacuum(name, vacuum) + hass.data[PLATFORM][host] = mirobo + + async_add_devices([mirobo], update_before_add=True) + + @asyncio.coroutine + def async_service_handler(service): + """Map services to methods on MiroboVacuum.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_vacuums = [vac for vac in hass.data[PLATFORM].values() + if vac.entity_id in entity_ids] + else: + target_vacuums = hass.data[PLATFORM].values() + + update_tasks = [] + for vacuum in target_vacuums: + yield from getattr(vacuum, method['method'])(**params) + + for vacuum in target_vacuums: + update_coro = vacuum.async_update_ha_state(True) + update_tasks.append(update_coro) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + for vacuum_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[vacuum_service].get( + 'schema', VACUUM_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, vacuum_service, async_service_handler, + description=descriptions.get(vacuum_service), schema=schema) + + +class MiroboVacuum(VacuumDevice): + """Representation of a Xiaomi Vacuum cleaner robot.""" + + def __init__(self, name, vacuum): + """Initialize the Xiaomi vacuum cleaner robot handler.""" + self._name = name + self._icon = ICON + self._vacuum = vacuum + + self.vacuum_state = None + self._is_on = False + self._available = False + + @property + def name(self): + """Return the name of the device.""" + return self._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.""" + if self.vacuum_state is not None: + return self.vacuum_state.state + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + if self.vacuum_state is not None: + return self.vacuum_state.battery + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + if self.vacuum_state is not None: + speed = self.vacuum_state.fanspeed + if speed in FAN_SPEEDS.values(): + return [key for key, value in FAN_SPEEDS.items() + if value == speed][0] + return speed + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + return list(sorted(FAN_SPEEDS.keys(), key=lambda s: FAN_SPEEDS[s])) + + @property + def device_state_attributes(self): + """Return the specific state attributes of this vacuum cleaner.""" + if self.vacuum_state is not None: + attrs = { + ATTR_DO_NOT_DISTURB: + STATE_ON if self.vacuum_state.dnd else STATE_OFF, + # Not working --> 'Cleaning mode': + # STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF, + ATTR_CLEANING_TIME: str(self.vacuum_state.clean_time), + ATTR_CLEANED_AREA: round(self.vacuum_state.clean_area, 2)} + if self.vacuum_state.got_error: + attrs[ATTR_ERROR] = self.vacuum_state.error + return attrs + + return {} + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._is_on + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_XIAOMI + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a vacuum command handling error messages.""" + from mirobo import VacuumException + try: + yield from self.hass.async_add_job(partial(func, *args, **kwargs)) + return True + except VacuumException as ex: + _LOGGER.error(mask_error, ex) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the vacuum on.""" + is_on = yield from self._try_command( + "Unable to start the vacuum: %s", self._vacuum.start) + self._is_on = is_on + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the vacuum off and return to home.""" + yield from self.async_stop() + return_home = yield from self.async_return_to_base() + if return_home: + self._is_on = False + + @asyncio.coroutine + def async_stop(self, **kwargs): + """Stop the vacuum cleaner.""" + yield from self._try_command( + "Unable to stop: %s", self._vacuum.stop) + + @asyncio.coroutine + def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if fan_speed.capitalize() in FAN_SPEEDS: + fan_speed = FAN_SPEEDS[fan_speed.capitalize()] + else: + try: + fan_speed = int(fan_speed) + except ValueError as exc: + _LOGGER.error("Fan speed step not recognized (%s). " + "Valid speeds are: %s", exc, + self.fan_speed_list) + return + yield from self._try_command( + "Unable to set fan speed: %s", + self._vacuum.set_fan_speed, fan_speed) + + @asyncio.coroutine + def async_start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self.vacuum_state and self.is_on: + yield from self._try_command( + "Unable to set start/pause: %s", self._vacuum.pause) + else: + yield from self.async_turn_on() + + @asyncio.coroutine + def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + return_home = yield from self._try_command( + "Unable to return home: %s", self._vacuum.home) + if return_home: + self._is_on = False + + @asyncio.coroutine + def async_locate(self, **kwargs): + """Locate the vacuum cleaner.""" + yield from self._try_command( + "Unable to locate the botvac: %s", self._vacuum.find) + + @asyncio.coroutine + def async_send_command(self, command, params=None, **kwargs): + """Send raw command.""" + yield from self._try_command( + "Unable to send command to the vacuum: %s", + self._vacuum.raw_command, command, params) + + @asyncio.coroutine + def async_remote_control_start(self): + """Start remote control mode.""" + yield from self._try_command( + "Unable to start remote control the vacuum: %s", + self._vacuum.manual_start) + + @asyncio.coroutine + def async_remote_control_stop(self): + """Stop remote control mode.""" + yield from self._try_command( + "Unable to stop remote control the vacuum: %s", + self._vacuum.manual_stop) + + @asyncio.coroutine + def async_remote_control_move(self, + rotation: int=0, + velocity: float=0.3, + duration: int=1500): + """Move vacuum with remote control mode.""" + yield from self._try_command( + "Unable to move with remote control the vacuum: %s", + self._vacuum.manual_control, + velocity=velocity, rotation=rotation, duration=duration) + + @asyncio.coroutine + def async_remote_control_move_step(self, + rotation: int=0, + velocity: float=0.2, + duration: int=1500): + """Move vacuum one step with remote control mode.""" + yield from self._try_command( + "Unable to remote control the vacuum: %s", + self._vacuum.manual_control_once, + velocity=velocity, rotation=rotation, duration=duration) + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + try: + state = yield from self.hass.async_add_job(self._vacuum.status) + + _LOGGER.debug("Got new state from the vacuum: %s", state.data) + self.vacuum_state = state + self._is_on = state.is_on + self._available = True + except DeviceException as ex: + _LOGGER.warning("Got exception while fetching the state: %s", ex) + # self._available = False + except OSError as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + # self._available = False diff --git a/homeassistant/const.py b/homeassistant/const.py index fadd85e6b72856a9dd7fb00da4420de26ae0b2b6..1fdf5b7d51ae831df6c5dd70e2a1de67c6787853 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -283,6 +283,9 @@ ATTR_WAKEUP = 'wake_up_interval' ATTR_CODE = 'code' ATTR_CODE_FORMAT = 'code_format' +# For calling a device specific command +ATTR_COMMAND = 'command' + # For devices which support an armed state ATTR_ARMED = 'device_armed' diff --git a/homeassistant/util/icon.py b/homeassistant/util/icon.py new file mode 100644 index 0000000000000000000000000000000000000000..dc8cce647127d98c061c4662739dbe7e9a0afd24 --- /dev/null +++ b/homeassistant/util/icon.py @@ -0,0 +1,18 @@ +"""Icon util methods.""" +from typing import Optional + + +def icon_for_battery_level(battery_level: Optional[int]=None, + charging: bool=False) -> str: + """Return a battery icon valid identifier.""" + icon = 'mdi:battery' + if battery_level is None: + return icon + '-unknown' + if charging and battery_level > 10: + icon += '-charging-{}'.format( + int(round(battery_level / 20 - .01)) * 20) + elif charging or battery_level <= 5: + icon += '-outline' + elif 5 < battery_level < 95: + icon += '-{}'.format(int(round(battery_level / 10 - .01)) * 10) + return icon diff --git a/requirements_all.txt b/requirements_all.txt index 4ea43745cf947425347be196c8761df328a91e69..d253a77e86d491f709bc4cc59e23fbc7e026d2c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -728,7 +728,7 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 -# homeassistant.components.switch.xiaomi_vacuum +# homeassistant.components.vacuum.xiaomi python-mirobo==0.1.2 # homeassistant.components.media_player.mpd diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b62949e6e8a669ab23a4afc621d2959d07e626fe --- /dev/null +++ b/tests/components/vacuum/__init__.py @@ -0,0 +1 @@ +"""The tests for vacuum platforms.""" diff --git a/tests/components/vacuum/test_demo.py b/tests/components/vacuum/test_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..445aa0a4e88356a61d050c6d343d2cbfc98ed417 --- /dev/null +++ b/tests/components/vacuum/test_demo.py @@ -0,0 +1,210 @@ +"""The tests for the Demo vacuum platform.""" +import unittest + +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, ATTR_FAN_SPEED, + ATTR_FAN_SPEED_LIST, ATTR_PARAMS, ATTR_STATUS, DOMAIN, + ENTITY_ID_ALL_VACUUMS, + SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED) +from homeassistant.components.vacuum.demo import ( + DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL, + DEMO_VACUUM_MOST, FAN_SPEEDS) +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF, STATE_ON) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, mock_service + + +ENTITY_VACUUM_BASIC = '{}.{}'.format(DOMAIN, DEMO_VACUUM_BASIC).lower() +ENTITY_VACUUM_COMPLETE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_COMPLETE).lower() +ENTITY_VACUUM_MINIMAL = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MINIMAL).lower() +ENTITY_VACUUM_MOST = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MOST).lower() + + +class TestVacuumDemo(unittest.TestCase): + """Test the Demo vacuum.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.assertTrue(setup_component( + self.hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: 'demo'}})) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + 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("Charging", state.attributes.get(ATTR_STATUS)) + self.assertEqual(100, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual("medium", state.attributes.get(ATTR_FAN_SPEED)) + self.assertListEqual(FAN_SPEEDS, + state.attributes.get(ATTR_FAN_SPEED_LIST)) + self.assertEqual(STATE_OFF, state.state) + + state = self.hass.states.get(ENTITY_VACUUM_MOST) + self.assertEqual(219, 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(None, state.attributes.get(ATTR_FAN_SPEED)) + self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED_LIST)) + self.assertEqual(STATE_OFF, state.state) + + state = self.hass.states.get(ENTITY_VACUUM_BASIC) + self.assertEqual(195, 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(None, state.attributes.get(ATTR_FAN_SPEED)) + self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED_LIST)) + self.assertEqual(STATE_OFF, state.state) + + state = self.hass.states.get(ENTITY_VACUUM_MINIMAL) + self.assertEqual(3, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertEqual(None, state.attributes.get(ATTR_STATUS)) + self.assertEqual(None, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED)) + self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED_LIST)) + self.assertEqual(STATE_OFF, state.state) + + def test_methods(self): + """Test if methods call the services as expected.""" + self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_ON) + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_BASIC)) + + self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_OFF) + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_BASIC)) + + self.hass.states.set(ENTITY_ID_ALL_VACUUMS, STATE_ON) + self.assertTrue(vacuum.is_on(self.hass)) + + self.hass.states.set(ENTITY_ID_ALL_VACUUMS, STATE_OFF) + self.assertFalse(vacuum.is_on(self.hass)) + + vacuum.turn_on(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.turn_off(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.toggle(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.start_pause(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.start_pause(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.stop(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + self.assertLess(state.attributes.get(ATTR_BATTERY_LEVEL), 100) + self.assertNotEqual("Charging", state.attributes.get(ATTR_STATUS)) + + vacuum.locate(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + self.assertIn("I'm over here", state.attributes.get(ATTR_STATUS)) + + vacuum.return_to_base(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + self.assertIn("Returning home", state.attributes.get(ATTR_STATUS)) + + vacuum.set_fan_speed(self.hass, FAN_SPEEDS[-1], + entity_id=ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + self.assertEqual(FAN_SPEEDS[-1], state.attributes.get(ATTR_FAN_SPEED)) + + def test_services(self): + """Test vacuum services.""" + # Test send_command + send_command_calls = mock_service( + self.hass, DOMAIN, SERVICE_SEND_COMMAND) + + params = {"rotate": 150, "speed": 20} + vacuum.send_command( + self.hass, 'test_command', entity_id=ENTITY_VACUUM_BASIC, + params=params) + + self.hass.block_till_done() + self.assertEqual(1, len(send_command_calls)) + call = send_command_calls[-1] + + self.assertEqual(DOMAIN, call.domain) + self.assertEqual(SERVICE_SEND_COMMAND, call.service) + self.assertEqual(ENTITY_VACUUM_BASIC, call.data[ATTR_ENTITY_ID]) + self.assertEqual('test_command', call.data[ATTR_COMMAND]) + self.assertEqual(params, call.data[ATTR_PARAMS]) + + # Test set fan speed + set_fan_speed_calls = mock_service( + self.hass, DOMAIN, SERVICE_SET_FAN_SPEED) + + vacuum.set_fan_speed( + self.hass, FAN_SPEEDS[0], entity_id=ENTITY_VACUUM_COMPLETE) + + self.hass.block_till_done() + self.assertEqual(1, len(set_fan_speed_calls)) + call = set_fan_speed_calls[-1] + + self.assertEqual(DOMAIN, call.domain) + self.assertEqual(SERVICE_SET_FAN_SPEED, call.service) + self.assertEqual(ENTITY_VACUUM_COMPLETE, call.data[ATTR_ENTITY_ID]) + self.assertEqual(FAN_SPEEDS[0], call.data[ATTR_FAN_SPEED]) + + def test_set_fan_speed(self): + """Test vacuum service to set the fan speed.""" + group_vacuums = ','.join([ENTITY_VACUUM_BASIC, + ENTITY_VACUUM_COMPLETE]) + old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) + old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + + vacuum.set_fan_speed( + self.hass, FAN_SPEEDS[0], entity_id=group_vacuums) + + self.hass.block_till_done() + new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) + new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + + self.assertEqual(old_state_basic, new_state_basic) + self.assertNotIn(ATTR_FAN_SPEED, new_state_basic.attributes) + + self.assertNotEqual(old_state_complete, new_state_complete) + self.assertEqual(FAN_SPEEDS[1], + old_state_complete.attributes[ATTR_FAN_SPEED]) + self.assertEqual(FAN_SPEEDS[0], + new_state_complete.attributes[ATTR_FAN_SPEED]) + + def test_send_command(self): + """Test vacuum service to send a command.""" + group_vacuums = ','.join([ENTITY_VACUUM_BASIC, + ENTITY_VACUUM_COMPLETE]) + old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) + old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + + vacuum.send_command( + self.hass, 'test_command', params={"p1": 3}, + entity_id=group_vacuums) + + self.hass.block_till_done() + new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) + new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + + self.assertEqual(old_state_basic, new_state_basic) + self.assertNotEqual(old_state_complete, new_state_complete) + self.assertEqual(STATE_ON, new_state_complete.state) + self.assertEqual("Executing test_command({'p1': 3})", + new_state_complete.attributes[ATTR_STATUS]) diff --git a/tests/components/vacuum/test_xiaomi.py b/tests/components/vacuum/test_xiaomi.py new file mode 100644 index 0000000000000000000000000000000000000000..5897b76a0be09e3a20d6ef5132b7d6cef1429526 --- /dev/null +++ b/tests/components/vacuum/test_xiaomi.py @@ -0,0 +1,154 @@ +"""The tests for the Xiaomi vacuum platform.""" +import asyncio +from datetime import timedelta +from unittest import mock + +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_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, + SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, + SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL) +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF, STATE_ON) +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_mirobo(): + """Mock mock_mirobo.""" + mock_vacuum = mock.MagicMock() + mock_vacuum.Vacuum().status().data = {'test': 'raw'} + mock_vacuum.Vacuum().status().is_on = False + mock_vacuum.Vacuum().status().fanspeed = 38 + mock_vacuum.Vacuum().status().got_error = False + mock_vacuum.Vacuum().status().dnd = True + mock_vacuum.Vacuum().status().battery = 82 + mock_vacuum.Vacuum().status().clean_area = 123.43218 + mock_vacuum.Vacuum().status().clean_time = timedelta( + hours=2, minutes=35, seconds=34) + mock_vacuum.Vacuum().status().state = 'Test Xiaomi Charging' + + with mock.patch.dict('sys.modules', { + 'mirobo': mock_vacuum, + }): + yield mock_vacuum + + +@asyncio.coroutine +def test_xiaomi_vacuum(hass, caplog, mock_mirobo): + """Test vacuum supported features.""" + entity_name = 'test_vacuum_cleaner' + entity_id = '{}.{}'.format(DOMAIN, entity_name) + + yield from async_setup_component( + hass, DOMAIN, + {DOMAIN: {CONF_PLATFORM: PLATFORM, + CONF_HOST: '127.0.0.1', + CONF_NAME: entity_name, + CONF_TOKEN: '12345678901234567890123456789012'}}) + + assert 'Initializing with host 127.0.0.1 (token 12345...)' in caplog.text + + # Check state attributes + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 1023 + assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_ON + assert state.attributes.get(ATTR_ERROR) is None + assert (state.attributes.get(ATTR_BATTERY_ICON) + == 'mdi:battery-charging-80') + assert state.attributes.get(ATTR_CLEANING_TIME) == '2:35:34' + assert state.attributes.get(ATTR_CLEANED_AREA) == 123.43 + assert state.attributes.get(ATTR_FAN_SPEED) == 'Quiet' + assert (state.attributes.get(ATTR_FAN_SPEED_LIST) + == ['Quiet', 'Balanced', 'Turbo', 'Max']) + + # Call services + yield from hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().start()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().home()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().start()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_STOP, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().stop()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().home()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_LOCATE, blocking=True) + assert str(mock_mirobo.mock_calls[-2]) == 'call.Vacuum().find()' + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": 60}, blocking=True) + assert (str(mock_mirobo.mock_calls[-2]) + == 'call.Vacuum().set_fan_speed(60)') + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_SEND_COMMAND, + {"command": "raw"}, blocking=True) + assert (str(mock_mirobo.mock_calls[-2]) + == "call.Vacuum().raw_command('raw', None)") + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_SEND_COMMAND, + {"command": "raw", "params": {"k1": 2}}, blocking=True) + assert (str(mock_mirobo.mock_calls[-2]) + == "call.Vacuum().raw_command('raw', {'k1': 2})") + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_START_REMOTE_CONTROL, {}, blocking=True) + assert (str(mock_mirobo.mock_calls[-2]) + == "call.Vacuum().manual_start()") + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_MOVE_REMOTE_CONTROL, + {"duration": 1000, "rotation": -40, "velocity": -0.1}, blocking=True) + assert 'call.Vacuum().manual_control(' in str(mock_mirobo.mock_calls[-2]) + assert 'duration=1000' in str(mock_mirobo.mock_calls[-2]) + assert 'rotation=-40' in str(mock_mirobo.mock_calls[-2]) + assert 'velocity=-0.1' in str(mock_mirobo.mock_calls[-2]) + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_STOP_REMOTE_CONTROL, {}, blocking=True) + assert (str(mock_mirobo.mock_calls[-2]) + == "call.Vacuum().manual_stop()") + assert str(mock_mirobo.mock_calls[-1]) == 'call.Vacuum().status()' + + yield from hass.services.async_call( + DOMAIN, SERVICE_MOVE_REMOTE_CONTROL_STEP, + {"duration": 2000, "rotation": 120, "velocity": 0.1}, blocking=True) + assert ('call.Vacuum().manual_control_once(' + in str(mock_mirobo.mock_calls[-2])) + assert 'duration=2000' in str(mock_mirobo.mock_calls[-2]) + assert 'rotation=120' in str(mock_mirobo.mock_calls[-2]) + assert 'velocity=0.1' in str(mock_mirobo.mock_calls[-2]) diff --git a/tests/util/test_icon.py b/tests/util/test_icon.py new file mode 100644 index 0000000000000000000000000000000000000000..2275fdcc6d36266f91f5011dccc709e9c2c84b92 --- /dev/null +++ b/tests/util/test_icon.py @@ -0,0 +1,53 @@ +"""Test Home Assistant icon util methods.""" +import unittest + + +class TestIconUtil(unittest.TestCase): + """Test icon util methods.""" + + def test_battery_icon(self): + """Test icon generator for battery sensor.""" + from homeassistant.util.icon import icon_for_battery_level + + self.assertEqual('mdi:battery-unknown', + icon_for_battery_level(None, True)) + self.assertEqual('mdi:battery-unknown', + icon_for_battery_level(None, False)) + + self.assertEqual('mdi:battery-outline', + icon_for_battery_level(5, True)) + self.assertEqual('mdi:battery-outline', + icon_for_battery_level(5, False)) + + self.assertEqual('mdi:battery-charging-100', + icon_for_battery_level(100, True)) + self.assertEqual('mdi:battery', + icon_for_battery_level(100, False)) + + iconbase = 'mdi:battery' + for level in range(0, 100, 5): + print('Level: %d. icon: %s, charging: %s' + % (level, icon_for_battery_level(level, False), + icon_for_battery_level(level, True))) + if level <= 10: + postfix_charging = '-outline' + elif level <= 30: + postfix_charging = '-charging-20' + elif level <= 50: + postfix_charging = '-charging-40' + elif level <= 70: + postfix_charging = '-charging-60' + elif level <= 90: + postfix_charging = '-charging-80' + else: + postfix_charging = '-charging-100' + if 5 < level < 95: + postfix = '-{}'.format(int(round(level / 10 - .01)) * 10) + elif level <= 5: + postfix = '-outline' + else: + postfix = '' + self.assertEqual(iconbase + postfix, + icon_for_battery_level(level, False)) + self.assertEqual(iconbase + postfix_charging, + icon_for_battery_level(level, True))