add custom component from main branch

This commit is contained in:
bittles
2023-01-02 18:22:04 -05:00
parent 33d864b3a5
commit 9a4481ae5f
8 changed files with 2867 additions and 136 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+36 -136
View File
@@ -1,152 +1,52 @@
Linux: [![Build Status](https://travis-ci.org/wpietri/sucks.svg?branch=master)](https://travis-ci.org/wpietri/sucks)
Windows: [![Build Status](https://ci.appveyor.com/api/projects/status/github/wpietri/sucks)](https://ci.appveyor.com/project/wpietri/sucks)
# Home Assistant Ecovacs Custom Component with Bumper Support
Replaces built in ecovacs component.
Works with bumper with my N79 and should work with at least other XMPP based ecovacs. Don't know if changes will work with MQTT based ones.
sucks
=====
Added additional catches to sucks because my N79 sends some weird payloads, but attributes all pull in now for brush life spans. Couple initial queries it also sends weird that I'm in process of catching atm. As of version 1.3.0 (in the manifest.json) these initial queries and all attributes are working. Was using an implementation completely mine but saw in the MQTT class there were already catches for child payloads without the main payload having the expected td in its payload. Kept comments in giving credit and adapted them to work with xmpp.
A simple command-line python script to drive a robot vacuum. Currently
known to work with the Ecovacs Deebot N79, M80 Pro, M81, M88
Pro, and R95 MKII from both North America and Europe.
With bumper and my N79 commands would work but some queries had responses that included errno='', which bumper would flag as an error even though the full response was there. If your debug logs are throwing errors and the errno is '' then my small fork of bumper may help https://github.com/bittles/bumper-fork
Does it work for your model as well? Join the discussion on the
[sucks-users mailing
list](https://groups.google.com/forum/#!forum/sucks-users).
Should work as regular if bumper isn't used in config but haven't tested yet, goal was to get it all local. Maybe mess around and test it in future.
If you're curious about the protocol, I have [a rough doc](http://github.com/wpietri/sucks/blob/master/protocol.md)
started. I'll happily accept pull requests for it.
I'm using the docker-compose example for bumper by bmartin5692, https://github.com/bmartin5692/bumper on an odroid-n2+.
Why the project name? Well, a) it's ridiculous that I needed to MITM
my own vacuum. This is not the future I signed up for. And b),
it's a vacuum.
## Installation
If you have a recent version of Python 3, you should be able to
do `pip install sucks` to get the most recently released version of
this.
## Usage
To get started, you'll need to have already set up an EcoVacs account
using your smartphone.
With that ready, step one is to log in:
For DNS routing I have an Asus AX88u with asus-merlin installed running Adguard. DNS rewrites in AdGuard for domains:
```
% sucks login
Ecovacs app email: [your email]
Ecovacs app password: [your password]
your two-letter country code: us
your two-letter continent code: na
Config saved.
*.ecouser.net
*.ecovacs.com
*.ecovacs.net
```
pointing to my bumper server.
That creates a config file in a platform-appropriate place. The password
is hashed before saving, so it's reasonably safe. (If it doesn't appear
to work for your continent, try "ww", their world-wide catchall.)
Big credits to bmartin5692 for his fork of sucks to base this off of as well.
With that set up, you could have it clean in auto mode for 10 minutes
and return to its charger:
## Home Assistant Install & Config
Drop the ecovacs folder into your custom_components folder. If I polish this up I'll add hacs support.
Restart HASS.
In your configuration.yaml:
```
% sucks clean 10
ecovacs:
username:
password:
country:
continent:
bumper: true/false (optional, defaults false)
bumper_server: (optional, defaults null)
verify_ssl: true/false, false if using bumper (optional, defaults true)
```
Any username, password, country, and continent should work if bumper is true. Set bumper_server to the ip_address where you're running bumper and set verify_ssl to false for bumper. If you're not using bumper this SHOULD technically work no different than the Home Assistant ecovacs integration but I haven't looked at it enough to be sure and I haven't tested it.
You could have it clean for 15 minutes and then do an extra 10 minutes
of edging:
### Example Config
```
% sucks clean 15 edge 10
ecovacs:
username: bumper
password: bumper
country: us
continent: na
bumper: true
bumper_server: "192.168.1.55"
verify_ssl: false
```
If you wanted it to clean for 5 minutes and then stop without charging:
```
% sucks clean 5 stop
```
If it's running amok and you'd just like it to stop where it is:
```
% sucks stop
```
To tell it to go plug in:
```
% sucks charge
```
I run mine from my crontab, but I didn't want it to clean every day,
so it also has a mode where it randomly decides to run or not based on
a frequency you give it. My crontab entry looks like this:
```
0 10 * * * /home/william/projects/sucks/sucks.sh clean -f 4/7 15 edge -f 1/14 10
```
This means that every day at 10 am, it might do something. 4 days out
of 7, it will do 15 minutes of automatic cleaning. 1 day out of 14,
it will do 10 minutes of edging. And afterward it will always go back to
charge.
## Library use
You are welcome to try using this as a python library for other efforts. The
API is still experimental, so expect changes. Please join the [mailing
list](https://groups.google.com/forum/#!forum/sucks-users) to participate in
shaping the API.
A simple usage might go something like this:
```python
from sucks import *
config = ...
api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'],
config['country'], config['continent'])
my_vac = api.devices()[0]
vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, my_vac, config['continent'])
vacbot.connect_and_wait_until_ready()
vacbot.run(Clean()) # start cleaning
time.sleep(900) # clean for 15 minutes
vacbot.run(Charge()) # return to the charger
```
## Developing
If you'd like to join in on developing, I recommend checking out the code,
setting up a virtual environment, and installing this package in editable
mode. You can confirm your environment works by running the tests. And please
do join the [mailing list](https://groups.google.com/forum/#!forum/sucks-users)
to discuss your plans.
For more information see [the development documentation](developing.md).
## See also
There are now similar libraries in [Javascript](https://github.com/joostth/sucks.js)
and [Go](https://github.com/skburgart/go-vacbot).
## Thanks
My heartfelt thanks to:
* [xmpppeek](https://www.beneaththewaves.net/Software/XMPPPeek.html),
a great library for examining XMPP traffic flows (yes, your vacuum
speaks Jabbber!),
* [mitmproxy](https://mitmproxy.org/), a fantastic tool for analyzing HTTPS,
* [click](http://click.pocoo.org/), a wonderfully complete and thoughtful
library for making Python command-line interfaces,
* [requests](http://docs.python-requests.org/en/master/), a polished Python
library for HTTP requests,
* [Decompilers online](http://www.javadecompilers.com/apk), which was
very helpful in figuring out what the Android app was up to,
* Albert Louw, who was kind enough to post code from [his own
experiments](https://community.smartthings.com/t/ecovacs-deebot-n79/93410/33)
with his device, and
* All the users who have given useful feedback and contributed code!
Just finished getting this working late 12/13/22 so not sure if everything works yet but will commit changes here if I update it or at least document issues.
+119
View File
@@ -0,0 +1,119 @@
"""Support for Ecovacs Deebot vacuums."""
import logging
import random
import string
##import asyncio ## to do
#just included the modified sucks in component
from .sucksbumper import EcoVacsAPI, VacBot
import voluptuous as vol
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL, # added
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "ecovacs"
CONF_COUNTRY = "country"
CONF_CONTINENT = "continent"
#bumper config vars
CONF_BUMPER = "bumper"
CONF_BUMPER_SERVER = "bumper_server"
server_address = None
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),
vol.Optional(CONF_BUMPER, default=False): cv.boolean,
vol.Optional(CONF_BUMPER_SERVER): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, # can probably get rid of this and set verify ssl false if bumper true
}
)
},
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: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Ecovacs component."""
_LOGGER.debug("Creating new Ecovacs component")
hass.data[ECOVACS_DEVICES] = []
# if we're using bumper then define the server address
if CONF_BUMPER == True:
server_address = (config[DOMAIN].get(CONF_BUMPER_SERVER), 5223)
# if not make sure it's null
else:
server_address = None
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),
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
)
devices = ecovacs_api.devices()
_LOGGER.debug("Ecobot devices: %s", devices)
for device in devices:
_LOGGER.info(
"Discovered Ecovacs device on account: %s with nickname %s",
device.get("did"),
device.get("nick"),
)
vacbot = VacBot(
ecovacs_api.uid,
ecovacs_api.REALM,
ecovacs_api.resource,
ecovacs_api.user_access_token,
device,
config[DOMAIN].get(CONF_CONTINENT).lower(),
server_address, # include server address in class, if it's null shoul be no effect
config[DOMAIN].get(CONF_VERIFY_SSL), # verify ssl or not
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.get("did"),
)
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, Platform.VACUUM, DOMAIN, {}, config)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "ecovacs",
"name": "Ecovacs Bumper",
"version": "1.3.0",
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"requirements": ["sleekxmppfs==1.4.1", "click>=6", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"],
"codeowners": ["@OverloadUT", "@mib1185"],
"iot_class": "local_polling",
"loggers": ["sleekxmppfs", "sucksbumper"]
}
+102
View File
@@ -0,0 +1,102 @@
# Describes the format for available vacuum services
turn_on:
name: Turn on
description: Start a new cleaning task.
target:
entity:
domain: vacuum
turn_off:
name: Turn off
description: Stop the current cleaning task and return to home.
target:
entity:
domain: vacuum
stop:
name: Stop
description: Stop the current cleaning task.
target:
entity:
domain: vacuum
locate:
name: Locate
description: Locate the vacuum cleaner robot.
target:
entity:
domain: vacuum
start_pause:
name: Start/Pause
description: Start, pause, or resume the cleaning task.
target:
entity:
domain: vacuum
start:
name: Start
description: Start or resume the cleaning task.
target:
entity:
domain: vacuum
pause:
name: Pause
description: Pause the cleaning task.
target:
entity:
domain: vacuum
return_to_base:
name: Return to base
description: Tell the vacuum cleaner to return to its dock.
target:
entity:
domain: vacuum
clean_spot:
name: Clean spot
description: Tell the vacuum cleaner to do a spot clean-up.
target:
entity:
domain: vacuum
send_command:
name: Send command
description: Send a raw command to the vacuum cleaner.
target:
entity:
domain: vacuum
fields:
command:
name: Command
description: Command to execute.
required: true
example: "set_dnd_timer"
selector:
text:
params:
name: Parameters
description: Parameters for the command.
example: '{ "key": "value" }'
selector:
object:
set_fan_speed:
name: Set fan speed
description: Set the fan speed of the vacuum cleaner.
target:
entity:
domain: vacuum
fields:
fan_speed:
name: Fan speed
description:
Platform dependent vacuum cleaner fan speed, with speed steps, like
'medium' or by percentage, between 0 and 100.
required: true
example: "low"
selector:
text:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
"""Support for Ecovacs Ecovacs Vacuums."""
from __future__ import annotations
import logging
from typing import Any
#sucks
from . import sucksbumper
from homeassistant.components.vacuum import VacuumEntity, VacuumEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import ECOVACS_DEVICES
_LOGGER = logging.getLogger(__name__)
ATTR_ERROR = "error"
ATTR_COMPONENT_PREFIX = "component_"
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Ecovacs vacuums."""
vacuums = []
for device in hass.data[ECOVACS_DEVICES]:
vacuums.append(EcovacsVacuum(device))
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
add_entities(vacuums, True)
class EcovacsVacuum(VacuumEntity):
"""Ecovacs Vacuums such as Deebot."""
_attr_fan_speed_list = [sucksbumper.FAN_SPEED_NORMAL, sucksbumper.FAN_SPEED_HIGH]
_attr_should_poll = False
_attr_supported_features = (
VacuumEntityFeature.BATTERY
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.STOP
| VacuumEntityFeature.TURN_OFF
| VacuumEntityFeature.TURN_ON
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATUS
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.FAN_SPEED
)
def __init__(self, device: sucksbumper.VacBot) -> None:
"""Initialize the Ecovacs Vacuum."""
self.device = device
self.device.connect_and_wait_until_ready()
if self.device.vacuum.get("nick") is not None:
self._attr_name = str(self.device.vacuum["nick"])
else:
# In case there is no nickname defined, use the device id
self._attr_name = str(format(self.device.vacuum["did"]))
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 unique_id(self) -> str:
"""Return an unique ID."""
return self.device.vacuum.get("did")
@property
def is_on(self) -> bool:
"""Return true if vacuum is currently cleaning."""
return self.device.is_cleaning
@property
def is_charging(self) -> bool:
"""Return true if vacuum is currently charging."""
return self.device.is_charging
@property
def status(self) -> str | None:
"""Return the status of the vacuum cleaner."""
return self.device.vacuum_status
def return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
self.device.run(sucksbumper.Charge())
@property
def battery_icon(self) -> str:
"""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) -> int | None:
"""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) -> str | None:
"""Return the fan speed of the vacuum cleaner."""
return self.device.fan_speed
def turn_on(self, **kwargs: Any) -> None:
"""Turn the vacuum on and start cleaning."""
self.device.run(sucksbumper.Clean())
def turn_off(self, **kwargs: Any) -> None:
"""Turn the vacuum off stopping the cleaning and returning home."""
self.return_to_base()
def stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
self.device.run(sucksbumper.Stop())
def clean_spot(self, **kwargs: Any) -> None:
"""Perform a spot clean-up."""
self.device.run(sucksbumper.Spot())
def locate(self, **kwargs: Any) -> None:
"""Locate the vacuum cleaner."""
self.device.run(sucksbumper.PlaySound())
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
if self.is_on:
self.device.run(sucksbumper.Clean(mode=self.device.clean_status, speed=fan_speed))
def send_command(
self,
command: str,
params: dict[str, Any] | list[Any] | None = None,
**kwargs: Any,
) -> None:
"""Send a command to a vacuum cleaner."""
self.device.run(sucksbumper.VacBotCommand(command, params))
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device-specific state attributes of this vacuum."""
data: dict[str, Any] = {}
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)
return data