From ca7d37c1937180bb1244da3bc0173b7c551cdc7f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 16 Jan 2019 09:02:03 -0500 Subject: [PATCH 1/5] WIP: Initial MQTT work WIP: Add initial EcoVacsMQTT client - Connect and get message TODO: Parse messages and plumb to events --- .gitignore | 3 + sucks/__init__.py | 149 ++++++++++++++++++++++++++++++++++++++++++---- sucks/cli.py | 4 ++ 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index c7bc22f..ba37586 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ cover/ # Ignore Vscode files .vscode/ + +# Ignore sucks.egg-info +sucks.egg-info/ diff --git a/sucks/__init__.py b/sucks/__init__.py index cbd0026..db4e8ab 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -11,6 +11,11 @@ from sleekxmpp import ClientXMPP, Callback, MatchXPath from sleekxmpp.xmlstream import ET from sleekxmpp.exceptions import XMPPError +from paho.mqtt.client import Client as ClientMQTT +from paho.mqtt import publish as MQTTPublish +from paho.mqtt import subscribe as MQTTSubscribe +import ssl + _LOGGER = logging.getLogger(__name__) # These consts define all of the vocabulary used by this library when presenting various states and components. @@ -375,14 +380,20 @@ class VacBot(): if vacuum['iot']: self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) self.iot.subscribe_to_ctls(self._handle_ctl) + self.mqtt = EcoVacsMQTT(user, domain, resource, secret, continent, vacuum) + self.mqtt.subscribe_to_ctls(self._handle_ctl) - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address ) - self.xmpp.subscribe_to_ctls(self._handle_ctl) + else: + self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address ) + self.xmpp.subscribe_to_ctls(self._handle_ctl) def connect_and_wait_until_ready(self): - self.xmpp.connect_and_wait_until_ready() - self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + if not self.vacuum['iot']: + self.xmpp.connect_and_wait_until_ready() + self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + else: + self.mqtt.connect_and_wait_until_ready() #ToDo identify the best way to handle similar for IOT devices #self.iot.connect_and_wait_until_ready() @@ -390,8 +401,11 @@ class VacBot(): if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds - self.send_ping() - self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + if not self.vacuum['iot']: + self.send_ping() + self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + #else: + #TODO: Handle in MQTT? def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -502,6 +516,7 @@ class VacBot(): if not self.vacuum['iot']: self.xmpp.send_ping(self._vacuum_address()) else: + self.mqtt.send_ping() self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead except XMPPError as err: _LOGGER.warning("Ping did not reach VacBot. Will retry.") @@ -559,10 +574,13 @@ class VacBot(): def run(self, action): self.send_command(action) - def disconnect(self, wait=False): - self.xmpp.disconnect(wait=wait) + def disconnect(self, wait=False): + if not self.vacuum['iot']: + self.xmpp.disconnect(wait=wait) + else: + self.mqtt.disconnect() -#This is used by EcoVacsIOT and EcoVacsXMPP for _ctl_to_dict +#This is used by EcoVacsIOT, EcoVacsXMPP, and EcoVacsMQTT for _ctl_to_dict def RepresentsInt(stringvar): try: int(stringvar) @@ -581,7 +599,6 @@ class EcoVacsIOT(): self.api = EcoVacsAPI self.api.continent = continent self.api.meta = {} - #self.add_event_handler("session_start", self.session_start) self.ctl_subscribers = [] self.ready_flag = Event() @@ -659,6 +676,115 @@ class EcoVacsIOT(): return result +class EcoVacsMQTT(ClientMQTT): + def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): + ClientMQTT.__init__(self) + + self.ctl_subscribers = [] + self.user = user + self.domain = str(domain).split(".")[0] #MQTT is using domain without tld + self.resource = resource + self.continent = continent + self.vacuum = vacuum + 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 wait_until_ready(self): + self.ready_flag.wait() + + def on_connect(self, client, userdata, flags, rc): + if rc != 0: + _LOGGER.error("EcoVacsMQTT error connecting - MQTT Return {}".format(rc)) + raise RuntimeError("EcoVacsMQTT error connecting - MQTT Return {}".format(rc)) + + else: + _LOGGER.debug("Connected MQTT 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): + _LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf)) + + + def subscribe_to_ctls(self, function): + self.ctl_subscribers.append(function) + + def _handle_ctl(self, client, userdata, message): + _LOGGER.debug("EcoVacs MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) + #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): + result = xml.attrib.copy() + if 'td' not in result: + # This happens for commands with no response data, such as PlaySound + return + + result['event'] = result.pop('td') + if xml: + result.update(xml[0].attrib) + + for key in result: + if not RepresentsInt(result[key]): #Fix to handle negative int values + result[key] = stringcase.snakecase(result[key]) + + return result + + 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' + for child in q.xml: + if child.tag.endswith('query'): + child.append(ctl) + return q + + def _my_address(self): + if not self.vacuum['iot']: + return self.user + '@' + self.domain + '/' + self.boundjid.resource + else: + return self.user + '@' + self.domain + '/' + self.resource + + def connect_and_wait_until_ready(self): + + self._on_log = self.on_log + self._on_message = self._handle_ctl + 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() + class EcoVacsXMPP(ClientXMPP): def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): @@ -715,7 +841,8 @@ class EcoVacsXMPP(ClientXMPP): return result - def register_callback(self, kind, function): + def register_callback(self, userdata, message): + self.register_handler(Callback(kind, MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'), function)) diff --git a/sucks/cli.py b/sucks/cli.py index 1971b2c..d399b2a 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -219,6 +219,10 @@ def run(actions, debug): vacuum = api.devices()[0] vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) vacbot.connect_and_wait_until_ready() + time.sleep(3) + vacbot.run(Move('backward')) + time.sleep(3) + vacbot.run(Charge()) for action in actions: click.echo("performing " + str(action.vac_command)) From c72be5509a599df73207ab1547822fe7f1efd909 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 17 Jan 2019 02:35:48 -0500 Subject: [PATCH 2/5] MQTT Plumbing MQTT Client plumbed up - Connect, disconnect, statuses working --- sucks/__init__.py | 177 +++++++++++++++++++++++++++++++--------------- sucks/cli.py | 4 -- 2 files changed, 121 insertions(+), 60 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index db4e8ab..b49a2bf 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -4,6 +4,8 @@ import time from base64 import b64decode, b64encode from collections import OrderedDict from threading import Event +import threading +import sched import requests import stringcase @@ -14,6 +16,7 @@ from sleekxmpp.exceptions import XMPPError from paho.mqtt.client import Client as ClientMQTT from paho.mqtt import publish as MQTTPublish from paho.mqtt import subscribe as MQTTSubscribe + import ssl _LOGGER = logging.getLogger(__name__) @@ -346,7 +349,6 @@ class EventListener(object): def unsubscribe(self): self._emitter.unsubscribe(self) - class VacBot(): def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False): @@ -394,18 +396,16 @@ class VacBot(): self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) else: self.mqtt.connect_and_wait_until_ready() - - #ToDo identify the best way to handle similar for IOT devices - #self.iot.connect_and_wait_until_ready() - #self.iot.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + self.mqtt.schedule(30, self.send_ping) if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds - if not self.vacuum['iot']: - self.send_ping() + self.send_ping() + if not self.vacuum['iot']: self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) - #else: - #TODO: Handle in MQTT? + else: + self.mqtt.schedule(3600,self.refresh_components) + def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -514,10 +514,8 @@ class VacBot(): def send_ping(self): try: if not self.vacuum['iot']: - self.xmpp.send_ping(self._vacuum_address()) - else: - self.mqtt.send_ping() - self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead + self.xmpp.send_ping(self._vacuum_address()) + except XMPPError as err: _LOGGER.warning("Ping did not reach VacBot. Will retry.") _LOGGER.debug("*** Error type: " + err.etype) @@ -526,6 +524,23 @@ class VacBot(): if self._failed_pings >= 4: self.vacuum_status = 'offline' self.statusEvents.notify(self.vacuum_status) + + try: + if self.vacuum['iot']: + self.mqtt.send_ping() + #self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead + #Some devices may utilize this, but it appears to + # just be an oversight in the app communidcations. IOT should probably be using MQTT pings (which are automatic when connected) + + except MQTTException as err: + _LOGGER.warning("Ping did not reach VacBot. Will retry.") + _LOGGER.debug("*** Error type: " + err.etype) + _LOGGER.debug("*** Error condition: " + err.condition) + self._failed_pings += 1 + if self._failed_pings >= 4: + self.vacuum_status = 'offline' + self.statusEvents.notify(self.vacuum_status) + else: self._failed_pings = 0 if self._monitor: @@ -538,9 +553,6 @@ class VacBot(): self.vacuum_status = None self.statusEvents.notify(self.vacuum_status) - if self.vacuum['iot']: #If an IOT device request statuses, to update events - self.refresh_statuses() - def refresh_components(self): try: self.run(GetLifeSpan('main_brush')) @@ -568,7 +580,7 @@ class VacBot(): def send_command(self, action): if not self.vacuum['iot']: self.xmpp.send_command(action.to_xml(), self._vacuum_address()) - else: + else: self.iot.send_command(action, self._vacuum_address()) #IOT devices need the full action for additional parsing def run(self, action): @@ -578,7 +590,9 @@ class VacBot(): if not self.vacuum['iot']: self.xmpp.disconnect(wait=wait) else: - self.mqtt.disconnect() + self.mqtt._disconnect() + + #This is used by EcoVacsIOT, EcoVacsXMPP, and EcoVacsMQTT for _ctl_to_dict def RepresentsInt(stringvar): @@ -682,10 +696,14 @@ class EcoVacsMQTT(ClientMQTT): self.ctl_subscribers = [] self.user = user - self.domain = str(domain).split(".")[0] #MQTT is using domain without tld + self.domain = str(domain).split(".")[0] #MQTT is using domain without tld extension self.resource = resource 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") + + if server_address is None: self.hostname = ('mq-{}.ecouser.net'.format(self.continent)) self.port = 8883 @@ -703,76 +721,100 @@ class EcoVacsMQTT(ClientMQTT): self.ready_flag = Event() + def _disconnect(): + 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 - MQTT Return {}".format(rc)) - raise RuntimeError("EcoVacsMQTT error connecting - MQTT Return {}".format(rc)) + _LOGGER.error("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc)) + raise RuntimeError("EcoVacsMQTT - error connecting with MQTT Return {}".format(rc)) else: - _LOGGER.debug("Connected MQTT with result code "+str(rc)) + _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): - _LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf)) - + #def on_log(self, client, userdata, level, buf): #This is very noisy and verbose + # _LOGGER.debug("EcoVacsMQTT Log: {} ".format(buf)) def subscribe_to_ctls(self, function): self.ctl_subscribers.append(function) def _handle_ctl(self, client, userdata, message): _LOGGER.debug("EcoVacs MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) - #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) + as_dict = self._ctl_to_dict(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(self, xml): + def _ctl_to_dict(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 xm (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 - return + # Handle response data with no 'td' - result['event'] = result.pop('td') - if xml: - result.update(xml[0].attrib) + 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: if not RepresentsInt(result[key]): #Fix to handle negative int values result[key] = stringcase.snakecase(result[key]) - + return result - 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' - for child in q.xml: - if child.tag.endswith('query'): - child.append(ctl) - return q - - def _my_address(self): - if not self.vacuum['iot']: - return self.user + '@' + self.domain + '/' + self.boundjid.resource - else: - return self.user + '@' + self.domain + '/' + self.resource + def send_ping(self): + _LOGGER.debug("*** MQTT sending ping ***") + rc = self._send_simple_command(MQTTPublish.paho.PINGREQ) + if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS: + _LOGGER.debug("*** MQTT ping acknowledged ***") + + return rc def connect_and_wait_until_ready(self): - self._on_log = self.on_log + #self._on_log = self.on_log #This provides more logging than needed, even for debug self._on_message = self._handle_ctl - self._on_connect = self.on_connect + self._on_connect = self.on_connect #TODO: This is pretty insecure and accepts any cert, maybe actually check? ssl_ctx = ssl.create_default_context() @@ -786,6 +828,29 @@ class EcoVacsMQTT(ClientMQTT): self.wait_until_ready() + # def send_command(self, xml, recipient): #MQTT doesn't seem to care about commands we send today, but leaving in case of futures + # #c = self._wrap_command(xml, recipient) + # #_LOGGER.debug('Sending command {0}'.format(c)) + # txml = '"' + # _LOGGER.debug('Sending command {0}'.format(txml)) + # self.publish('iot/atr/Move/' + self.vacuum['did'] + '/' + self.vacuum['class'] + '/' + self.vacuum['resource'] + '/x', txml) + # # + + # def _wrap_command(self, ctl, recipient): + # q = self.make_iq_query(xmlns=u'com:ctl', ito=recipient, ifrom=self._my_address()) + # q['type'] = 'set' + # for child in q.xml: + # if child.tag.endswith('query'): + # child.append(ctl) + # return q + + # def _my_address(self): + # if not self.vacuum['iot']: + # return self.user + '@' + self.domain + '/' + self.boundjid.resource + # else: + # return self.user + '@' + self.domain + '/' + self.resource + + class EcoVacsXMPP(ClientXMPP): def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) diff --git a/sucks/cli.py b/sucks/cli.py index d399b2a..1971b2c 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -219,10 +219,6 @@ def run(actions, debug): vacuum = api.devices()[0] vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) vacbot.connect_and_wait_until_ready() - time.sleep(3) - vacbot.run(Move('backward')) - time.sleep(3) - vacbot.run(Charge()) for action in actions: click.echo("performing " + str(action.vac_command)) From e2b0ea7b55c79fddb7963f65b4c1004e64d0bd05 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 17 Jan 2019 09:13:47 -0500 Subject: [PATCH 3/5] Add test MQTTPing Add test MQTTPing & Fix tests --- .gitignore | 2 ++ sucks/__init__.py | 38 ++++++++++++++++++----------- tests/test_vacbot.py | 58 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index ba37586..9c4cd19 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ cover/ # Ignore sucks.egg-info sucks.egg-info/ +.noseids +nosetests.xml diff --git a/sucks/__init__.py b/sucks/__init__.py index b49a2bf..00ca138 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -379,6 +379,11 @@ class VacBot(): self.lifespanEvents = EventEmitter() self.errorEvents = EventEmitter() + #Set none for clients to start + self.mqtt = None + self.iot = None + self.xmpp = None + if vacuum['iot']: self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) self.iot.subscribe_to_ctls(self._handle_ctl) @@ -481,6 +486,7 @@ class VacBot(): if event['ret'] == 'fail' and event['errno'] == '8': #Already charging status = 'slot_charging' else: + status = 'idle' #Fall back to Idle status _LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors try: @@ -515,6 +521,14 @@ class VacBot(): try: if not self.vacuum['iot']: self.xmpp.send_ping(self._vacuum_address()) + elif self.vacuum['iot']: + if not self.mqtt.send_ping(): + raise RuntimeError() + + #self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead + #Some devices may utilize this, but it appears to + # just be an oversight in the app communidcations. IOT should probably be using MQTT pings (which are automatic when connected) + except XMPPError as err: _LOGGER.warning("Ping did not reach VacBot. Will retry.") @@ -525,21 +539,12 @@ class VacBot(): self.vacuum_status = 'offline' self.statusEvents.notify(self.vacuum_status) - try: - if self.vacuum['iot']: - self.mqtt.send_ping() - #self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead - #Some devices may utilize this, but it appears to - # just be an oversight in the app communidcations. IOT should probably be using MQTT pings (which are automatic when connected) - - except MQTTException as err: + except RuntimeError as err: _LOGGER.warning("Ping did not reach VacBot. Will retry.") - _LOGGER.debug("*** Error type: " + err.etype) - _LOGGER.debug("*** Error condition: " + err.condition) self._failed_pings += 1 if self._failed_pings >= 4: self.vacuum_status = 'offline' - self.statusEvents.notify(self.vacuum_status) + self.statusEvents.notify(self.vacuum_status) else: self._failed_pings = 0 @@ -806,9 +811,14 @@ class EcoVacsMQTT(ClientMQTT): _LOGGER.debug("*** MQTT sending ping ***") rc = self._send_simple_command(MQTTPublish.paho.PINGREQ) if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS: - _LOGGER.debug("*** MQTT ping acknowledged ***") - - return rc + _LOGGER.debug("*** MQTT ping acknowledged ***") + print(rc) + return True + else: + print(rc) + return False + + def connect_and_wait_until_ready(self): diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index 4cdc242..3276767 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -4,6 +4,8 @@ from sucks import * from unittest.mock import Mock from sleekxmpp.exceptions import XMPPError +from paho.mqtt.client import MQTT_ERR_UNKNOWN as MQTTError + def test_handle_clean_report(): @@ -46,6 +48,9 @@ def test_handle_charge_state(): v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '8'}) #Seen in IOT when already charging assert_equals('charging', v.charge_status) + v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '5'}) #Seen in IOT randomly - not sure what this is yet + assert_equals('idle', v.charge_status) + v._handle_ctl({'event': 'charge_state', 'type': 'a_type_not_supported_by_sucks'}) assert_equals('a_type_not_supported_by_sucks', v.charge_status) @@ -135,8 +140,8 @@ def test_is_charging(): assert_false(v.is_charging) def test_send_ping_no_monitor(): + #Test XMPP Ping v = a_vacbot() - mock = v.xmpp.send_ping = Mock() v.send_ping() @@ -154,8 +159,28 @@ def test_send_ping_no_monitor(): v.send_ping() assert_equals(None, v.vacuum_status) + #Test MQTT Ping + v = a_vacbot(iot=True) + mock = v.mqtt.send_ping = Mock() + v.send_ping() + + # On four failed pings, vacuum state gets set to 'offline' + mock.return_value = False + v.send_ping() + v.send_ping() + v.send_ping() + assert_equals(None, v.vacuum_status) + v.send_ping() + assert_equals('offline', v.vacuum_status) + + # On a successful ping after the offline state, state gets reset to None, indicating that it is unknown + mock.return_value = True + v.send_ping() + assert_equals(None, v.vacuum_status) + def test_send_ping_with_monitor(): + #Test XMPP Ping v = a_vacbot(monitor=True) ping_mock = v.xmpp.send_ping = Mock() @@ -182,6 +207,33 @@ def test_send_ping_with_monitor(): v.send_ping() assert_equals(1, request_statuses_mock.call_count) + #Test MQTT Ping + v = a_vacbot(iot=True, monitor=True) + + ping_mock = v.mqtt.send_ping = Mock() + request_statuses_mock = v.request_all_statuses = Mock() + + # First ping should try to fetch statuses + v.send_ping() + assert_equals(1, request_statuses_mock.call_count) + + # Nothing blowing up is success + + # On four failed pings, vacuum state gets set to 'offline' + ping_mock.return_value = False + v.send_ping() + v.send_ping() + v.send_ping() + assert_equals(None, v.vacuum_status) + v.send_ping() + assert_equals('offline', v.vacuum_status) + + # On a successful ping after the offline state, a request for initial statuses is made + ping_mock.return_value = True + request_statuses_mock.reset_mock() + v.send_ping() + assert_equals(1, request_statuses_mock.call_count) + def test_status_event_subscription(): v = a_vacbot() @@ -283,8 +335,8 @@ def test_model_variation(): -def a_vacbot(bot=None, monitor=False): +def a_vacbot(bot=None, iot=False, monitor=False): if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} + bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": iot} return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', bot, 'na', monitor=monitor) From c0eda3a6cb34005589df09d3a010253db15dfc96 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 17 Jan 2019 09:55:22 -0500 Subject: [PATCH 4/5] Fix clean from CLI Fix clean from CLI Inject an action='start' for IOTvacs --- sucks/__init__.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 00ca138..31fc804 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -223,8 +223,8 @@ class EcoVacsAPI: if not api == self.IOTDEVMANAGERAPI: response = requests.post(url, json=params) else: - try: #IOT Device sometimes doesnt provide a response depending on command, reduce timeout to 1.25 to accomodate and make requests faster - response = requests.post(url, json=params, timeout=1.25) #May think about having timeout as an arg that could be provided in the future + try: #IOT Device 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) #May think about having timeout as an arg that could be provided in the future except requests.exceptions.ReadTimeout: _LOGGER.debug("call to {} failed with ReadTimeout".format(function)) return {} @@ -298,10 +298,8 @@ class EcoVacsAPI: def SetIOTDevices(self, devices, iotproducts): for device in devices: #Check if the device is part of iotProducts for iotProduct in iotproducts: - if not device['class'] == iotProduct['classid']: - device['iot'] = False - else: - device['iot'] = True #If it is add an iot flag. + if device['class'] in iotProduct['classid']: + device['iot'] = True return devices @@ -589,7 +587,8 @@ class VacBot(): self.iot.send_command(action, self._vacuum_address()) #IOT devices need the full action for additional parsing def run(self, action): - self.send_command(action) + self.send_command(action) + def disconnect(self, wait=False): if not self.vacuum['iot']: @@ -628,6 +627,8 @@ class EcoVacsIOT(): # self.wait_until_ready() 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(action, self.api._EcoVacsAPI__call_portal_api(self.api, self.api.IOTDEVMANAGERAPI,'',c )) @@ -726,7 +727,7 @@ class EcoVacsMQTT(ClientMQTT): self.ready_flag = Event() - def _disconnect(): + def _disconnect(self): self.disconnect() #disconnect mqtt connection self.scheduler.empty() #Clear schedule queue @@ -998,9 +999,12 @@ class VacBotCommand: class Clean(VacBotCommand): - def __init__(self, mode='auto', speed='normal', terminal=False, **kwargs): + def __init__(self, mode='auto', speed='normal', iot=False, action='start',terminal=False, **kwargs): if kwargs is None: - super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) + if not iot: + super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) + else: + super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed],'act': CLEAN_ACTION_TO_ECOVACS[action]}}) else: initcmd = {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]} for kkey, kvalue in kwargs.items(): From 6ff7173ddc316f28285a78393e63ccdbba9b3404 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 17 Jan 2019 23:15:35 -0500 Subject: [PATCH 5/5] Update __init__.py --- sucks/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 31fc804..55977fd 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -583,7 +583,8 @@ class VacBot(): def send_command(self, action): if not self.vacuum['iot']: self.xmpp.send_command(action.to_xml(), self._vacuum_address()) - else: + else: + #IOT issues commands via restAPI, and listens on MQTT for status updates self.iot.send_command(action, self._vacuum_address()) #IOT devices need the full action for additional parsing def run(self, action): @@ -772,7 +773,7 @@ class EcoVacsMQTT(ClientMQTT): def _ctl_to_dict(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 xm (like IOT rest calls), other than this it is similar to XMPP + 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() @@ -813,10 +814,8 @@ class EcoVacsMQTT(ClientMQTT): rc = self._send_simple_command(MQTTPublish.paho.PINGREQ) if rc == MQTTPublish.paho.MQTT_ERR_SUCCESS: _LOGGER.debug("*** MQTT ping acknowledged ***") - print(rc) return True else: - print(rc) return False