Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e56266be86 | |||
| 651058bea1 | |||
| db7743bc0e | |||
| 87f6a47374 | |||
| 43bdf849c9 | |||
| 29b36a3529 | |||
| a0881719cb | |||
| db2c4ec880 | |||
| 7feb48c9cc | |||
| 475ba5bdb3 | |||
| 5abdbf858f | |||
| 52b4fa4ee0 | |||
| 17b041074c | |||
| 4fb79b7186 | |||
| 1decadee2e | |||
| 6ef44ddb9b | |||
| 724e167114 | |||
| cf817145a0 | |||
| 066cc5bf7b | |||
| aea75338f6 | |||
| cd4c8069bc | |||
| 43a9cef3b7 | |||
| 42356582cc | |||
| 0d95ef7fac | |||
| a9bd4d2d48 | |||
| f9899bbf0d | |||
| c6a11b47b6 | |||
| cbac201d0d | |||
| 8d7aa5c582 | |||
| 0a2c296ef2 | |||
| d4afaceb62 |
@@ -1,16 +1,16 @@
|
|||||||
# Home Assistant Ecovacs Custom Component with Bumper Support
|
# Home Assistant Ecovacs Custom Component with Bumper Support
|
||||||
Replaces built in ecovacs component. Commit history is a bit of a mess. master branch shows changes from bmartins fork of sucks to v1.3.0 of this custom component. dev branch shows commits from my attempts at testing and getting this to work.
|
Replaces built in ecovacs component. Designed to work with bumper, https://github.com/bmartin5692/bumper, a replacement for Ecovacs servers to truly get local control.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
Should work as regular if bumper is false in config but haven't tested yet, goal was to get it all local. Maybe mess around and test it in future.
|
||||||
|
|
||||||
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
|
With bumper, 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. It never created attributes for the filters as one of the results. 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
|
||||||
|
|
||||||
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.
|
Based off the regular home assistant ecovacs config and bmartin's fork of sucks, https://github.com/bmartin5692/sucks.
|
||||||
|
I'm using the docker-compose example from my bumper forked from bmartin5692's, https://github.com/bmartin5692/bumper on an odroid-n2+.
|
||||||
I'm using the docker-compose example for bumper by bmartin5692, https://github.com/bmartin5692/bumper on an odroid-n2+.
|
|
||||||
|
|
||||||
|
### DNS
|
||||||
For DNS routing I have an Asus AX88u with asus-merlin installed running Adguard. DNS rewrites in AdGuard for domains:
|
For DNS routing I have an Asus AX88u with asus-merlin installed running Adguard. DNS rewrites in AdGuard for domains:
|
||||||
```
|
```
|
||||||
*.ecouser.net
|
*.ecouser.net
|
||||||
@@ -19,12 +19,20 @@ For DNS routing I have an Asus AX88u with asus-merlin installed running Adguard.
|
|||||||
```
|
```
|
||||||
pointing to my bumper server.
|
pointing to my bumper server.
|
||||||
|
|
||||||
Big credits to bmartin5692 for his fork of sucks to base this off of as well.
|
|
||||||
|
|
||||||
## Home Assistant Install & Config
|
## Home Assistant Install & Config
|
||||||
Drop the ecovacs folder into your custom_components folder. If I polish this up I'll add hacs support.
|
### HACS Install
|
||||||
|
You can add this repository to your HACS: https://github.com/bittles/ha_ecovacs_bumper
|
||||||
|
|
||||||
|
Then download with HACS, HACS -> Integrations -> Explore & Download Repositories -> EcovacsBumper
|
||||||
|
|
||||||
Restart HASS.
|
Restart HASS.
|
||||||
|
|
||||||
|
### Manually Install
|
||||||
|
Drop the ecovacs folder into your custom_components folder.
|
||||||
|
|
||||||
|
Restart HASS.
|
||||||
|
|
||||||
|
### Config
|
||||||
In your configuration.yaml:
|
In your configuration.yaml:
|
||||||
```
|
```
|
||||||
ecovacs:
|
ecovacs:
|
||||||
@@ -32,11 +40,9 @@ ecovacs:
|
|||||||
password:
|
password:
|
||||||
country:
|
country:
|
||||||
continent:
|
continent:
|
||||||
bumper: true/false (optional, defaults false)
|
verify_ssl: true/false, use false if using bumper (optional, defaults true)
|
||||||
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.
|
Any username, password, country, and continent should work if using bumper. 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.
|
||||||
|
|
||||||
### Example Config
|
### Example Config
|
||||||
```
|
```
|
||||||
@@ -45,8 +51,21 @@ ecovacs:
|
|||||||
password: bumper
|
password: bumper
|
||||||
country: us
|
country: us
|
||||||
continent: na
|
continent: na
|
||||||
bumper: true
|
|
||||||
bumper_server: "192.168.1.55"
|
|
||||||
verify_ssl: false
|
verify_ssl: false
|
||||||
```
|
```
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
```
|
||||||
|
logger:
|
||||||
|
logs:
|
||||||
|
custom_components.ecovacs.sucksbumper: debug # or whatever level you want
|
||||||
|
```
|
||||||
|
|
||||||
|
### To-Do:
|
||||||
|
Make component async, use config_flow, create device and clean up some of the hass integration stuff.
|
||||||
|
|
||||||
|
### Misc Info From Making This
|
||||||
|
Commit history is a bit of a mess. master branch shows changes from bmartins fork of sucks to v1.3.0 of this custom component. dev branch shows commits from my attempts at testing and getting this to work.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
"""Support for Ecovacs Deebot vacuums."""
|
"""Support for Ecovacs Deebot vacuums."""
|
||||||
import logging
|
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
#import asyncio ## to do will need to convert to slixmpp to do this i believe
|
||||||
##import asyncio ## to do
|
|
||||||
|
|
||||||
|
|
||||||
#just included the modified sucks in component
|
|
||||||
from .sucksbumper import EcoVacsAPI, VacBot
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
CONF_VERIFY_SSL, # added
|
CONF_PASSWORD,
|
||||||
|
CONF_COUNTRY,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
@@ -21,17 +15,16 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
import voluptuous as vol
|
||||||
|
#just included the modified sucks in component
|
||||||
|
from .sucksbumper import EcoVacsAPI, VacBot
|
||||||
|
from .const import (
|
||||||
|
ECOVACS_DEVICES,
|
||||||
|
DOMAIN,
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
CONF_CONTINENT,
|
||||||
|
LOGGER
|
||||||
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(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -41,17 +34,13 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Required(CONF_PASSWORD): cv.string,
|
vol.Required(CONF_PASSWORD): cv.string,
|
||||||
vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
|
vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
|
||||||
vol.Required(CONF_CONTINENT): 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_VERIFY_SSL, default=True): cv.boolean, # can probably get rid of this and set verify ssl false if
|
||||||
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,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
ECOVACS_DEVICES = "ecovacs_devices"
|
|
||||||
|
|
||||||
# Generate a random device ID on each bootup
|
# Generate a random device ID on each bootup
|
||||||
ECOVACS_API_DEVICEID = "".join(
|
ECOVACS_API_DEVICEID = "".join(
|
||||||
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
||||||
@@ -59,15 +48,9 @@ ECOVACS_API_DEVICEID = "".join(
|
|||||||
|
|
||||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Ecovacs component."""
|
"""Set up the Ecovacs component."""
|
||||||
_LOGGER.debug("Creating new Ecovacs component")
|
LOGGER.debug("Creating new Ecovacs component")
|
||||||
|
|
||||||
hass.data[ECOVACS_DEVICES] = []
|
hass.data[ECOVACS_DEVICES] = []
|
||||||
# if we're using bumper then define the server address
|
SERVER_ADDRESS = None
|
||||||
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 = EcoVacsAPI(
|
||||||
ECOVACS_API_DEVICEID,
|
ECOVACS_API_DEVICEID,
|
||||||
@@ -79,10 +62,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
devices = ecovacs_api.devices()
|
devices = ecovacs_api.devices()
|
||||||
_LOGGER.debug("Ecobot devices: %s", devices)
|
LOGGER.debug("Ecobot devices: %s", devices)
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
_LOGGER.info(
|
LOGGER.info(
|
||||||
"Discovered Ecovacs device on account: %s with nickname %s",
|
"Discovered Ecovacs device on account: %s with nickname %s",
|
||||||
device.get("did"),
|
device.get("did"),
|
||||||
device.get("nick"),
|
device.get("nick"),
|
||||||
@@ -94,16 +77,16 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
ecovacs_api.user_access_token,
|
ecovacs_api.user_access_token,
|
||||||
device,
|
device,
|
||||||
config[DOMAIN].get(CONF_CONTINENT).lower(),
|
config[DOMAIN].get(CONF_CONTINENT).lower(),
|
||||||
server_address, # include server address in class, if it's null shoul be no effect
|
SERVER_ADDRESS, # include server address in class, if it's null should be no effect
|
||||||
config[DOMAIN].get(CONF_VERIFY_SSL), # verify ssl or not
|
config[DOMAIN].get(CONF_VERIFY_SSL), # add to class call
|
||||||
monitor=True,
|
monitor=True
|
||||||
)
|
)
|
||||||
hass.data[ECOVACS_DEVICES].append(vacbot)
|
hass.data[ECOVACS_DEVICES].append(vacbot)
|
||||||
|
|
||||||
def stop(event: object) -> None:
|
def stop(event: object) -> None:
|
||||||
"""Shut down open connections to Ecovacs XMPP server."""
|
"""Shut down open connections to Ecovacs XMPP server."""
|
||||||
for device in hass.data[ECOVACS_DEVICES]:
|
for device in hass.data[ECOVACS_DEVICES]:
|
||||||
_LOGGER.info(
|
LOGGER.info(
|
||||||
"Shutting down connection to Ecovacs device %s",
|
"Shutting down connection to Ecovacs device %s",
|
||||||
device.vacuum.get("did"),
|
device.vacuum.get("did"),
|
||||||
)
|
)
|
||||||
@@ -111,9 +94,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
# Listen for HA stop to disconnect.
|
# Listen for HA stop to disconnect.
|
||||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
|
||||||
|
|
||||||
if hass.data[ECOVACS_DEVICES]:
|
if hass.data[ECOVACS_DEVICES]:
|
||||||
_LOGGER.debug("Starting vacuum components")
|
LOGGER.debug("Starting vacuum components")
|
||||||
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import logging
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#ecovacs constants
|
||||||
|
#init constants
|
||||||
|
ECOVACS_DEVICES = "ecovacs_devices"
|
||||||
|
DOMAIN = "ecovacs"
|
||||||
|
CONF_CONTINENT = "continent"
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "ecovacs",
|
"domain": "ecovacs",
|
||||||
"name": "Ecovacs Bumper",
|
"name": "Ecovacs Bumper",
|
||||||
"version": "1.3.0",
|
"version": "1.3.7",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
"documentation": "https://github.com/bittles/ha_ecovacs_bumper",
|
||||||
"requirements": ["sleekxmppfs==1.4.1", "click>=6", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"],
|
"issue_tracker": "https://github.com/bittles/ha_ecovacs_bumper/issues",
|
||||||
"codeowners": ["@OverloadUT", "@mib1185"],
|
"requirements": ["sleekxmppfs==1.4.1", "requests>=2.18", "pycryptodome>=3.4", "pycountry-convert>=0.5", "paho-mqtt>=1.4", "stringcase>=1.2"],
|
||||||
|
"codeowners": ["bittles"],
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["sleekxmppfs", "sucksbumper"]
|
"loggers": ["sleekxmppfs", "ecovacs"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
import sched
|
||||||
|
import threading
|
||||||
|
import ssl
|
||||||
|
import requests
|
||||||
|
import stringcase
|
||||||
|
from threading import Event
|
||||||
|
from paho.mqtt.client import Client as ClientMQTT
|
||||||
|
from paho.mqtt import publish as MQTTPublish
|
||||||
|
from paho.mqtt import subscribe as MQTTSubscribe
|
||||||
|
from sleekxmppfs.xmlstream import ET
|
||||||
|
|
||||||
|
from .const import LOGGER
|
||||||
|
|
||||||
|
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
||||||
|
def RepresentsInt(stringvar):
|
||||||
|
try:
|
||||||
|
int(stringvar)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
class EcoVacsIOTMQ(ClientMQTT):
|
||||||
|
def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None, verify_ssl=True):
|
||||||
|
ClientMQTT.__init__(self)
|
||||||
|
self.ctl_subscribers = []
|
||||||
|
self.user = user
|
||||||
|
self.domain = str(domain).split(".")[0] #MQTT is using domain without tld extension
|
||||||
|
self.resource = resource
|
||||||
|
self.secret = secret
|
||||||
|
self.continent = continent
|
||||||
|
self.vacuum = vacuum
|
||||||
|
self.scheduler = sched.scheduler(time.time, time.sleep)
|
||||||
|
self.scheduler_thread = threading.Thread(target=self.scheduler.run, daemon=True, name="mqtt_schedule_thread")
|
||||||
|
self.verify_ssl = str_to_bool_or_cert(verify_ssl)
|
||||||
|
if server_address is None:
|
||||||
|
self.hostname = ('mq-{}.ecouser.net'.format(self.continent))
|
||||||
|
self.port = 8883
|
||||||
|
else:
|
||||||
|
saddress = server_address.split(":")
|
||||||
|
if len(saddress) > 1:
|
||||||
|
self.hostname = saddress[0]
|
||||||
|
if RepresentsInt(saddress[1]):
|
||||||
|
self.port = int(saddress[1])
|
||||||
|
else:
|
||||||
|
self.port = 8883
|
||||||
|
self._client_id = self.user + '@' + self.domain.split(".")[0] + '/' + self.resource
|
||||||
|
self.username_pw_set(self.user + '@' + self.domain, secret)
|
||||||
|
self.ready_flag = Event()
|
||||||
|
|
||||||
|
def connect_and_wait_until_ready(self):
|
||||||
|
#self._on_log = self.on_log #This provides more logging than needed, even for debug
|
||||||
|
self._on_message = self._handle_ctl_mqtt
|
||||||
|
self._on_connect = self.on_connect
|
||||||
|
#TODO: This is pretty insecure and accepts any cert, maybe actually check?
|
||||||
|
ssl_ctx = ssl.create_default_context()
|
||||||
|
ssl_ctx.check_hostname = False
|
||||||
|
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
self.tls_set_context(ssl_ctx)
|
||||||
|
self.tls_insecure_set(True)
|
||||||
|
self.connect(self.hostname, self.port)
|
||||||
|
self.loop_start()
|
||||||
|
self.wait_until_ready()
|
||||||
|
|
||||||
|
def subscribe_to_ctls(self, function):
|
||||||
|
self.ctl_subscribers.append(function)
|
||||||
|
|
||||||
|
def _disconnect(self):
|
||||||
|
self.disconnect() #disconnect mqtt connection
|
||||||
|
self.scheduler.empty() #Clear schedule queue
|
||||||
|
|
||||||
|
def _run_scheduled_func(self, timer_seconds, timer_function):
|
||||||
|
timer_function()
|
||||||
|
self.schedule(timer_seconds, timer_function)
|
||||||
|
|
||||||
|
def schedule(self, timer_seconds, timer_function):
|
||||||
|
self.scheduler.enter(timer_seconds, 1, self._run_scheduled_func,(timer_seconds, timer_function))
|
||||||
|
if not self.scheduler_thread.isAlive():
|
||||||
|
self.scheduler_thread.start()
|
||||||
|
|
||||||
|
def wait_until_ready(self):
|
||||||
|
self.ready_flag.wait()
|
||||||
|
|
||||||
|
def on_connect(self, client, userdata, flags, rc):
|
||||||
|
if rc != 0:
|
||||||
|
LOGGER.error("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
||||||
|
raise RuntimeError("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc))
|
||||||
|
else:
|
||||||
|
LOGGER.debug("EcoVacsMQTT - Connected with result code "+str(rc))
|
||||||
|
LOGGER.debug("EcoVacsMQTT - Subscribing to all")
|
||||||
|
self.subscribe('iot/atr/+/' + self.vacuum['did'] + '/' + self.vacuum['class'] + '/' + self.vacuum['resource'] + '/+', qos=0)
|
||||||
|
self.ready_flag.set()
|
||||||
|
|
||||||
|
#def on_log(self, client, userdata, level, buf): #This is very noisy and verbose
|
||||||
|
# LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf))
|
||||||
|
|
||||||
|
def send_ping(self):
|
||||||
|
LOGGER.debug("*** MQTT sending ping ***")
|
||||||
|
rc = self._send_simple_command(MQTTPublish.paho.PINGREQ)
|
||||||
|
if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_command(self, action, recipient):
|
||||||
|
if action.name == "Clean": #For handling Clean when action not specified (i.e. CLI)
|
||||||
|
action.args['clean']['act'] = CLEAN_ACTION_TO_ECOVACS['start'] #Inject a start action
|
||||||
|
c = self._wrap_command(action, recipient)
|
||||||
|
LOGGER.debug('Sending command {0}'.format(c))
|
||||||
|
self._handle_ctl_api(action,
|
||||||
|
self.__call_iotdevmanager_api(c ,verify_ssl=self.verify_ssl )
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wrap_command(self, cmd, recipient):
|
||||||
|
#Remove the td from ctl xml for RestAPI
|
||||||
|
payloadxml = cmd.to_xml()
|
||||||
|
payloadxml.attrib.pop("td")
|
||||||
|
return {
|
||||||
|
'auth': {
|
||||||
|
'realm': EcoVacsAPI.REALM,
|
||||||
|
'resource': self.resource,
|
||||||
|
'token': self.secret,
|
||||||
|
'userid': self.user,
|
||||||
|
'with': 'users',
|
||||||
|
},
|
||||||
|
"cmdName": cmd.name,
|
||||||
|
"payload": ET.tostring(payloadxml).decode(),
|
||||||
|
|
||||||
|
"payloadType": "x",
|
||||||
|
"td": "q",
|
||||||
|
"toId": recipient,
|
||||||
|
"toRes": self.vacuum['resource'],
|
||||||
|
"toType": self.vacuum['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
def __call_iotdevmanager_api(self, args, verify_ssl=True):
|
||||||
|
LOGGER.debug("calling iotdevmanager api with {}".format(args))
|
||||||
|
params = {}
|
||||||
|
params.update(args)
|
||||||
|
url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=self.continent)
|
||||||
|
response = None
|
||||||
|
try: #The RestAPI sometimes doesnt provide a response depending on command, reduce timeout to 3 to accomodate and make requests faster
|
||||||
|
response = requests.post(url, json=params, timeout=3, verify=verify_ssl) #May think about having timeout as an arg that could be provided in the future
|
||||||
|
except requests.exceptions.ReadTimeout:
|
||||||
|
LOGGER.debug("call to iotdevmanager failed with ReadTimeout")
|
||||||
|
return {}
|
||||||
|
json = response.json()
|
||||||
|
if json['ret'] == 'ok':
|
||||||
|
return json
|
||||||
|
elif json['ret'] == 'fail':
|
||||||
|
if 'debug' in json:
|
||||||
|
if json['debug'] == 'wait for response timed out':
|
||||||
|
#TODO - Maybe handle timeout for IOT better in the future
|
||||||
|
LOGGER.error("call to iotdevmanager failed with {}".format(json))
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
#TODO - Not sure if we want to raise an error yet, just return empty for now
|
||||||
|
LOGGER.error("call to iotdevmanager failed with {}".format(json))
|
||||||
|
return {}
|
||||||
|
#raise RuntimeError(
|
||||||
|
#"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params))
|
||||||
|
|
||||||
|
def _handle_ctl_api(self, action, message):
|
||||||
|
if not message == {}:
|
||||||
|
resp = self._ctl_to_dict_api(action, message['resp'])
|
||||||
|
if resp is not None:
|
||||||
|
for s in self.ctl_subscribers:
|
||||||
|
s(resp)
|
||||||
|
|
||||||
|
def _ctl_to_dict_api(self, action, xmlstring):
|
||||||
|
xml = ET.fromstring(xmlstring)
|
||||||
|
xmlchild = xml.getchildren()
|
||||||
|
if len(xmlchild) > 0:
|
||||||
|
result = xmlchild[0].attrib.copy()
|
||||||
|
#Fix for difference in XMPP vs API response
|
||||||
|
#Depending on the report will use the tag and add "report" to fit the mold of sucks library
|
||||||
|
if xmlchild[0].tag == "clean":
|
||||||
|
result['event'] = "CleanReport"
|
||||||
|
elif xmlchild[0].tag == "charge":
|
||||||
|
result['event'] = "ChargeState"
|
||||||
|
elif xmlchild[0].tag == "battery":
|
||||||
|
result['event'] = "BatteryInfo"
|
||||||
|
else: #Default back to replacing Get from the api cmdName
|
||||||
|
result['event'] = action.name.replace("Get","",1)
|
||||||
|
else:
|
||||||
|
result = xml.attrib.copy()
|
||||||
|
result['event'] = action.name.replace("Get","",1)
|
||||||
|
if 'ret' in result: #Handle errors as needed
|
||||||
|
if result['ret'] == 'fail':
|
||||||
|
if action.name == "Charge": #So far only seen this with Charge, when already docked
|
||||||
|
result['event'] = "ChargeState"
|
||||||
|
for key in result:
|
||||||
|
if not RepresentsInt(result[key]): #Fix to handle negative int values
|
||||||
|
result[key] = stringcase.snakecase(result[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _handle_ctl_mqtt(self, client, userdata, message):
|
||||||
|
#LOGGER.debug("EcoVacs MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8"))))
|
||||||
|
as_dict = self._ctl_to_dict_mqtt(message.topic, str(message.payload.decode("utf-8")))
|
||||||
|
if as_dict is not None:
|
||||||
|
for s in self.ctl_subscribers:
|
||||||
|
s(as_dict)
|
||||||
|
|
||||||
|
def _ctl_to_dict_mqtt(self, topic, xmlstring):
|
||||||
|
#I haven't seen the need to fall back to data within the topic (like we do with IOT rest call actions), but it is here in case of future need
|
||||||
|
xml = ET.fromstring(xmlstring) #Convert from string to xml (like IOT rest calls), other than this it is similar to XMPP
|
||||||
|
#Including changes from jasonarends @ 28da7c2 below
|
||||||
|
result = xml.attrib.copy()
|
||||||
|
if 'td' not in result:
|
||||||
|
# This happens for commands with no response data, such as PlaySound
|
||||||
|
# Handle response data with no 'td'
|
||||||
|
if 'type' in result: # single element with type and val
|
||||||
|
result['event'] = "LifeSpan" # seems to always be LifeSpan type
|
||||||
|
else:
|
||||||
|
if len(xml) > 0: # case where there is child element
|
||||||
|
if 'clean' in xml[0].tag:
|
||||||
|
result['event'] = "CleanReport"
|
||||||
|
elif 'charge' in xml[0].tag:
|
||||||
|
result['event'] = "ChargeState"
|
||||||
|
elif 'battery' in xml[0].tag:
|
||||||
|
result['event'] = "BatteryInfo"
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
result.update(xml[0].attrib)
|
||||||
|
else: # for non-'type' result with no child element, e.g., result of PlaySound
|
||||||
|
return
|
||||||
|
else: # response includes 'td'
|
||||||
|
result['event'] = result.pop('td')
|
||||||
|
if xml:
|
||||||
|
result.update(xml[0].attrib)
|
||||||
|
for key in result:
|
||||||
|
#Check for RepresentInt to handle negative int values, and ',' for ignoring position updates
|
||||||
|
if not RepresentsInt(result[key]) and ',' not in result[key]:
|
||||||
|
result[key] = stringcase.snakecase(result[key])
|
||||||
|
return result
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#sucks constants
|
||||||
|
# These consts define all of the vocabulary used by this library when presenting various states and components.
|
||||||
|
# Applications implementing this library should import these rather than hard-code the strings, for future-proofing.
|
||||||
|
|
||||||
|
CLEAN_MODE_AUTO = 'auto'
|
||||||
|
CLEAN_MODE_EDGE = 'edge'
|
||||||
|
CLEAN_MODE_SPOT = 'spot'
|
||||||
|
CLEAN_MODE_SPOT_AREA = 'spot_area'
|
||||||
|
CLEAN_MODE_SINGLE_ROOM = 'single_room'
|
||||||
|
CLEAN_MODE_STOP = 'stop'
|
||||||
|
|
||||||
|
CLEAN_ACTION_START = 'start'
|
||||||
|
CLEAN_ACTION_PAUSE = 'pause'
|
||||||
|
CLEAN_ACTION_RESUME = 'resume'
|
||||||
|
CLEAN_ACTION_STOP = 'stop'
|
||||||
|
|
||||||
|
FAN_SPEED_NORMAL = 'normal'
|
||||||
|
FAN_SPEED_HIGH = 'high'
|
||||||
|
|
||||||
|
CHARGE_MODE_RETURN = 'return'
|
||||||
|
CHARGE_MODE_RETURNING = 'returning'
|
||||||
|
CHARGE_MODE_CHARGING = 'charging'
|
||||||
|
CHARGE_MODE_IDLE = 'idle'
|
||||||
|
|
||||||
|
COMPONENT_SIDE_BRUSH = 'side_brush'
|
||||||
|
COMPONENT_MAIN_BRUSH = 'main_brush'
|
||||||
|
COMPONENT_FILTER = 'filter'
|
||||||
|
|
||||||
|
VACUUM_STATUS_OFFLINE = 'offline'
|
||||||
|
|
||||||
|
CLEANING_STATES = {CLEAN_MODE_AUTO, CLEAN_MODE_EDGE, CLEAN_MODE_SPOT, CLEAN_MODE_SPOT_AREA, CLEAN_MODE_SINGLE_ROOM}
|
||||||
|
CHARGING_STATES = {CHARGE_MODE_CHARGING}
|
||||||
|
|
||||||
|
# These dictionaries convert to and from Sucks's consts (which closely match what the UI and manuals use)
|
||||||
|
# to and from what the Ecovacs API uses (which are sometimes very oddly named and have random capitalization.)
|
||||||
|
CLEAN_MODE_TO_ECOVACS = {
|
||||||
|
CLEAN_MODE_AUTO: 'auto',
|
||||||
|
CLEAN_MODE_EDGE: 'border',
|
||||||
|
CLEAN_MODE_SPOT: 'spot',
|
||||||
|
CLEAN_MODE_SPOT_AREA: 'SpotArea',
|
||||||
|
CLEAN_MODE_SINGLE_ROOM: 'singleroom',
|
||||||
|
CLEAN_MODE_STOP: 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
CLEAN_ACTION_TO_ECOVACS = {
|
||||||
|
CLEAN_ACTION_START: 's',
|
||||||
|
CLEAN_ACTION_PAUSE: 'p',
|
||||||
|
CLEAN_ACTION_RESUME: 'r',
|
||||||
|
CLEAN_ACTION_STOP: 'h',
|
||||||
|
}
|
||||||
|
|
||||||
|
CLEAN_ACTION_FROM_ECOVACS = {
|
||||||
|
's': CLEAN_ACTION_START,
|
||||||
|
'p': CLEAN_ACTION_PAUSE,
|
||||||
|
'r': CLEAN_ACTION_RESUME,
|
||||||
|
'h': CLEAN_ACTION_STOP,
|
||||||
|
}
|
||||||
|
|
||||||
|
CLEAN_MODE_FROM_ECOVACS = {
|
||||||
|
'auto': CLEAN_MODE_AUTO,
|
||||||
|
'border': CLEAN_MODE_EDGE,
|
||||||
|
'spot': CLEAN_MODE_SPOT,
|
||||||
|
'spot_area': CLEAN_MODE_SPOT_AREA,
|
||||||
|
'SpotArea': CLEAN_MODE_SPOT_AREA,
|
||||||
|
'singleroom': CLEAN_MODE_SINGLE_ROOM,
|
||||||
|
'stop': CLEAN_MODE_STOP,
|
||||||
|
'going': CHARGE_MODE_RETURNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
FAN_SPEED_TO_ECOVACS = {
|
||||||
|
FAN_SPEED_NORMAL: 'standard',
|
||||||
|
FAN_SPEED_HIGH: 'strong'
|
||||||
|
}
|
||||||
|
|
||||||
|
FAN_SPEED_FROM_ECOVACS = {
|
||||||
|
'standard': FAN_SPEED_NORMAL,
|
||||||
|
'strong': FAN_SPEED_HIGH,
|
||||||
|
}
|
||||||
|
|
||||||
|
CHARGE_MODE_TO_ECOVACS = {
|
||||||
|
CHARGE_MODE_RETURN: 'go',
|
||||||
|
CHARGE_MODE_RETURNING: 'Going',
|
||||||
|
CHARGE_MODE_CHARGING: 'SlotCharging',
|
||||||
|
CHARGE_MODE_IDLE: 'Idle',
|
||||||
|
}
|
||||||
|
|
||||||
|
CHARGE_MODE_FROM_ECOVACS = {
|
||||||
|
'going': CHARGE_MODE_RETURNING,
|
||||||
|
# 'Going': CHARGE_MODE_RETURNING,
|
||||||
|
'slot_charging': CHARGE_MODE_CHARGING,
|
||||||
|
# 'SlotCharging': CHARGE_MODE_CHARGING,
|
||||||
|
'idle': CHARGE_MODE_IDLE,
|
||||||
|
# 'Idle': CHARGE_MODE_IDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
COMPONENT_TO_ECOVACS = {
|
||||||
|
COMPONENT_MAIN_BRUSH: 'Brush',
|
||||||
|
COMPONENT_SIDE_BRUSH: 'SideBrush',
|
||||||
|
COMPONENT_FILTER: 'DustCaseHeap',
|
||||||
|
}
|
||||||
|
|
||||||
|
COMPONENT_FROM_ECOVACS = {
|
||||||
|
'brush': COMPONENT_MAIN_BRUSH,
|
||||||
|
# 'Brush': COMPONENT_MAIN_BRUSH,
|
||||||
|
'side_brush': COMPONENT_SIDE_BRUSH,
|
||||||
|
# 'SideBrush': COMPONENT_SIDE_BRUSH,
|
||||||
|
'dust_case_heap': COMPONENT_FILTER,
|
||||||
|
# 'DustCaseHeap': COMPONENT_FILTER,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
|||||||
|
import stringcase
|
||||||
|
import random
|
||||||
|
from threading import Event
|
||||||
|
from sleekxmppfs import ClientXMPP, Callback, MatchXPath
|
||||||
|
from sleekxmppfs.xmlstream import ET
|
||||||
|
#from sleekxmppfs.exceptions import XMPPError
|
||||||
|
from .const import LOGGER
|
||||||
|
|
||||||
|
#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict
|
||||||
|
def RepresentsInt(stringvar):
|
||||||
|
try:
|
||||||
|
int(stringvar)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
class EcoVacsXMPP(ClientXMPP):
|
||||||
|
def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ):
|
||||||
|
ClientXMPP.__init__(self, "{}@{}/{}".format(user, domain,resource), '0/' + resource + '/' + secret) #Init with resource to bind it
|
||||||
|
self.user = user
|
||||||
|
self.domain = domain
|
||||||
|
self.boundjid.resource = resource
|
||||||
|
self.continent = continent
|
||||||
|
self.vacuum = vacuum
|
||||||
|
self.credentials['authzid'] = user
|
||||||
|
if server_address is None:
|
||||||
|
self.server_address = ('msg-{}.ecouser.net'.format(self.continent), '5223')
|
||||||
|
else:
|
||||||
|
self.server_address = server_address
|
||||||
|
self.add_event_handler("session_start", self.session_start)
|
||||||
|
self.ctl_subscribers = []
|
||||||
|
self.ready_flag = Event()
|
||||||
|
|
||||||
|
def wait_until_ready(self):
|
||||||
|
self.ready_flag.wait()
|
||||||
|
|
||||||
|
def session_start(self, event):
|
||||||
|
LOGGER.debug("----------------- starting session ----------------")
|
||||||
|
LOGGER.debug("event = {}".format(event))
|
||||||
|
self.register_handler(Callback("general",
|
||||||
|
MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}'),
|
||||||
|
self._handle_ctl))
|
||||||
|
# register a ping handler, not really needed but keeps from errors being thrown
|
||||||
|
self.register_handler(Callback("Ping",
|
||||||
|
MatchXPath('{jabber:client}iq/{urn:xmpp:ping}ping/{urn:xmpp:ping}'),
|
||||||
|
self._handle_ping))
|
||||||
|
self.ready_flag.set()
|
||||||
|
|
||||||
|
def subscribe_to_ctls(self, function):
|
||||||
|
self.ctl_subscribers.append(function)
|
||||||
|
|
||||||
|
def _handle_ctl(self, message):
|
||||||
|
the_good_part = message.get_payload()[0][0]
|
||||||
|
as_dict = self._ctl_to_dict(the_good_part)
|
||||||
|
if as_dict is not None:
|
||||||
|
for s in self.ctl_subscribers:
|
||||||
|
s(as_dict)
|
||||||
|
|
||||||
|
def _ctl_to_dict(self, xml):
|
||||||
|
#Including changes from jasonarends @ 28da7c2 below
|
||||||
|
result = xml.attrib.copy()
|
||||||
|
childxml = None
|
||||||
|
try: # check for child xml
|
||||||
|
childxml = xml[0]
|
||||||
|
except IndexError:
|
||||||
|
LOGGER.debug("No child xml")
|
||||||
|
if 'td' not in result:
|
||||||
|
# Handle response data with no 'td'
|
||||||
|
if 'type' in result: # single element with type and val
|
||||||
|
result['event'] = "LifeSpan" # seems to always be LifeSpan type
|
||||||
|
else:
|
||||||
|
if childxml is not None:
|
||||||
|
if 'clean' in childxml.tag:
|
||||||
|
result['event'] = "CleanReport"
|
||||||
|
elif 'charge' in childxml.tag:
|
||||||
|
result['event'] = "ChargeState"
|
||||||
|
elif 'battery' in childxml.tag:
|
||||||
|
result['event'] = "BatteryInfo"
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
result.update(childxml.attrib)
|
||||||
|
else: # for non-'type' result with no child element, e.g., result of PlaySound
|
||||||
|
return
|
||||||
|
else: # response includes 'td'
|
||||||
|
result['event'] = result.pop('td')
|
||||||
|
if xml:
|
||||||
|
result.update(xml[0].attrib) # reponses with td seem to always have child component
|
||||||
|
for key in result:
|
||||||
|
#Check for RepresentInt to handle negative int values, and ',' for ignoring position updates
|
||||||
|
if not RepresentsInt(result[key]) and ',' not in result[key]:
|
||||||
|
result[key] = stringcase.snakecase(result[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_callback(self, userdata, message):
|
||||||
|
self.register_handler(Callback(kind,
|
||||||
|
MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'),
|
||||||
|
function))
|
||||||
|
|
||||||
|
def send_command(self, xml, recipient):
|
||||||
|
c = self._wrap_command(xml, recipient)
|
||||||
|
LOGGER.debug('Sending command {0}'.format(c))
|
||||||
|
c.send()
|
||||||
|
|
||||||
|
def _wrap_command(self, ctl, recipient):
|
||||||
|
q = self.make_iq_query(xmlns=u'com:ctl', ito=recipient, ifrom=self._my_address())
|
||||||
|
q['type'] = 'set'
|
||||||
|
if not "id" in ctl.attrib:
|
||||||
|
ctl.attrib["id"] = self.getReqID() #If no ctl id provided, add an id to the ctl. This was required for the ozmo930 and shouldn't hurt others
|
||||||
|
for child in q.xml:
|
||||||
|
if child.tag.endswith('query'):
|
||||||
|
child.append(ctl)
|
||||||
|
return q
|
||||||
|
|
||||||
|
def getReqID(self, customid="0"): #Generate a somewhat random string for request id, with minium 8 chars. Works similar to ecovacs app.
|
||||||
|
if customid != "0":
|
||||||
|
return "{}".format(customid) #return provided id as string
|
||||||
|
else:
|
||||||
|
rtnval = str(random.randint(1,50))
|
||||||
|
while len(str(rtnval)) <= 8:
|
||||||
|
rtnval = "{}{}".format(rtnval,random.randint(0,50))
|
||||||
|
return "{}".format(rtnval) #return as string
|
||||||
|
|
||||||
|
def _my_address(self):
|
||||||
|
if not self.vacuum['iotmq']:
|
||||||
|
return self.user + '@' + self.domain + '/' + self.boundjid.resource
|
||||||
|
else:
|
||||||
|
return self.user + '@' + self.domain + '/' + self.resource
|
||||||
|
|
||||||
|
def send_ping(self, to):
|
||||||
|
q = self.make_iq_get(ito=to, ifrom=self._my_address())
|
||||||
|
q.xml.append(ET.Element('ping', {'xmlns': 'urn:xmpp:ping'}))
|
||||||
|
LOGGER.debug("*** sending ping ***")
|
||||||
|
q.send()
|
||||||
|
|
||||||
|
# used some code from a sleekxmppfs plugin, seems to work fine
|
||||||
|
def _handle_ping(self, iq):
|
||||||
|
LOGGER.debug("Pinged by %s", iq['from'])
|
||||||
|
iq.reply().send()
|
||||||
|
|
||||||
|
def connect_and_wait_until_ready(self):
|
||||||
|
self.connect(self.server_address)
|
||||||
|
self.process()
|
||||||
|
self.wait_until_ready()
|
||||||
Reference in New Issue
Block a user