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 13 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
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
95 changes: 95 additions & 0 deletions homeassistant/components/ecovacs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""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.0']
Copy link
Member

Choose a reason for hiding this comment

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

🤣


_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): cv.string,
vol.Required(CONF_CONTINENT): 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.info("Creating new Ecovacs component")
Copy link
Member

Choose a reason for hiding this comment

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

debug


if ECOVACS_DEVICES not in hass.data:
Copy link
Member

Choose a reason for hiding this comment

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

Components are only setup once, no need for this if-statement.

hass.data[ECOVACS_DEVICES] = []

from sucks import EcoVacsAPI, VacBot

# Convenient hack for debugging to pipe sucks logging to the Hass logger
if _LOGGER.getEffectiveLevel() <= logging.DEBUG:
import sucks
sucks.logging = _LOGGER
Copy link
Member

Choose a reason for hiding this comment

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

It would be good if the sucks library could be updated to use a logger per module, as is recommended.

https://docs.python.org/3/library/logging.html#logger-objects

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, you're right. Done.


ecovacs_api = EcoVacsAPI(ECOVACS_API_DEVICEID,
config[DOMAIN].get(CONF_USERNAME),
EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)),
config[DOMAIN].get(CONF_COUNTRY).lower(),
Copy link
Member

Choose a reason for hiding this comment

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

Move the lowercase modification to the config schema and use vol.Lower().

config[DOMAIN].get(CONF_CONTINENT).lower())

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)

# pylint: disable=unused-argument
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, "sensor", DOMAIN, {}, config)
Copy link
Member

Choose a reason for hiding this comment

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

Comment?

discovery.load_platform(hass, "vacuum", DOMAIN, {}, config)

return True
222 changes: 222 additions & 0 deletions homeassistant/components/vacuum/ecovacs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""
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)

ECOVACS_FAN_SPEED_LIST = ['normal', 'high']

# These consts represent bot statuses that can come from the `sucks` library
Copy link
Member

Choose a reason for hiding this comment

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

Would we be able to get them from the sucks lib directly instead of defining them? sucks.STATUS_AUTO etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In doing this, I actually decided to move the is_cleaning and is_charging logic in to the sucks lib which removed the need to define or import these at all!

STATUS_AUTO = 'auto'
STATUS_EDGE = 'edge'
STATUS_SPOT = 'spot'
STATUS_SINGLE_ROOM = 'single_room'
STATUS_STOP = 'stop'
STATUS_RETURNING = 'returning'
STATUS_CHARGING = 'charging'
STATUS_IDLE = 'idle'
STATUS_ERROR = 'error'

# Any status that represents active cleaning
STATUSES_CLEANING = [STATUS_AUTO, STATUS_EDGE, STATUS_SPOT, STATUS_SINGLE_ROOM]
Copy link
Member

Choose a reason for hiding this comment

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

Make it a set for faster lookups 👍

# Any status that represents sitting on the charger
STATUSES_CHARGING = [STATUS_CHARGING, STATUS_IDLE]

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:
"""d."""
Copy link
Member

Choose a reason for hiding this comment

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

?

# Fire off some queries to get initial state
self.device.statusEvents.subscribe(self.on_status)
Copy link
Member

Choose a reason for hiding this comment

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

Since all you do is call schedule_update_ha_state, why not use lambdas?

self.device.statusEvents.subscribe(lambda _: self.schedule_update_ha_state())

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, that's better. Each of those event handlers used to do more, but I moved the logic to the sucks library since their creation. I'll make this change.

self.device.batteryEvents.subscribe(self.on_battery)
self.device.errorEvents.subscribe(self.on_error)
self.device.lifespanEvents.subscribe(self.on_lifespan)

def on_status(self, status):
"""Handle the status of the robot changing."""
self.schedule_update_ha_state()

def on_battery(self, battery_level):
"""Handle the battery level changing on the robot."""
self.schedule_update_ha_state()

def on_lifespan(self, lifespan):
"""Handle component lifespan reports from the robot."""
self.schedule_update_ha_state()

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.vacuum_status in STATUSES_CLEANING

@property
def is_charging(self):
"""Return true if vacuum is currently charging."""
return self.device.vacuum_status in STATUSES_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."""
return ECOVACS_FAN_SPEED_LIST

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 state_attributes(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 should be device_state_attributes. These will be merged with state attributes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks; I hadn't realized about that method!

"""Return the state attributes of the vacuum cleaner."""
data = super().state_attributes
Copy link
Member

Choose a reason for hiding this comment

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

This isn't needed.


data[ATTR_ERROR] = self._error

for key, val in self.device.components.items():
Copy link
Member

Choose a reason for hiding this comment

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

It's not clear to me what values are added to the state machine. Meaning it can't be documented nor can it be judged if they are appropriate to be stored as state attributes to begin with.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, these are attributes representing the lifespan of replaceable components on the vacuum. Today, they are hard coded in the lib to be main_brush, side_brush, and filter. (In Hass the attributes will be component_main_brush, etc.) The value is a 0-100 integer representing the percentage of lifespan remaining. I made it dynamic here because those three components may not be present on all vacuum models, so as we get feedback and make sucks more comprehensive for all vacuum models, this should appropriately populate based on what the vacuum actually has.

(I plan on documenting all of this in the docs once I implement the config entries)

attr_name = ATTR_COMPONENT_PREFIX + key
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 @@ -1295,6 +1295,9 @@ statsd==3.2.1
# homeassistant.components.sensor.steam_online
steamodd==4.21

# homeassistant.components.ecovacs
sucks==0.9.0

# homeassistant.components.camera.onvif
suds-passworddigest-homeassistant==0.1.2a0.dev0

Expand Down