From df6239e0fc3d36a56f1892482835ab2c2d2299f2 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Mon, 20 Aug 2018 08:42:53 -0700 Subject: [PATCH] Add ecovacs component (#15520) * Ecovacs Deebot vacuums * All core features implemented Getting fan speed and locating the vac are still unsupported until sucks adds support * Move init queries to the added_to_hass method * Adding support for subscribing to events from the sucks library This support does not exist in sucks yet; this commit serves as a sort of TDD approach of what such support COULD look like. * Add OverloadUT as ecovacs code owner * Full support for Ecovacs vacuums (Deebot) * Add requirements * Linting fixes * Make API Device ID random on each boot * Fix unique ID Never worked before, as it should have been looking for a key, not an attribute * Fix random string generation to work in Python 3.5 (thanks, Travis!) * Add new files to .coveragerc * Code review changes (Will require a sucks version bump in a coming commit; waiting for it to release) * Bump sucks to 0.9.1 now that it has released * Update requirements_all.txt as well * Bump sucks version to fix lifespan value errors * Revert to sucks 0.9.1 and include a fix for a bug in that release Sucks is being slow to release currently, so doing this so we can get a version out the door. * Switch state_attributes to device_state_attributes --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/ecovacs.py | 87 +++++++++ homeassistant/components/vacuum/ecovacs.py | 198 +++++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 293 insertions(+) create mode 100644 homeassistant/components/ecovacs.py create mode 100644 homeassistant/components/vacuum/ecovacs.py diff --git a/.coveragerc b/.coveragerc index de1d84634770c2..989830e5c9d00c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -104,6 +104,9 @@ omit = homeassistant/components/fritzbox.py homeassistant/components/switch/fritzbox.py + homeassistant/components/ecovacs.py + homeassistant/components/*/ecovacs.py + homeassistant/components/eufy.py homeassistant/components/*/eufy.py diff --git a/CODEOWNERS b/CODEOWNERS index 53f577d02ebe80..c756cb383d473b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -87,6 +87,8 @@ homeassistant/components/*/axis.py @kane610 homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/*/deconz.py @kane610 +homeassistant/components/ecovacs.py @OverloadUT +homeassistant/components/*/ecovacs.py @OverloadUT homeassistant/components/eight_sleep.py @mezz64 homeassistant/components/*/eight_sleep.py @mezz64 homeassistant/components/hive.py @Rendili @KJonline diff --git a/homeassistant/components/ecovacs.py b/homeassistant/components/ecovacs.py new file mode 100644 index 00000000000000..2e51b048d15e43 --- /dev/null +++ b/homeassistant/components/ecovacs.py @@ -0,0 +1,87 @@ +"""Parent component for Ecovacs Deebot vacuums. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/ecovacs/ +""" + +import logging +import random +import string + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, \ + EVENT_HOMEASSISTANT_STOP + +REQUIREMENTS = ['sucks==0.9.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ecovacs" + +CONF_COUNTRY = "country" +CONF_CONTINENT = "continent" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), + vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + }) +}, extra=vol.ALLOW_EXTRA) + +ECOVACS_DEVICES = "ecovacs_devices" + +# Generate a random device ID on each bootup +ECOVACS_API_DEVICEID = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) +) + + +def setup(hass, config): + """Set up the Ecovacs component.""" + _LOGGER.debug("Creating new Ecovacs component") + + hass.data[ECOVACS_DEVICES] = [] + + from sucks import EcoVacsAPI, VacBot + + ecovacs_api = EcoVacsAPI(ECOVACS_API_DEVICEID, + config[DOMAIN].get(CONF_USERNAME), + EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), + config[DOMAIN].get(CONF_COUNTRY), + config[DOMAIN].get(CONF_CONTINENT)) + + devices = ecovacs_api.devices() + _LOGGER.debug("Ecobot devices: %s", devices) + + for device in devices: + _LOGGER.info("Discovered Ecovacs device on account: %s", + device['nick']) + vacbot = VacBot(ecovacs_api.uid, + ecovacs_api.REALM, + ecovacs_api.resource, + ecovacs_api.user_access_token, + device, + config[DOMAIN].get(CONF_CONTINENT).lower(), + monitor=True) + hass.data[ECOVACS_DEVICES].append(vacbot) + + def stop(event: object) -> None: + """Shut down open connections to Ecovacs XMPP server.""" + for device in hass.data[ECOVACS_DEVICES]: + _LOGGER.info("Shutting down connection to Ecovacs device %s", + device.vacuum['nick']) + device.disconnect() + + # Listen for HA stop to disconnect. + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) + + if hass.data[ECOVACS_DEVICES]: + _LOGGER.debug("Starting vacuum components") + discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/vacuum/ecovacs.py b/homeassistant/components/vacuum/ecovacs.py new file mode 100644 index 00000000000000..e0870a4886124b --- /dev/null +++ b/homeassistant/components/vacuum/ecovacs.py @@ -0,0 +1,198 @@ +""" +Support for Ecovacs Ecovacs Vaccums. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/vacuum.neato/ +""" +import logging + +from homeassistant.components.vacuum import ( + VacuumDevice, SUPPORT_BATTERY, SUPPORT_RETURN_HOME, SUPPORT_CLEAN_SPOT, + SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_LOCATE, SUPPORT_FAN_SPEED, SUPPORT_SEND_COMMAND, ) +from homeassistant.components.ecovacs import ( + ECOVACS_DEVICES) +from homeassistant.helpers.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ecovacs'] + +SUPPORT_ECOVACS = ( + SUPPORT_BATTERY | SUPPORT_RETURN_HOME | SUPPORT_CLEAN_SPOT | + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | SUPPORT_LOCATE | + SUPPORT_STATUS | SUPPORT_SEND_COMMAND | SUPPORT_FAN_SPEED) + +ATTR_ERROR = 'error' +ATTR_COMPONENT_PREFIX = 'component_' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ecovacs vacuums.""" + vacuums = [] + for device in hass.data[ECOVACS_DEVICES]: + vacuums.append(EcovacsVacuum(device)) + _LOGGER.debug("Adding Ecovacs Vacuums to Hass: %s", vacuums) + add_devices(vacuums, True) + + +class EcovacsVacuum(VacuumDevice): + """Ecovacs Vacuums such as Deebot.""" + + def __init__(self, device): + """Initialize the Ecovacs Vacuum.""" + self.device = device + self.device.connect_and_wait_until_ready() + try: + self._name = '{}'.format(self.device.vacuum['nick']) + except KeyError: + # In case there is no nickname defined, use the device id + self._name = '{}'.format(self.device.vacuum['did']) + + self._fan_speed = None + self._error = None + _LOGGER.debug("Vacuum initialized: %s", self.name) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self.device.statusEvents.subscribe(lambda _: + self.schedule_update_ha_state()) + self.device.batteryEvents.subscribe(lambda _: + self.schedule_update_ha_state()) + self.device.lifespanEvents.subscribe(lambda _: + self.schedule_update_ha_state()) + self.device.errorEvents.subscribe(self.on_error) + + def on_error(self, error): + """Handle an error event from the robot. + + This will not change the entity's state. If the error caused the state + to change, that will come through as a separate on_status event + """ + if error == 'no_error': + self._error = None + else: + self._error = error + + self.hass.bus.fire('ecovacs_error', { + 'entity_id': self.entity_id, + 'error': error + }) + self.schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self.device.vacuum.get('did', None) + + @property + def is_on(self): + """Return true if vacuum is currently cleaning.""" + return self.device.is_cleaning + + @property + def is_charging(self): + """Return true if vacuum is currently charging.""" + return self.device.is_charging + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_ECOVACS + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return self.device.vacuum_status + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + from sucks import Charge + self.device.run(Charge()) + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + return icon_for_battery_level( + battery_level=self.battery_level, charging=self.is_charging) + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + if self.device.battery_status is not None: + return self.device.battery_status * 100 + + return super().battery_level + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + return self.device.fan_speed + + @property + def fan_speed_list(self): + """Get the list of available fan speed steps of the vacuum cleaner.""" + from sucks import FAN_SPEED_NORMAL, FAN_SPEED_HIGH + return [FAN_SPEED_NORMAL, FAN_SPEED_HIGH] + + def turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + from sucks import Clean + self.device.run(Clean()) + + def turn_off(self, **kwargs): + """Turn the vacuum off stopping the cleaning and returning home.""" + self.return_to_base() + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + from sucks import Stop + self.device.run(Stop()) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + from sucks import Spot + self.device.run(Spot()) + + def locate(self, **kwargs): + """Locate the vacuum cleaner.""" + from sucks import PlaySound + self.device.run(PlaySound()) + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if self.is_on: + from sucks import Clean + self.device.run(Clean( + mode=self.device.clean_status, speed=fan_speed)) + + def send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + from sucks import VacBotCommand + self.device.run(VacBotCommand(command, params)) + + @property + def device_state_attributes(self): + """Return the device-specific state attributes of this vacuum.""" + data = {} + data[ATTR_ERROR] = self._error + + for key, val in self.device.components.items(): + attr_name = ATTR_COMPONENT_PREFIX + key + data[attr_name] = int(val * 100 / 0.2777778) + # The above calculation includes a fix for a bug in sucks 0.9.1 + # When sucks 0.9.2+ is released, it should be changed to the + # following: + # data[attr_name] = int(val * 100) + + return data diff --git a/requirements_all.txt b/requirements_all.txt index 2c0bb46a73ff01..468076fc4e83b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1339,6 +1339,9 @@ statsd==3.2.1 # homeassistant.components.sensor.steam_online steamodd==4.21 +# homeassistant.components.ecovacs +sucks==0.9.1 + # homeassistant.components.camera.onvif suds-passworddigest-homeassistant==0.1.2a0.dev0