Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ecovacs component #15520

Merged
merged 19 commits into from Aug 20, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
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 …
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
@@ -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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason you went for configuration via configuration.yaml and not using our shiny new config entry?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know what, I created the bulk of this component 9 months ago and when I came back to it I didn't think to convert it. I'll read up on the config entry docs and see about converting it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hit me up on Discord, would love to help you with this. Hue is a good (although maybe a bit complicated) example.

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),

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

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
@@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I/O should be avoided in __init__ as it blocks the event loop, better connect inside update or before adding the device.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case it doesn't block the event loop since we instantiate the entity in setup_platform which is run in the thread pool.

But I like to avoid I/O in init on a general basis anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the preferred implementation to be to connect to the device in setup_platform and then pass the already-connected device object in to the __init__? That's an easy change; just want to confirm that's the best practice here.

Copy link
Contributor

@trisk trisk Jul 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If connect_and_wait_until_ready() only depends on the API being available, moving it to setup_platform is fine. If the call fails if a vacuum is not available when HA starts, it would be better to allow it to be brought on-line asynchronously in EcovacsVacuum.update. You can use add_devices(..., update_before_add=True) if important attributes like the name are not available until connected.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a flaw in the vacuum ABC if you need to implement this yourself. Your implementation seems like how it should be?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It definitely is an issue with the ABC having no formal support for determining if the bot is charging. The reason I chose not to fix the ABC here is because the proper fix is the rearchitecture discussed here: home-assistant/architecture#29

I plan on updating this component after/alongside @cnrd makes the foundational changes

"""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
Expand Up @@ -1295,6 +1295,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