Skip to content

Commit

Permalink
Add ecovacs component (#15520)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
OverloadUT authored and balloob committed Aug 20, 2018
1 parent 1be61df commit df6239e
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions homeassistant/components/ecovacs.py
Original file line number Diff line number Diff line change
@@ -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
198 changes: 198 additions & 0 deletions homeassistant/components/vacuum/ecovacs.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit df6239e

Please sign in to comment.