Compare commits

..

31 Commits

Author SHA1 Message Date
bittles e56266be86 Merge pull request #3 from bittles/dev
Dev
2023-01-03 23:02:16 -05:00
bittles 651058bea1 update docs for simpler config 2023-01-03 23:01:43 -05:00
bittles db7743bc0e typo 2023-01-03 22:57:30 -05:00
bittles 87f6a47374 simplify config 2023-01-03 22:54:32 -05:00
bittles 43bdf849c9 move server address and verify ssl out of const 2023-01-03 22:39:20 -05:00
bittles 29b36a3529 Update __init__.py 2023-01-03 22:31:45 -05:00
bittles a0881719cb Update __init__.py 2023-01-03 22:29:13 -05:00
bittles db2c4ec880 optimizations
split out sucks constants to their own file, no need to define verify ssl.  if bumper is true then verify ssl false
2023-01-03 22:21:14 -05:00
bittles 7feb48c9cc logging levels, version bump
set some more appropriate levels in logging
2023-01-03 21:47:07 -05:00
bittles 475ba5bdb3 rename logger 2023-01-03 21:32:02 -05:00
bittles 5abdbf858f rename logger
to define in const
2023-01-03 21:16:42 -05:00
bittles 52b4fa4ee0 import fixes 2023-01-03 21:06:17 -05:00
bittles 17b041074c code refactoring
split xmpp and mqtt vacuums to their own modules
2023-01-03 20:29:44 -05:00
bittles 4fb79b7186 Use boundjid.resource fulljid property deprecated. 2023-01-03 18:00:02 -05:00
bittles 1decadee2e Merge pull request #2 from bittles/dev
mostly code cleanup
2023-01-03 14:15:03 -05:00
bittles 6ef44ddb9b add check for childxml to avoid errors
lot of code cleanup and getting rid of trailing whitespace, try to make it a little more uniform.  document some changes in comments
2023-01-03 14:09:44 -05:00
bittles 724e167114 change ping schedule from 30s to 300s
decrease calls to robot, see if this is contributing to battery use
2023-01-02 22:56:34 -05:00
bittles cf817145a0 more code cleanup, no need to handle differences if server_address defined or not
mustve fixed init at some point, whatever, it works
2023-01-02 22:54:48 -05:00
bittles 066cc5bf7b code cleanup and more searching 2023-01-02 22:41:04 -05:00
bittles aea75338f6 some init logging to catch whether bumper or sucks is handling pings 2023-01-02 22:28:22 -05:00
bittles cd4c8069bc typo 2023-01-02 22:18:08 -05:00
bittles 43a9cef3b7 try ping? unsure how to register handlers 2023-01-02 22:14:28 -05:00
bittles 42356582cc remove what i think are some unneeded changes 2023-01-02 20:19:37 -05:00
bittles 0d95ef7fac Merge pull request #1 from bittles/master
change xmpp ping from 30 to 300s to not spam robot, add ifs for mqtt …
2023-01-02 19:55:04 -05:00
bittles a9bd4d2d48 change xmpp ping from 30 to 300s to not spam robot, add ifs for mqtt or xmpp robot 2023-01-02 19:54:11 -05:00
bittles f9899bbf0d Delete sucksbumper.py.save 2023-01-02 19:32:51 -05:00
bittles c6a11b47b6 more hacs stuff 2023-01-02 19:27:03 -05:00
bittles cbac201d0d Update manifest.json 2023-01-02 19:20:58 -05:00
bittles 8d7aa5c582 add to do 2023-01-02 19:14:31 -05:00
bittles 0a2c296ef2 formatting 2023-01-02 19:12:44 -05:00
bittles d4afaceb62 add hacs support, bump version number, update readme 2023-01-02 19:11:56 -05:00
10 changed files with 647 additions and 1961 deletions
+35 -16
View File
@@ -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.
+23 -42
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
import logging
LOGGER = logging.getLogger(__name__)
#ecovacs constants
#init constants
ECOVACS_DEVICES = "ecovacs_devices"
DOMAIN = "ecovacs"
CONF_CONTINENT = "continent"
+6 -5
View File
@@ -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"]
} }
+236
View File
@@ -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
+109
View File
@@ -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
+143
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
{
"name": "EcovacsBumper",
"domains": ["vacuum"],
"render_readme": true
}