From 58ace66a788e6f4ccfdca6c84da6924034d4d56f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 10 Jan 2019 22:40:10 -0500 Subject: [PATCH 01/48] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e0d16c7..8563ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist # Nosetests files cover/ .coverage +.vscode/settings.json From e408157b726b109f1a4be0e8ed763135e9fde85b Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 12 Jan 2019 15:43:02 -0500 Subject: [PATCH 02/48] Basic support for D90X Add EcoVacsIOT in VacBot --- .vscode/launch.json | 75 +++++++++ sucks.egg-info/PKG-INFO | 18 ++ sucks.egg-info/SOURCES.txt | 10 ++ sucks.egg-info/dependency_links.txt | 1 + sucks.egg-info/entry_points.txt | 3 + sucks.egg-info/requires.txt | 10 ++ sucks.egg-info/top_level.txt | 1 + sucks/__init__.py | 253 ++++++++++++++++++++++++---- sucks/cli.py | 25 ++- 9 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 sucks.egg-info/PKG-INFO create mode 100644 sucks.egg-info/SOURCES.txt create mode 100644 sucks.egg-info/dependency_links.txt create mode 100644 sucks.egg-info/entry_points.txt create mode 100644 sucks.egg-info/requires.txt create mode 100644 sucks.egg-info/top_level.txt diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..04639e2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,75 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File (Integrated Terminal)", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Python: Run Sucks CLI (Integrated Terminal)", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/sucks/cli.py", + "args" : [ + "--debug", + "charge" + ], + "console": "integratedTerminal" + }, + { + "name": "Python: Attach", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost" + }, + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "enter-your-module-name-here", + "console": "integratedTerminal" + }, + { + "name": "Python: Django", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "console": "integratedTerminal", + "args": [ + "runserver", + "--noreload", + "--nothreading" + ], + "django": true + }, + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "app.py" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload" + ], + "jinja": true + }, + { + "name": "Python: Current File (External Terminal)", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "externalTerminal" + } + ] +} \ No newline at end of file diff --git a/sucks.egg-info/PKG-INFO b/sucks.egg-info/PKG-INFO new file mode 100644 index 0000000..cc6c67e --- /dev/null +++ b/sucks.egg-info/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 2.1 +Name: sucks +Version: 0.9.3 +Summary: a library for controlling certain robot vacuums +Home-page: https://github.com/wpietri/sucks +Author: William Pietri +Author-email: sucks-users@googlegroups.com +License: GPL-3.0 +Description: UNKNOWN +Keywords: home automation vacuum robot +Platform: UNKNOWN +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Topic :: Software Development :: Libraries +Classifier: Topic :: Home Automation +Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) +Classifier: Programming Language :: Python :: 3.5 +Provides-Extra: dev diff --git a/sucks.egg-info/SOURCES.txt b/sucks.egg-info/SOURCES.txt new file mode 100644 index 0000000..399d04a --- /dev/null +++ b/sucks.egg-info/SOURCES.txt @@ -0,0 +1,10 @@ +README.md +setup.py +sucks/__init__.py +sucks/cli.py +sucks.egg-info/PKG-INFO +sucks.egg-info/SOURCES.txt +sucks.egg-info/dependency_links.txt +sucks.egg-info/entry_points.txt +sucks.egg-info/requires.txt +sucks.egg-info/top_level.txt \ No newline at end of file diff --git a/sucks.egg-info/dependency_links.txt b/sucks.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sucks.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/sucks.egg-info/entry_points.txt b/sucks.egg-info/entry_points.txt new file mode 100644 index 0000000..b630b0c --- /dev/null +++ b/sucks.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +sucks = sucks.cli:cli + diff --git a/sucks.egg-info/requires.txt b/sucks.egg-info/requires.txt new file mode 100644 index 0000000..6e9e16c --- /dev/null +++ b/sucks.egg-info/requires.txt @@ -0,0 +1,10 @@ +sleekxmpp>=1.3 +click>=6 +requests>=2.18 +pycryptodome>=3.4 +pycountry-convert>=0.5 +stringcase>=1.2 + +[dev] +nose +requests-mock>=1.3 diff --git a/sucks.egg-info/top_level.txt b/sucks.egg-info/top_level.txt new file mode 100644 index 0000000..b735fc9 --- /dev/null +++ b/sucks.egg-info/top_level.txt @@ -0,0 +1 @@ +sucks diff --git a/sucks/__init__.py b/sucks/__init__.py index 1c01489..93e5e66 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -99,6 +99,13 @@ class EcoVacsAPI: PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ' MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}' USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do' + PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api' + + USERSAPI = 'users/user.do' + IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via API, no longer XMPP + PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products, which is assumed should use IOT API instead of XMPP + + REALM = 'ecouser.net' def __init__(self, device_id, account_id, password_hash, country, continent): @@ -176,8 +183,43 @@ class EcoVacsAPI: raise RuntimeError( "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) + def __call_portal_api(self, api, function, args): + _LOGGER.debug("calling portal api {} function {} with {}".format(api, function, args)) + if api == self.USERSAPI: + params = {'todo': function} + params.update(args) + else: + params = {} + params.update(args) + + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) + response = requests.post(url, json=params) + json = response.json() + _LOGGER.debug("got {}".format(json)) + if api == self.USERSAPI: + if json['result'] == 'ok': + return json + + if api == self.IOTDEVMANAGERAPI: + if json['ret'] == 'ok': + return json + elif json['ret'] == 'fail' and json['debug'] == 'wait for response timed out': #Maybe handle timeout for IOT better in the future + _LOGGER.error("call to {} failed with {}".format(function, json)) + return {} + #raise RuntimeError( + # "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) + + if api.startswith(self.PRODUCTAPI): + if json['code'] == 0: + return json + + else: + _LOGGER.error("call to {} failed with {}".format(function, json)) + raise RuntimeError( + "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) + def __call_login_by_it_token(self): - return self.__call_user_api('loginByItToken', + return self.__call_portal_api(self.USERSAPI,'loginByItToken', {'country': self.meta['country'].upper(), 'resource': self.resource, 'realm': EcoVacsAPI.REALM, @@ -186,7 +228,7 @@ class EcoVacsAPI: ) def devices(self): - devices = self.__call_user_api('GetDeviceList', { + devices = self.__call_portal_api(self.USERSAPI,'GetDeviceList', { 'userid': self.uid, 'auth': { 'with': 'users', @@ -196,7 +238,28 @@ class EcoVacsAPI: 'resource': self.resource } })['devices'] + + iotProducts = self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', { + 'channel': '', + 'auth': { + 'with': 'users', + 'userid': self.uid, + 'realm': EcoVacsAPI.REALM, + 'token': self.user_access_token, + 'resource': self.resource + } + })['data'] + + for device in devices: #Check if the device is part of iotProducts and add an iot flag. + for iotProducts in iotProducts: + if device['class'] == iotProducts['classid']: + device['iot'] = True + else: + device['iot'] = False + + return devices + @staticmethod def md5(text): @@ -270,13 +333,22 @@ class VacBot(): self.lifespanEvents = EventEmitter() self.errorEvents = EventEmitter() - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) - self.xmpp.subscribe_to_ctls(self._handle_ctl) + if vacuum['iot']: + self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) + self.iot.subscribe_to_ctls(self._handle_ctl) + else: + self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) + self.xmpp.subscribe_to_ctls(self._handle_ctl) + def connect_and_wait_until_ready(self): - self.xmpp.connect_and_wait_until_ready() + if self.vacuum['iot']: + self.iot.connect_and_wait_until_ready() + self.iot.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) - self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + else: + self.xmpp.connect_and_wait_until_ready() + self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds @@ -300,9 +372,12 @@ class VacBot(): except KeyError: _LOGGER.warning("Unknown component type: '" + type + "'") - lifespan = int(event['val']) / 100 + if 'val' in event: + lifespan = int(event['val']) / 100 + else: + lifespan = int(event['left'].replace("_","-")) / 60 #This works for a D901, I also ran into a negative number and had to replace _ with - self.components[type] = lifespan - + #lifespan = str(int(int(event['left']) / 60) + " (" + int(int(event['left'])/int(event['total'])*100) + "%)") #This works for a D901 lifespan_event = {'type': type, 'lifespan': lifespan} self.lifespanEvents.notify(lifespan_event) _LOGGER.debug("*** life_span " + type + " = " + str(lifespan)) @@ -354,7 +429,10 @@ class VacBot(): _LOGGER.debug("*** charge_status = " + self.charge_status) def _vacuum_address(self): - return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' + if self.vacuum['iot']: + return self.vacuum['did'] + else: + return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' @property def is_charging(self) -> bool: @@ -365,27 +443,31 @@ class VacBot(): return self.vacuum_status in CLEANING_STATES def send_ping(self): - try: - 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) - _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) + if self.vacuum['iot']: + #TODO + print("IOT Ping") else: - self._failed_pings = 0 - if self._monitor: - # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded - if self.vacuum_status == 'offline' or self.vacuum_status is None: - self.request_all_statuses() - else: - # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown - if self.vacuum_status == 'offline': - self.vacuum_status = None + try: + 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) + _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: + # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded + if self.vacuum_status == 'offline' or self.vacuum_status is None: + self.request_all_statuses() + else: + # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown + if self.vacuum_status == 'offline': + self.vacuum_status = None + self.statusEvents.notify(self.vacuum_status) def refresh_components(self): try: @@ -409,15 +491,113 @@ class VacBot(): else: self.refresh_components() - def send_command(self, xml): - self.xmpp.send_command(xml, self._vacuum_address()) + def send_command(self, action): + if self.vacuum['iot']: + self.iot.send_command(action, self._vacuum_address()) + else: + self.xmpp.send_command(action, self._vacuum_address()) def run(self, action): - self.send_command(action.to_xml()) + if self.vacuum['iot']: + self.send_command(action) + else: + self.send_command(action.to_xml()) def disconnect(self, wait=False): self.xmpp.disconnect(wait=wait) +class EcoVacsIOT(): + def __init__(self, user, domain, resource, secret, continent, vacuum): + self.uid = user + self.domain = domain + self.resource = resource + self.secret = secret + self.continent = continent + self.vacuum = vacuum + 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() + + + def connect_and_wait_until_ready(self): + self.connect(EcoVacsAPI._EcoVacsAPI__call_portal_api()) + self.process() + self.wait_until_ready() + + def send_command(self, action, recipient): + 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 )) + + + def _wrap_command(self, cmd, recipient): + + q = { + 'auth': { + 'realm': EcoVacsAPI.REALM, + 'resource': self.resource, + 'token': self.secret, + 'userid': self.uid, + 'with': 'users', + }, + "cmdName": cmd.name, + "payload": cmd.args_to_xml(), + "payloadType": "x", + "td": "q", + "toId": recipient, + "toRes": self.vacuum['resource'], + "toType": self.vacuum['class'] + } + + return q + + + def subscribe_to_ctls(self, function): + self.ctl_subscribers.append(function) + + + def _handle_ctl(self, action, message): + resp = self._ctl_to_dict(action, message['resp']) + if resp is not None: + for s in self.ctl_subscribers: + s(resp) + + + def _ctl_to_dict(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 IOT 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'] = xmlchild[0].tag + "_report" + 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) + + for key in result: + result[key] = stringcase.snakecase(result[key]) + + return result + + + 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)) + self.ready_flag.set() + + class EcoVacsXMPP(ClientXMPP): def __init__(self, user, domain, resource, secret, continent, server_address=None): @@ -529,6 +709,17 @@ class VacBotCommand: ctl.set(key, value) return ctl + def args_to_xml(self): + ctl = ET.Element('ctl',{}) + for key, value in self.args.items(): + if type(value) is dict: + inner = ET.Element(key, value) + ctl.append(inner) + else: + ctl.set(key, value) + return ET.tostring(ctl).decode() + + def __str__(self, *args, **kwargs): return self.command_name() + " command" diff --git a/sucks/cli.py b/sucks/cli.py index 5903f11..aaf1860 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -209,17 +209,28 @@ def run(actions, debug): if actions: config = read_config() api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent']) + config['country'], config['continent']) 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() + #vacbot.connect_and_wait_until_ready() + + + + vacbot.request_all_statuses() + + print(vacbot.components) + print(vacbot.vacuum_status) + + print(vacbot) + - for action in actions: - click.echo("performing " + str(action.vac_command)) - vacbot.run(action.vac_command) - action.wait.wait(vacbot) + #for action in actions: + # click.echo("performing " + str(action.vac_command)) + # vacbot.run(action.vac_command) + # action.wait.wait(vacbot) - vacbot.disconnect(wait=True) + #vacbot.disconnect(wait=True) click.echo("done") From 0da4532908850d975ca40c3ebb37eb1d7e478bd1 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 13 Jan 2019 13:59:29 -0500 Subject: [PATCH 03/48] Add spotarea Add SpotArea option for D90X w/ Mapping --- .vscode/launch.json | 3 ++- sucks/__init__.py | 27 ++++++++++++++++++++++----- sucks/cli.py | 16 ++++------------ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 04639e2..19f79d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,8 @@ "program": "${workspaceFolder}/sucks/cli.py", "args" : [ "--debug", - "charge" + "clean", + "10" ], "console": "integratedTerminal" }, diff --git a/sucks/__init__.py b/sucks/__init__.py index 93e5e66..792539d 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -19,9 +19,14 @@ _LOGGER = logging.getLogger(__name__) CLEAN_MODE_AUTO = 'auto' CLEAN_MODE_EDGE = 'edge' CLEAN_MODE_SPOT = 'spot' +CLEAN_MODE_SPOT_AREA = 'spotarea' CLEAN_MODE_SINGLE_ROOM = 'single_room' CLEAN_MODE_STOP = 'stop' +CLEAN_ACTION_START = 'start' +CLEAN_ACTION_PAUSE = 'pause' +CLEAN_ACTION_RESUME = 'resume' + FAN_SPEED_NORMAL = 'normal' FAN_SPEED_HIGH = 'high' @@ -36,7 +41,7 @@ COMPONENT_FILTER = 'filter' VACUUM_STATUS_OFFLINE = 'offline' -CLEANING_STATES = {CLEAN_MODE_AUTO, CLEAN_MODE_EDGE, CLEAN_MODE_SPOT, CLEAN_MODE_SINGLE_ROOM} +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) @@ -45,14 +50,22 @@ 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_MODE_FROM_ECOVACS = { 'auto': CLEAN_MODE_AUTO, 'border': CLEAN_MODE_EDGE, 'spot': CLEAN_MODE_SPOT, + 'SpotArea': CLEAN_MODE_SPOT_AREA, 'singleroom': CLEAN_MODE_SINGLE_ROOM, 'stop': CLEAN_MODE_STOP, 'going': CHARGE_MODE_RETURNING @@ -504,7 +517,8 @@ class VacBot(): self.send_command(action.to_xml()) def disconnect(self, wait=False): - self.xmpp.disconnect(wait=wait) + if not self.vacuum['iot']: + self.xmpp.disconnect(wait=wait) class EcoVacsIOT(): def __init__(self, user, domain, resource, secret, continent, vacuum): @@ -728,9 +742,9 @@ class VacBotCommand: class Clean(VacBotCommand): - def __init__(self, mode='auto', speed='normal', terminal=False): - super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) - + #def __init__(self, mode='auto', speed='normal', terminal=False): - Keeping original in case + def __init__(self, mode='auto', speed='normal', action='start', mid='',p='',deep='', terminal=False): + super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed], 'act': CLEAN_ACTION_TO_ECOVACS[action], 'mid':mid, 'p':p, 'deep':deep}}) class Edge(Clean): def __init__(self): @@ -746,6 +760,9 @@ class Stop(Clean): def __init__(self): super().__init__('stop', 'normal') +class SpotArea(Clean): + def __init__(self): + super().__init__('spotarea', 'normal', 'start') class Charge(VacBotCommand): def __init__(self): diff --git a/sucks/cli.py b/sucks/cli.py index aaf1860..7c08e27 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -213,24 +213,16 @@ 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() - - - - vacbot.request_all_statuses() - - print(vacbot.components) - print(vacbot.vacuum_status) - - print(vacbot) + #vacbot.connect_and_wait_until_ready()( + vacbot.run(Clean('spotarea', 'normal', 'start', '2')) - #for action in actions: + # for action in actions: # click.echo("performing " + str(action.vac_command)) # vacbot.run(action.vac_command) # action.wait.wait(vacbot) - #vacbot.disconnect(wait=True) + vacbot.disconnect(wait=True) click.echo("done") From c1f6814f8e97bb979203bb5724dbbe9ca37386ac Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 13 Jan 2019 14:12:29 -0500 Subject: [PATCH 04/48] Add clean_status for IOT Add clean_status for IOT - Reports cleaning or paused --- sucks/__init__.py | 8 ++++++++ sucks/cli.py | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 792539d..2b2ac6f 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -403,6 +403,14 @@ class VacBot(): _LOGGER.warning("Unknown cleaning status '" + type + "'") self.clean_status = type self.vacuum_status = type + + if self.vacuum['iot']: + cleaning = event.get('st', None) + if cleaning == 'p': + self.clean_status = 'paused' + else: + self.clean_status = 'cleaning' + fan = event.get('speed', None) if fan is not None: try: diff --git a/sucks/cli.py b/sucks/cli.py index 7c08e27..bf77453 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -215,8 +215,9 @@ def run(actions, debug): vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) #vacbot.connect_and_wait_until_ready()( - vacbot.run(Clean('spotarea', 'normal', 'start', '2')) - + #vacbot.run(Clean('spotarea', 'normal', 'start', '2')) + vacbot.request_all_statuses() + # for action in actions: # click.echo("performing " + str(action.vac_command)) # vacbot.run(action.vac_command) From ef0bd2e90de4c51d36b4ec44172907b20879aa9a Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 13 Jan 2019 21:22:19 -0500 Subject: [PATCH 05/48] Add backward action Add backward action --- sucks/__init__.py | 10 ++++++---- sucks/cli.py | 10 +++++++--- tests/test_commands.py | 3 +++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 2b2ac6f..f715054 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -582,10 +582,11 @@ class EcoVacsIOT(): def _handle_ctl(self, action, message): - resp = self._ctl_to_dict(action, message['resp']) - if resp is not None: - for s in self.ctl_subscribers: - s(resp) + if not message == {}: + resp = self._ctl_to_dict(action, message['resp']) + if resp is not None: + for s in self.ctl_subscribers: + s(resp) def _ctl_to_dict(self, action, xmlstring): @@ -709,6 +710,7 @@ class EcoVacsXMPP(ClientXMPP): class VacBotCommand: ACTION = { 'forward': 'forward', + 'backward': 'backward', 'left': 'SpinLeft', 'right': 'SpinRight', 'turn_around': 'TurnAround', diff --git a/sucks/cli.py b/sucks/cli.py index bf77453..7e49704 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -215,9 +215,13 @@ def run(actions, debug): vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) #vacbot.connect_and_wait_until_ready()( - #vacbot.run(Clean('spotarea', 'normal', 'start', '2')) - vacbot.request_all_statuses() - + vacbot.run(Move('backward')) + vacbot.run(Move('backward')) + vacbot.run(Move('stop')) + #vacbot.run(Clean('spotarea', 'normal', 'start', '0')) + + #vacbot.request_all_statuses() + # for action in actions: # click.echo("performing " + str(action.vac_command)) # vacbot.run(action.vac_command) diff --git a/tests/test_commands.py b/tests/test_commands.py index a3039b3..6ed18cc 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -103,6 +103,9 @@ def test_move_command(): c = Move(action='forward') assert_equals(ElementTree.tostring(c.to_xml()), b'') + c = Move(action='backward') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') c = Move(action='stop') assert_equals(ElementTree.tostring(c.to_xml()), b'') From d317e4b9130853369fcff5c73aacb888a8e50fc2 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 10:59:25 -0500 Subject: [PATCH 06/48] Fix tests & Clean Fix tests Add tests for iot Modify Clean to use kwargs for SpotArea --- sucks/__init__.py | 71 +++++++++++++++++++++++++-------- sucks/cli.py | 7 ++-- tests/test_ecovacs_api.py | 28 ++++++++++++- tests/test_ecovacs_iot.py | 80 ++++++++++++++++++++++++++++++++++++++ tests/test_ecovacs_xmpp.py | 4 ++ tests/test_vacbot.py | 6 +-- 6 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 tests/test_ecovacs_iot.py diff --git a/sucks/__init__.py b/sucks/__init__.py index f715054..e535d29 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -240,8 +240,10 @@ class EcoVacsAPI: 'token': self.auth_code} ) - def devices(self): - devices = self.__call_portal_api(self.USERSAPI,'GetDeviceList', { + + + def getdevices(self): + return self.__call_portal_api(self.USERSAPI,'GetDeviceList', { 'userid': self.uid, 'auth': { 'with': 'users', @@ -252,7 +254,8 @@ class EcoVacsAPI: } })['devices'] - iotProducts = self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', { + def getiotProducts(self): + return self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', { 'channel': '', 'auth': { 'with': 'users', @@ -263,16 +266,18 @@ class EcoVacsAPI: } })['data'] + def SetIOTDevices(self, devices, iotproducts): for device in devices: #Check if the device is part of iotProducts and add an iot flag. - for iotProducts in iotProducts: - if device['class'] == iotProducts['classid']: + for iotProduct in iotproducts: + if device['class'] == iotProduct['classid']: device['iot'] = True else: device['iot'] = False - return devices + def devices(self): + return self.SetIOTDevices(self.getdevices(), self.getiotProducts()) @staticmethod def md5(text): @@ -386,11 +391,12 @@ class VacBot(): _LOGGER.warning("Unknown component type: '" + type + "'") if 'val' in event: + lifespan = int(event['val']) / 100 else: - lifespan = int(event['left'].replace("_","-")) / 60 #This works for a D901, I also ran into a negative number and had to replace _ with - + lifespan = int(event['left']) / 60 #This works for a D901 self.components[type] = lifespan - #lifespan = str(int(int(event['left']) / 60) + " (" + int(int(event['left'])/int(event['total'])*100) + "%)") #This works for a D901 + lifespan_event = {'type': type, 'lifespan': lifespan} self.lifespanEvents.notify(lifespan_event) _LOGGER.debug("*** life_span " + type + " = " + str(lifespan)) @@ -557,7 +563,7 @@ class EcoVacsIOT(): def _wrap_command(self, cmd, recipient): - q = { + return { 'auth': { 'realm': EcoVacsAPI.REALM, 'resource': self.resource, @@ -574,7 +580,7 @@ class EcoVacsIOT(): "toType": self.vacuum['class'] } - return q + def subscribe_to_ctls(self, function): @@ -671,9 +677,18 @@ class EcoVacsXMPP(ClientXMPP): result.update(xml[0].attrib) for key in result: - result[key] = stringcase.snakecase(result[key]) + if not self.RepresentsInt(result[key]): #Fix to handle negative int values + result[key] = stringcase.snakecase(result[key]) + return result + def RepresentsInt(self, stringvar): + try: + int(stringvar) + return True + except ValueError: + return False + def register_callback(self, kind, function): self.register_handler(Callback(kind, MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'), @@ -717,7 +732,7 @@ class VacBotCommand: 'stop': 'stop' } - def __init__(self, name, args=None): + def __init__(self, name, args=None, **kwargs): if args is None: args = {} self.name = name @@ -753,8 +768,14 @@ class VacBotCommand: class Clean(VacBotCommand): #def __init__(self, mode='auto', speed='normal', terminal=False): - Keeping original in case - def __init__(self, mode='auto', speed='normal', action='start', mid='',p='',deep='', terminal=False): - super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed], 'act': CLEAN_ACTION_TO_ECOVACS[action], 'mid':mid, 'p':p, 'deep':deep}}) + def __init__(self, mode='auto', speed='normal', terminal=False, **kwargs): + if kwargs is None: + super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) + else: + initcmd = {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]} + for kkey, kvalue in kwargs.items(): + initcmd[kkey] = kvalue + super().__init__('Clean', {'clean': initcmd}) class Edge(Clean): def __init__(self): @@ -771,8 +792,26 @@ class Stop(Clean): super().__init__('stop', 'normal') class SpotArea(Clean): - def __init__(self): - super().__init__('spotarea', 'normal', 'start') + def __init__(self, **kwargs): + self.action='start' + self.mid='' + self.p='' + self.deep='' + if kwargs is not None: + for kkey, kvalue in kwargs.items(): + if kkey == 'action': + self.action = kvalue + elif kkey == 'mid': + self.mid = kvalue + elif kkey == 'p': + self.p = kvalue + elif kkey == 'deep': + self.deep = kvalue + + if self.mid != '': #For cleaning specified map area + super().__init__('spotarea', 'normal', act=CLEAN_ACTION_TO_ECOVACS[self.action], mid=self.mid) + elif self.p != '': #For cleaning custom map area, and specify deep amount 1x/2x + super().__init__('spotarea' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[self.action], p=self.p, deep=self.deep) class Charge(VacBotCommand): def __init__(self): diff --git a/sucks/cli.py b/sucks/cli.py index 7e49704..88b6e24 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -215,10 +215,9 @@ def run(actions, debug): vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) #vacbot.connect_and_wait_until_ready()( - vacbot.run(Move('backward')) - vacbot.run(Move('backward')) - vacbot.run(Move('stop')) - #vacbot.run(Clean('spotarea', 'normal', 'start', '0')) + + vacbot.run(SpotArea(action='start', mid='0')) + #vacbot.request_all_statuses() diff --git a/tests/test_ecovacs_api.py b/tests/test_ecovacs_api.py index e71b197..49d9d76 100644 --- a/tests/test_ecovacs_api.py +++ b/tests/test_ecovacs_api.py @@ -72,7 +72,10 @@ def test_device_lookup(): device_id = 'E0000001234567890123' r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "class": "126", "nick": "bob"}], "result": "ok"}' % device_id) + text='{"todo": "result", "devices": [{"did": "%s", "class": "126", "nick": "bob"}], "result": "ok"}' % device_id) + r = m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') + d = api.devices() assert_equals(r.call_count, 1) assert_equals(len(d), 1) @@ -80,6 +83,25 @@ def test_device_lookup(): assert_equals(vacuum['did'], device_id) assert_equals(vacuum['class'], '126') +def test_device_lookup_is_IOT(): + api = make_api() + with requests_mock.mock() as m: + device_id = 'E0000001234567890123' + device_class = 'ls1ok3' + + r = m.post(compile('user.do'), + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) + r = m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') + + d = api.devices() + assert_equals(r.call_count, 1) + assert_equals(len(d), 1) + vacuum = d[0] + assert_equals(vacuum['did'], device_id) + assert_equals(vacuum['class'], device_class) + assert_equals(vacuum['iot'], True) + def make_api(): with requests_mock.mock() as m: @@ -88,5 +110,7 @@ def make_api(): m.get(compile('user/getAuthCode'), text='{"time": 1511200804607, "data": {"authCode": "abcdef01234567890abcdef012345678"}, "code": "0000", "msg": "X"}') m.post(compile('user.do'), - text='{"todo": "result", "token": "base64base64base64base64base64ba", "result": "ok", "userId": "20170101abcdefabcdefa", "resource": "abcdef12"}') + text='{"todo": "result", "token": "base64base64base64base64base64ba", "result": "ok", "userId": "20170101abcdefabcdefa", "resource": "abcdef12"}') + m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') return EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na') diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py new file mode 100644 index 0000000..3cc949c --- /dev/null +++ b/tests/test_ecovacs_iot.py @@ -0,0 +1,80 @@ +from re import compile + +import requests_mock +from nose.tools import * + +from sucks import * +from test_ecovacs_api import make_api + + +# There are few tests for the IOT stuff here because it's relatively complicated to test given +# the library's design and its multithreaded nature and lack of explicit testing support. + +def test_wrap_command(): + x = make_ecovacs_iot() + + c = x._wrap_command(Charge(), 'E0000000001234567890') + assert_equal(c['cmdName'], Charge().name) + assert_equal(c['toId'], 'E0000000001234567890') + assert_equal(c['payload'], '') + +def test_is_iot(): + x = make_ecovacs_iot() + + +# def test_subscribe_to_ctls(): +# response = None + +# def save_response(value): +# nonlocal response +# response = value + +# x = make_ecovacs_iot() + +# query = x.make_iq_query() +# query.set_payload( +# ET.fromstring(' ')) + +# x.subscribe_to_ctls(save_response) +# x._handle_ctl(query) +# assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) + + +# def test_xml_to_dict(): +# x = make_ecovacs_iot() + +# assert_dict_equal( +# x._ctl_to_dict(make_ctl(' ')), +# {'event': 'clean_report', 'type': 'auto'}) +# assert_dict_equal( +# x._ctl_to_dict(make_ctl(' ')), +# {'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) + +# assert_dict_equal( +# x._ctl_to_dict(make_ctl('')), +# {'event': 'battery_info', 'power': '095'}) + +# assert_dict_equal( +# x._ctl_to_dict(make_ctl('# ')), +# {'event': 'life_span', 'type': 'brush', 'val': '099', 'total': '365'}) + + +def make_ecovacs_iot(): + eapi = make_api() + + with requests_mock.mock() as m: + device_id = 'E0000001234567890123' + device_resource = 'test_resource' + device_class = 'ls1ok3' + r = m.post(compile('user.do'), + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' % (device_id, device_class)) + r = m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') + d = eapi.devices() + + eiotvacuum = d[0] + eiotvacuum['resource'] = device_resource + return EcoVacsIOT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'base64base64base64base64base64ba', 'na', eiotvacuum) + +def make_ctl(string): + return ET.fromstring('' + string + '')[0] diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index ba79d4c..5e15818 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -51,6 +51,10 @@ def test_xml_to_dict(): x._ctl_to_dict(make_ctl('# ')), {'event': 'life_span', 'type': 'brush', 'val': '099', 'total': '365'}) + assert_dict_equal( + x._ctl_to_dict(make_ctl('')), + {'event': 'life_span', 'type': 'dust_case_heap', 'val': '-050', 'total': '365'}) + def make_ecovacs_xmpp(): return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na') diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index 925168c..b89e3d4 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -270,18 +270,18 @@ def test_handle_unknown_ctl(): # plus errors! def test_bot_address(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob"}) + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot":False}) assert_equals('E0000000001234567890@126.ecorobot.net/atom', v._vacuum_address()) def test_model_variation(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob"}) + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob","iot":False}) assert_equals('E0000000001234567890@141.ecorobot.net/atom', v._vacuum_address()) def a_vacbot(bot=None, monitor=False): if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob"} + bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', bot, 'na', monitor=monitor) From 304c576960bd43df1cec242d831e2c0934f6ddea Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 11:23:27 -0500 Subject: [PATCH 07/48] Add spotarea command tests Add spotarea command tests --- sucks/__init__.py | 28 +++++++++------------------- sucks/cli.py | 2 +- tests/test_commands.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index e535d29..323eb08 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -792,26 +792,16 @@ class Stop(Clean): super().__init__('stop', 'normal') class SpotArea(Clean): - def __init__(self, **kwargs): - self.action='start' - self.mid='' - self.p='' - self.deep='' - if kwargs is not None: - for kkey, kvalue in kwargs.items(): - if kkey == 'action': - self.action = kvalue - elif kkey == 'mid': - self.mid = kvalue - elif kkey == 'p': - self.p = kvalue - elif kkey == 'deep': - self.deep = kvalue + def __init__(self, action='start', namedarea='', customarea='', cleanings='1'): + - if self.mid != '': #For cleaning specified map area - super().__init__('spotarea', 'normal', act=CLEAN_ACTION_TO_ECOVACS[self.action], mid=self.mid) - elif self.p != '': #For cleaning custom map area, and specify deep amount 1x/2x - super().__init__('spotarea' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[self.action], p=self.p, deep=self.deep) + if namedarea != '': #For cleaning specified map area + super().__init__('spotarea', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=namedarea) + elif customarea != '': #For cleaning custom map area, and specify deep amount 1x/2x + super().__init__('spotarea' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=customarea, deep=cleanings) + else: + #no valid entries + raise ValueError("must provide namedarea or customarea for spotarea clean") class Charge(VacBotCommand): def __init__(self): diff --git a/sucks/cli.py b/sucks/cli.py index 88b6e24..727b730 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -216,7 +216,7 @@ def run(actions, debug): #vacbot.connect_and_wait_until_ready()( - vacbot.run(SpotArea(action='start', mid='0')) + vacbot.run(SpotArea('start')) #vacbot.request_all_statuses() diff --git a/tests/test_commands.py b/tests/test_commands.py index 6ed18cc..a1f020e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -33,6 +33,42 @@ def test_clean_command(): c = Clean('edge', 'high') assert_equals(ElementTree.tostring(c.to_xml()), b'') # protocol has attribs in other order + + +def test_spotarea_command(): + assert_raises(ValueError, SpotArea, 'start') #Value error if SpotArea doesn't include a mid or p + + c = SpotArea('start', '0') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test namedarea clean + + c = SpotArea('start', namedarea='0') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test namedarea keyword clean + + c = SpotArea('start', '', '01234,56789') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test customarea clean + + c = SpotArea('start', '', '01234,56789', '2') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test customarea clean with deep 2 + + c = SpotArea('start', '', customarea='01234,56789') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test customarea keyword clean with deep default + + c = SpotArea('start', customarea='01234,56789', cleanings='2') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test customarea keyword and cleanings keyword clean with deep default + + c = SpotArea('start', namedarea='0', customarea='01234,56789', cleanings='2') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test all keywords specified, should default to only mid + + c = SpotArea('start', '0', '01234,56789','2') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') #Test all keywords specified, should default to only mid def test_edge_command(): From 62185d135f584e7fb67236d1d5f77aaa264311bf Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 13:13:02 -0500 Subject: [PATCH 08/48] Add spotclean to cli Add spotclean to cli --- .vscode/launch.json | 5 +++-- sucks/__init__.py | 8 ++++---- sucks/cli.py | 25 ++++++++++++------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 19f79d2..fbe4892 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,8 +18,9 @@ "program": "${workspaceFolder}/sucks/cli.py", "args" : [ "--debug", - "clean", - "10" + "spotclean", + "0", + "60" ], "console": "integratedTerminal" }, diff --git a/sucks/__init__.py b/sucks/__init__.py index 323eb08..1321896 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -360,11 +360,11 @@ class VacBot(): def connect_and_wait_until_ready(self): - if self.vacuum['iot']: - self.iot.connect_and_wait_until_ready() - self.iot.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + if not self.vacuum['iot']: + #self.iot.connect_and_wait_until_ready() + #self.iot.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) - else: + #else: self.xmpp.connect_and_wait_until_ready() self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) diff --git a/sucks/cli.py b/sucks/cli.py index 727b730..1971b2c 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -179,6 +179,12 @@ def edge(frequency, minutes): return CliAction(Edge(), wait=TimeWait(minutes * 60)) +@cli.command(help='spotcleans provided room(s) for the specified number of minutes') +@click.argument('room', type=click.STRING) +@click.argument('minutes', type=click.FLOAT) +def spotclean(room, minutes): + return CliAction(SpotArea('start', room), wait=TimeWait(minutes * 60)) + @cli.command(help='returns to charger') def charge(): return charge_action() @@ -209,22 +215,15 @@ def run(actions, debug): if actions: config = read_config() api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent']) + config['country'], config['continent']) 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()( - - - vacbot.run(SpotArea('start')) - - - #vacbot.request_all_statuses() + vacbot.connect_and_wait_until_ready() - # for action in actions: - # click.echo("performing " + str(action.vac_command)) - # vacbot.run(action.vac_command) - # action.wait.wait(vacbot) + for action in actions: + click.echo("performing " + str(action.vac_command)) + vacbot.run(action.vac_command) + action.wait.wait(vacbot) vacbot.disconnect(wait=True) From 484502baddd087026ef12c78fda8881ae3b24a0b Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 13:59:02 -0500 Subject: [PATCH 09/48] Cleanup init code Cleanup init code from adding IOT --- .vscode/launch.json | 2 +- sucks/__init__.py | 52 ++++++++++++++++++--------------------------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index fbe4892..5534cd4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,7 @@ "--debug", "spotclean", "0", - "60" + "1" ], "console": "integratedTerminal" }, diff --git a/sucks/__init__.py b/sucks/__init__.py index 1321896..a75c5eb 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -353,7 +353,8 @@ class VacBot(): if vacuum['iot']: self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) - self.iot.subscribe_to_ctls(self._handle_ctl) + #TODO: How to handle subscriptions for IOT + #self.iot.subscribe_to_ctls(self._handle_ctl) else: self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) self.xmpp.subscribe_to_ctls(self._handle_ctl) @@ -361,17 +362,21 @@ class VacBot(): def connect_and_wait_until_ready(self): if not self.vacuum['iot']: - #self.iot.connect_and_wait_until_ready() - #self.iot.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) - - #else: self.xmpp.connect_and_wait_until_ready() self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + + #else: #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) 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.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + else: + #For IOT go ahead and refresh components + self.refresh_components() def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -391,7 +396,6 @@ class VacBot(): _LOGGER.warning("Unknown component type: '" + type + "'") if 'val' in event: - lifespan = int(event['val']) / 100 else: lifespan = int(event['left']) / 60 #This works for a D901 @@ -470,10 +474,7 @@ class VacBot(): return self.vacuum_status in CLEANING_STATES def send_ping(self): - if self.vacuum['iot']: - #TODO - print("IOT Ping") - else: + if not self.vacuum['iot']: try: self.xmpp.send_ping(self._vacuum_address()) except XMPPError as err: @@ -495,6 +496,8 @@ class VacBot(): if self.vacuum_status == 'offline': self.vacuum_status = None self.statusEvents.notify(self.vacuum_status) + #else: #TODO determine how to handle send_ping for IOT device + #print("IOT Ping") def refresh_components(self): try: @@ -549,11 +552,11 @@ class EcoVacsIOT(): self.ctl_subscribers = [] self.ready_flag = Event() - - def connect_and_wait_until_ready(self): - self.connect(EcoVacsAPI._EcoVacsAPI__call_portal_api()) - self.process() - self.wait_until_ready() + #TODO: Determine what to do with IOT connect and wait + # def connect_and_wait_until_ready(self): + # self.connect(EcoVacsAPI._EcoVacsAPI__call_portal_api()) + # self.process() + # self.wait_until_ready() def send_command(self, action, recipient): c = self._wrap_command(action, recipient) @@ -562,7 +565,6 @@ class EcoVacsIOT(): def _wrap_command(self, cmd, recipient): - return { 'auth': { 'realm': EcoVacsAPI.REALM, @@ -580,11 +582,9 @@ class EcoVacsIOT(): "toType": self.vacuum['class'] } - - - def subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) + # def subscribe_to_ctls(self, function): + # self.ctl_subscribers.append(function) def _handle_ctl(self, action, message): @@ -618,16 +618,6 @@ class EcoVacsIOT(): return result - 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)) - self.ready_flag.set() - - - class EcoVacsXMPP(ClientXMPP): def __init__(self, user, domain, resource, secret, continent, server_address=None): ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) From 205214c9a1a37808d98d380a0dd4c7a7a8a26a5e Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 14:14:46 -0500 Subject: [PATCH 10/48] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8563ea6..babdeec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ dist # Nosetests files cover/ .coverage -.vscode/settings.json +.vscode/ From d070ab7812d2814699d9c8cf694134a2671414d9 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 14:15:20 -0500 Subject: [PATCH 11/48] Delete launch.json --- .vscode/launch.json | 77 --------------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 5534cd4..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Current File (Integrated Terminal)", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - }, - { - "name": "Python: Run Sucks CLI (Integrated Terminal)", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/sucks/cli.py", - "args" : [ - "--debug", - "spotclean", - "0", - "1" - ], - "console": "integratedTerminal" - }, - { - "name": "Python: Attach", - "type": "python", - "request": "attach", - "port": 5678, - "host": "localhost" - }, - { - "name": "Python: Module", - "type": "python", - "request": "launch", - "module": "enter-your-module-name-here", - "console": "integratedTerminal" - }, - { - "name": "Python: Django", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/manage.py", - "console": "integratedTerminal", - "args": [ - "runserver", - "--noreload", - "--nothreading" - ], - "django": true - }, - { - "name": "Python: Flask", - "type": "python", - "request": "launch", - "module": "flask", - "env": { - "FLASK_APP": "app.py" - }, - "args": [ - "run", - "--no-debugger", - "--no-reload" - ], - "jinja": true - }, - { - "name": "Python: Current File (External Terminal)", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "externalTerminal" - } - ] -} \ No newline at end of file From ef467acaf8fe19d5694dd80b71a520f922a980c6 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 14 Jan 2019 14:20:25 -0500 Subject: [PATCH 12/48] Remove egg & update gitignore for vscode files Remove egg & update gitignore for vscode files --- .gitignore | 2 ++ sucks.egg-info/PKG-INFO | 18 ------------------ sucks.egg-info/SOURCES.txt | 10 ---------- sucks.egg-info/dependency_links.txt | 1 - sucks.egg-info/entry_points.txt | 3 --- sucks.egg-info/requires.txt | 10 ---------- sucks.egg-info/top_level.txt | 1 - 7 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 sucks.egg-info/PKG-INFO delete mode 100644 sucks.egg-info/SOURCES.txt delete mode 100644 sucks.egg-info/dependency_links.txt delete mode 100644 sucks.egg-info/entry_points.txt delete mode 100644 sucks.egg-info/requires.txt delete mode 100644 sucks.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore index babdeec..c7bc22f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ dist # Nosetests files cover/ .coverage + +# Ignore Vscode files .vscode/ diff --git a/sucks.egg-info/PKG-INFO b/sucks.egg-info/PKG-INFO deleted file mode 100644 index cc6c67e..0000000 --- a/sucks.egg-info/PKG-INFO +++ /dev/null @@ -1,18 +0,0 @@ -Metadata-Version: 2.1 -Name: sucks -Version: 0.9.3 -Summary: a library for controlling certain robot vacuums -Home-page: https://github.com/wpietri/sucks -Author: William Pietri -Author-email: sucks-users@googlegroups.com -License: GPL-3.0 -Description: UNKNOWN -Keywords: home automation vacuum robot -Platform: UNKNOWN -Classifier: Development Status :: 4 - Beta -Classifier: Intended Audience :: Developers -Classifier: Topic :: Software Development :: Libraries -Classifier: Topic :: Home Automation -Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) -Classifier: Programming Language :: Python :: 3.5 -Provides-Extra: dev diff --git a/sucks.egg-info/SOURCES.txt b/sucks.egg-info/SOURCES.txt deleted file mode 100644 index 399d04a..0000000 --- a/sucks.egg-info/SOURCES.txt +++ /dev/null @@ -1,10 +0,0 @@ -README.md -setup.py -sucks/__init__.py -sucks/cli.py -sucks.egg-info/PKG-INFO -sucks.egg-info/SOURCES.txt -sucks.egg-info/dependency_links.txt -sucks.egg-info/entry_points.txt -sucks.egg-info/requires.txt -sucks.egg-info/top_level.txt \ No newline at end of file diff --git a/sucks.egg-info/dependency_links.txt b/sucks.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/sucks.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sucks.egg-info/entry_points.txt b/sucks.egg-info/entry_points.txt deleted file mode 100644 index b630b0c..0000000 --- a/sucks.egg-info/entry_points.txt +++ /dev/null @@ -1,3 +0,0 @@ -[console_scripts] -sucks = sucks.cli:cli - diff --git a/sucks.egg-info/requires.txt b/sucks.egg-info/requires.txt deleted file mode 100644 index 6e9e16c..0000000 --- a/sucks.egg-info/requires.txt +++ /dev/null @@ -1,10 +0,0 @@ -sleekxmpp>=1.3 -click>=6 -requests>=2.18 -pycryptodome>=3.4 -pycountry-convert>=0.5 -stringcase>=1.2 - -[dev] -nose -requests-mock>=1.3 diff --git a/sucks.egg-info/top_level.txt b/sucks.egg-info/top_level.txt deleted file mode 100644 index b735fc9..0000000 --- a/sucks.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -sucks From 4ca97507384e71b37a0f127a8969c7e790e2f9eb Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 15 Jan 2019 02:24:49 -0500 Subject: [PATCH 13/48] Fix xmpp Fix xmpp to work with iot --- sucks/__init__.py | 132 +++++++++++++++++++++---------------- tests/test_ecovacs_iot.py | 12 +++- tests/test_ecovacs_xmpp.py | 6 +- 3 files changed, 87 insertions(+), 63 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index a75c5eb..9c7e658 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -127,9 +127,13 @@ class EcoVacsAPI: 'lang': 'en', 'deviceId': device_id, 'appCode': 'i_eco_e', + #'appCode': 'i_eco_a' - iphone 'appVersion': '1.3.5', + #'appVersion': '1.4.6' - iphone 'channel': 'c_googleplay', + #'channel': 'c_iphone', - iphone 'deviceType': '1' + #'deviceType': '2' - iphone } _LOGGER.debug("Setting up EcoVacsAPI") self.resource = device_id[0:8] @@ -216,11 +220,18 @@ class EcoVacsAPI: if api == self.IOTDEVMANAGERAPI: if json['ret'] == 'ok': return json - elif json['ret'] == 'fail' and json['debug'] == 'wait for response timed out': #Maybe handle timeout for IOT better in the future - _LOGGER.error("call to {} failed with {}".format(function, json)) - return {} - #raise RuntimeError( - # "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) + 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 {} failed with {}".format(function, json)) + return {} + else: + #TODO - Not sure if we want to raise an error yet, just return empty for now + _LOGGER.error("call to {} failed with {}".format(function, json)) + return {} + #raise RuntimeError( + #"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) if api.startswith(self.PRODUCTAPI): if json['code'] == 0: @@ -352,18 +363,17 @@ class VacBot(): self.errorEvents = EventEmitter() if vacuum['iot']: - self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) - #TODO: How to handle subscriptions for IOT - #self.iot.subscribe_to_ctls(self._handle_ctl) - else: - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) - self.xmpp.subscribe_to_ctls(self._handle_ctl) + self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) + self.iot.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) def connect_and_wait_until_ready(self): - if not self.vacuum['iot']: - 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: #ToDo identify the best way to handle similar for IOT devices #self.iot.connect_and_wait_until_ready() @@ -372,11 +382,11 @@ class VacBot(): if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds self.send_ping() - if not self.vacuum['iot']: - self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) - else: + #if not self.vacuum['iot']: + self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) + #else: #For IOT go ahead and refresh components - self.refresh_components() + # self.refresh_components() def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -418,6 +428,8 @@ class VacBot(): cleaning = event.get('st', None) if cleaning == 'p': self.clean_status = 'paused' + elif cleaning == 'h': + self.clean_status = 'standby' else: self.clean_status = 'cleaning' @@ -474,30 +486,30 @@ class VacBot(): return self.vacuum_status in CLEANING_STATES def send_ping(self): - if not self.vacuum['iot']: - try: + try: + if not self.vacuum['iot']: 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) - _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: - # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded - if self.vacuum_status == 'offline' or self.vacuum_status is None: - self.request_all_statuses() - else: - # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown - if self.vacuum_status == 'offline': - self.vacuum_status = None - self.statusEvents.notify(self.vacuum_status) - #else: #TODO determine how to handle send_ping for IOT device - #print("IOT 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.") + _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: + # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded + if self.vacuum_status == 'offline' or self.vacuum_status is None: + self.request_all_statuses() + else: + # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown + if self.vacuum_status == 'offline': + self.vacuum_status = None + self.statusEvents.notify(self.vacuum_status) def refresh_components(self): try: @@ -537,6 +549,14 @@ class VacBot(): if not self.vacuum['iot']: self.xmpp.disconnect(wait=wait) +#This is used by EcoVacsIOT and EcoVacsXMPP for _ctl_to_dict +def RepresentsInt(stringvar): + try: + int(stringvar) + return True + except ValueError: + return False + class EcoVacsIOT(): def __init__(self, user, domain, resource, secret, continent, vacuum): self.uid = user @@ -552,7 +572,7 @@ class EcoVacsIOT(): self.ctl_subscribers = [] self.ready_flag = Event() - #TODO: Determine what to do with IOT connect and wait + #TODO: Determine what to do with IOT connect and wait, or scrap # def connect_and_wait_until_ready(self): # self.connect(EcoVacsAPI._EcoVacsAPI__call_portal_api()) # self.process() @@ -583,8 +603,8 @@ class EcoVacsIOT(): } - # def subscribe_to_ctls(self, function): - # self.ctl_subscribers.append(function) + def subscribe_to_ctls(self, function): + self.ctl_subscribers.append(function) def _handle_ctl(self, action, message): @@ -613,19 +633,22 @@ class EcoVacsIOT(): result['event'] = action.name.replace("Get","",1) for key in result: - result[key] = stringcase.snakecase(result[key]) + if not RepresentsInt(result[key]): #Fix to handle negative int values + result[key] = stringcase.snakecase(result[key]) return result class EcoVacsXMPP(ClientXMPP): - def __init__(self, user, domain, resource, secret, continent, server_address=None): + def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) self.user = user self.domain = domain self.resource = resource + self.apiresource = 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') @@ -667,18 +690,11 @@ class EcoVacsXMPP(ClientXMPP): result.update(xml[0].attrib) for key in result: - if not self.RepresentsInt(result[key]): #Fix to handle negative int values + if not RepresentsInt(result[key]): #Fix to handle negative int values result[key] = stringcase.snakecase(result[key]) return result - def RepresentsInt(self, stringvar): - try: - int(stringvar) - return True - except ValueError: - return False - def register_callback(self, kind, function): self.register_handler(Callback(kind, MatchXPath('{jabber:client}iq/{com:ctl}query/{com:ctl}ctl[@td="' + kind + '"]'), @@ -698,7 +714,11 @@ class EcoVacsXMPP(ClientXMPP): return q def _my_address(self): - return self.user + '@' + self.domain + '/' + self.boundjid.resource + if not self.vacuum['iot']: + return self.user + '@' + self.domain + '/' + self.boundjid.resource + else: + return self.user + '@' + self.domain + '/' + self.apiresource + def send_ping(self, to): q = self.make_iq_get(ito=to, ifrom=self._my_address()) @@ -711,7 +731,6 @@ class EcoVacsXMPP(ClientXMPP): self.process() self.wait_until_ready() - class VacBotCommand: ACTION = { 'forward': 'forward', @@ -757,7 +776,6 @@ class VacBotCommand: class Clean(VacBotCommand): - #def __init__(self, mode='auto', speed='normal', terminal=False): - Keeping original in case def __init__(self, mode='auto', speed='normal', terminal=False, **kwargs): if kwargs is None: super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) @@ -783,8 +801,6 @@ class Stop(Clean): class SpotArea(Clean): def __init__(self, action='start', namedarea='', customarea='', cleanings='1'): - - if namedarea != '': #For cleaning specified map area super().__init__('spotarea', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=namedarea) elif customarea != '': #For cleaning custom map area, and specify deep amount 1x/2x diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py index 3cc949c..9f4ec1b 100644 --- a/tests/test_ecovacs_iot.py +++ b/tests/test_ecovacs_iot.py @@ -21,6 +21,13 @@ def test_wrap_command(): def test_is_iot(): x = make_ecovacs_iot() +# TODO - Error response from command +#'cmdName': 'Charge', 'payload': '', 'payloadType': 'x', 'td': 'q', 'toId': '0e084f6c-0846-4342-a947-fe14c293301f', 'toRes': 'wC3g', 'toType': 'ls1ok3'} +# - Already charging on dock +#{'ret': 'ok', 'resp': "", 'id': 'NLQy'} + +#Timeout +# {'ret': 'fail', 'errno': 500, 'debug': 'wait for response timed out'} # def test_subscribe_to_ctls(): # response = None @@ -63,11 +70,10 @@ def make_ecovacs_iot(): eapi = make_api() with requests_mock.mock() as m: - device_id = 'E0000001234567890123' device_resource = 'test_resource' - device_class = 'ls1ok3' + device_class = 'ls1ok3' #this is for a D900 series r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' % (device_id, device_class)) + text='{"todo": "result", "devices": [{"did": "E0000000001234567890", "class": "%s", "nick": "bob"}], "result": "ok"}' % (device_class)) r = m.post(compile('pim/product/getProductIotMap'), text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') d = eapi.devices() diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index 5e15818..cdfc3c7 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -56,8 +56,10 @@ def test_xml_to_dict(): {'event': 'life_span', 'type': 'dust_case_heap', 'val': '-050', 'total': '365'}) -def make_ecovacs_xmpp(): - return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na') +def make_ecovacs_xmpp(bot=None): + if bot is None: + bot = bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} + return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) def make_ctl(string): From 335ac6a7e11785f0ba4c5bb11a5c05a89ab9ba48 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 15 Jan 2019 10:16:09 -0500 Subject: [PATCH 14/48] Handle already charging Handle already charging Small cleanup --- sucks/__init__.py | 70 +++++++++++++++++++++++++------------------- tests/test_vacbot.py | 3 ++ 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 9c7e658..272763f 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -278,13 +278,13 @@ class EcoVacsAPI: })['data'] def SetIOTDevices(self, devices, iotproducts): - for device in devices: #Check if the device is part of iotProducts and add an iot flag. + for device in devices: #Check if the device is part of iotProducts for iotProduct in iotproducts: - if device['class'] == iotProduct['classid']: - device['iot'] = True - else: + if not device['class'] == iotProduct['classid']: device['iot'] = False - + else: + device['iot'] = True #If it is add an iot flag. + return devices def devices(self): @@ -371,22 +371,17 @@ class VacBot(): def connect_and_wait_until_ready(self): - #if not self.vacuum['iot']: self.xmpp.connect_and_wait_until_ready() self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) - #else: #ToDo identify the best way to handle similar for IOT devices + #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) if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds self.send_ping() - #if not self.vacuum['iot']: self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) - #else: - #For IOT go ahead and refresh components - # self.refresh_components() def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -424,7 +419,7 @@ class VacBot(): self.clean_status = type self.vacuum_status = type - if self.vacuum['iot']: + if self.vacuum['iot']: #Was able to parse additional status from the IOT, may apply to XMPP too cleaning = event.get('st', None) if cleaning == 'p': self.clean_status = 'paused' @@ -456,7 +451,14 @@ class VacBot(): _LOGGER.debug("*** battery_status = {:.0%}".format(self.battery_status)) def _handle_charge_state(self, event): - status = event['type'] + if 'type' in event: + status = event['type'] + elif 'errno' in event: #Handle error + if event['ret'] == 'fail' and event['errno'] == '8': #Already charging + status = 'slot_charging' + else: + _LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors + try: status = CHARGE_MODE_FROM_ECOVACS[status] except KeyError: @@ -511,6 +513,9 @@ 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')) @@ -521,7 +526,7 @@ class VacBot(): _LOGGER.debug("*** Error type: " + err.etype) _LOGGER.debug("*** Error condition: " + err.condition) - def request_all_statuses(self): + def refresh_statuses(self): try: self.run(GetCleanState()) self.run(GetChargeState()) @@ -530,24 +535,22 @@ class VacBot(): _LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.") _LOGGER.debug("*** Error type: " + err.etype) _LOGGER.debug("*** Error condition: " + err.condition) - else: - self.refresh_components() + + def request_all_statuses(self): + self.refresh_statuses() + self.refresh_components() def send_command(self, action): - if self.vacuum['iot']: - self.iot.send_command(action, self._vacuum_address()) + if not self.vacuum['iot']: + self.xmpp.send_command(action.to_xml(), self._vacuum_address()) else: - self.xmpp.send_command(action, self._vacuum_address()) - + self.iot.send_command(action, self._vacuum_address()) #IOT devices need the full action for additional parsing + def run(self, action): - if self.vacuum['iot']: - self.send_command(action) - else: - self.send_command(action.to_xml()) + self.send_command(action) def disconnect(self, wait=False): - if not self.vacuum['iot']: - self.xmpp.disconnect(wait=wait) + self.xmpp.disconnect(wait=wait) #This is used by EcoVacsIOT and EcoVacsXMPP for _ctl_to_dict def RepresentsInt(stringvar): @@ -613,7 +616,7 @@ class EcoVacsIOT(): if resp is not None: for s in self.ctl_subscribers: s(resp) - + def _ctl_to_dict(self, action, xmlstring): xml = ET.fromstring(xmlstring) @@ -624,13 +627,21 @@ class EcoVacsIOT(): #Fix for difference in XMPP vs IOT 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'] = xmlchild[0].tag + "_report" + 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 @@ -646,7 +657,6 @@ class EcoVacsXMPP(ClientXMPP): self.user = user self.domain = domain self.resource = resource - self.apiresource = resource self.continent = continent self.vacuum = vacuum self.credentials['authzid'] = user @@ -717,7 +727,7 @@ class EcoVacsXMPP(ClientXMPP): if not self.vacuum['iot']: return self.user + '@' + self.domain + '/' + self.boundjid.resource else: - return self.user + '@' + self.domain + '/' + self.apiresource + return self.user + '@' + self.domain + '/' + self.resource def send_ping(self, to): diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index b89e3d4..4cdc242 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -43,6 +43,9 @@ def test_handle_charge_state(): v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) assert_equals('idle', v.charge_status) + 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', 'type': 'a_type_not_supported_by_sucks'}) assert_equals('a_type_not_supported_by_sucks', v.charge_status) From 3c01cd2678d6c408918f25223beec21e6ca513cf Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Tue, 15 Jan 2019 11:09:49 -0500 Subject: [PATCH 15/48] Set timeout for IOT api calls Set timeout to 1.25 secs for IOT API - Some commands (Move) never return a status, so this allows things to continue moving along without waiting too long --- sucks/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 272763f..cbd0026 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -209,8 +209,18 @@ class EcoVacsAPI: params = {} params.update(args) + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) - response = requests.post(url, json=params) + response = None + 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 + except requests.exceptions.ReadTimeout: + _LOGGER.debug("call to {} failed with ReadTimeout".format(function)) + return {} + json = response.json() _LOGGER.debug("got {}".format(json)) if api == self.USERSAPI: From ca7d37c1937180bb1244da3bc0173b7c551cdc7f Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 16 Jan 2019 09:02:03 -0500 Subject: [PATCH 16/48] 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 17/48] 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 18/48] 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 19/48] 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 20/48] 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 From 605b1b5523c061bf84318a7a512f788618ffee8e Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 18 Jan 2019 01:31:48 -0500 Subject: [PATCH 21/48] Add to protocol.md Add to protocol.md --- protocol.md | 187 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 33 deletions(-) diff --git a/protocol.md b/protocol.md index 98db5ee..5943dde 100644 --- a/protocol.md +++ b/protocol.md @@ -1,8 +1,8 @@ # Ecovacs Protocol -There are two protocols involved in the communication between the client and Ecovacs systems. There are a series of HTTPS requests +Depending on the device there are a few different protocols involved in the communication between the client and Ecovacs systems. There are a series of HTTPS requests used to log in and find devices. Once logged in, you get a token that is -used to connect to an XMPP server, which mediates communication with the +used for connecting to different services. In many cases this involves connecting to an XMPP server, which mediates communication with the vacuum. That's right, your robot housecleaner, like an errant teen, spends all its free time hanging out in an internet chat room. @@ -22,26 +22,16 @@ For example, a Canadian user must authenticate on country-specific HTTPS server, but XMPP commands work both on the worldwide server msg-ww.ecouser.net) and the North America server (msg-na.ecouser.net) -The Android App uses the following XMPP messaging servers: - -``` -CH: msg.ecouser.net -TW, MY, JP, SG, TH, HK, IN, KR: msg-as.ecouser.net -US: msg-na.ecouser.net -FR, ES, UK, NO, MX, DE, PT, CH, AU, IT, NL, SE, BE, DK: msg-eu.ecouser.net -Any other country: msg-ww.ecouser.net -``` - ## HTTPS There are two sorts of URLs in the basic login flow. The first set use a format like this: -``` +` https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType} -``` - +` + They also have a complicated API request signature that seems overelaborate to me. See the Python code for more details. @@ -53,32 +43,118 @@ access token. 3. GET eco-us-api.ecovacs.com ... user/getAuthCode - sends uid, accessToken; gets back an auth code + *Under mysterious circumstances, for some people the getAuthCode call will + return a different userId than is passed in. In that case, apparently the new userId should be used for future calls, or an Auth 1004 error results.* + Now we switch to posting to a different server, and the request and response style change substantially. I think of this at the user server, or perhaps the XMPP/device server. +` + https://portal-{continent}.ecouser.net/api +` -4. POST users-na.ecouser.net:8000/user.do loginByItToken - trades the +There are a few different endpoints within the API that have been seen and are used in the library: + +| Endpoint | Description | +| ----------------------------- | ----------------------------------------- | +| /users/user.do | Handles user / account functions | +| /iot/devmanager.do | Handles sending commands to "IOT" devices | +| /pim/product/getProductIotMap | Provides the "IOT" Product map | + + + +4. POST portal-na.ecouser.net/api/users/user.do loginByItToken - trades the authCode from the previous call for yet another token 5. POST ne-na.ecouser.net:8018/notify_engine.do - not sure what this is for; my script skips this and seems to work fine -6. POST users-na.ecouser.net:8000/user.do GetDeviceList - Using the token +6. POST portal-na.ecouser.net/api/users/user.do GetDeviceList - Using the token from step 4, gets the list of devices; that's needed for talking to the vacuum via XMPP +7. POST +portal-na.ecouser.net/api/pim/product/getProductIotMap +getProductIotMap - Provides a list of "IOT" products, the devices are referenced in the table below and these are assumed to be "IOT" devices within the library. -Under mysterious circumstances, for some people the getAuthCode call will -return a different userId than is passed in. In that case, apparently the -new userId should be used for future calls, or an Auth 1004 error results. + |IOT Products | + |---| + |DEEBOT 600 Series| + |DEEBOT OZMO Slim10 Series | + |DEEBOT OZMO 900| + |DEEBOT 711| + |DEEBOT 710| + |DEEBOT 900 Series| -## XMPP + +At this point depending on your device you will connect to either an XMPP server, or an MQTT server. This is believed to be based on the "IOT Products" vs "Non-IOT" products. +| "Non-IOT" Products | "IOT" Products | +|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Connect to an XMPP server to send commands to devices and receive status results | Connect to an MQTT server to subscribe to status messages and results. A Rest API is utilized to send commands to devices, but can also be used to obtain statuses. | + + +## XMPP - ("Non-IOT") The app establishes a connection to an XMPP server and logs in using a secret that comes from the earlier HTTPS calls. It then sends XMPP IQ commands. It describes them as queries, but they all contain "ctl" elements that appear to be commands. +The Android App uses the following XMPP messaging servers: +|Country|URL| +|------|--------| +|CH|msg.ecouser.net| +|TW, MY, JP, SG, TH, HK, IN, KR|msg-as.ecouser.net| +|US|msg-na.ecouser.net| +|FR, ES, UK, NO, MX, DE, PT, CH, AU, IT, NL, SE, BE, DK|msg-eu.ecouser.net| +|Any other|msg-ww.ecouser.net| + +## MQTT - ("IOT") + +The app establishes a connection to an MQTT server and logs in using +a secret that comes from the earlier HTTPS calls. + +It then subscribes to a topic where various status and result messages are published by the device. +The topic looks like this: + +` +iot/atr/+/{deviceID}/{deviceClass}/{deviceResource}/+ +` + +It is believed the MQTT servers mirror the XMPP servers, but only the NA and WW have been tested so far. + +|Country|URL| +|------|--------| +|US|mq-na.ecouser.net| +|"World-wide"|mq-ww.ecouser.net| + +## Rest API - ("IOT") + +For IOT devices the app sends commands to the device over a Rest API utilizing the secret that comes from the earlier HTTPS calls. This API has only been tested from an "IOT" device, but could possibly work for "Non-IOT" devices as well. + +The Rest API utilizes the same portal URL as used previously, but with the iot/devmanager endpoint: +` + https://portal-{continent}.ecouser.net/api/iot/devmanager.do +` +Commands are sent via POST in the format of: +```json +{ + "auth": { + "realm": EcoVacsAPI.REALM, + "resource": self.resource, + "token": self.secret, + "userid": self.uid, + "with": "users", +}, +"cmdName": cmd.name, +"payload": cmd.args_to_xml(), +"payloadType": "x", +"td": "q", +"toId": recipient, +"toRes": self.vacuum['resource'], +"toType": self.vacuum['class'] +} +``` ### Cleaning @@ -93,6 +169,7 @@ elements that appear to be commands. - type `spot` spot cleaning program - type `singleroom` cleaning a single room - type `stop` bot at full stop + - type `SpotArea` cleaning a mapped room - speed `standard` regular fan speed (suction) - speed `strong` high fan speed (suction) @@ -139,12 +216,14 @@ It's presumed that the timers need to be reset manually. ### Manually moving around -**Command** -- Move forward: `` -- Spin left 360 degrees: `` -- Spin right 360 degrees: `` -- Turn 180 degrees: `` -- Stop the ongoing action: `` +|**Command**|**Control**| +|-|-| +|Move forward|``| +|Move backward|``| +|Spin left 360 degrees|``| +|Spin right 360 degrees|``| +|Turn 180 degrees|``| +|Stop the ongoing action|``| ### Configuration @@ -180,16 +259,58 @@ HostHang, then proceeds to stop and broadcasts 100 NoError. **Known error codes** -- 100 NoError: Robot is operational -- 101 BatteryLow: Low battery -- 102 HostHang: Robot is stuck -- 103 WheelAbnormal: Wheels are not moving as expected -- 104 DownSensorAbnormal: Down sensor is getting abnormal values -- 110 NoDustBox: Dust Bin Not installed +|Code|Description| +|-|-| +|100|NoError: Robot is operational| +|101|BatteryLow: Low battery| +|102|HostHang: Robot is stuck| +|103|WheelAbnormal: Wheels are not moving as expected| +|104|DownSensorAbnormal: Down sensor is getting abnormal values| +|110|NoDustBox: Dust Bin Not installed| These codes are taken from model M81 Pro. Error codes may differ between models. +### Sounds +Different sid "Sound IDs" will play different sounds. If the vacuum has Voice Report disabled, these won't play. + +`` + +| SID | Description | +|-----|------------------------------------------------------------| +| 0 | Startup Music Chime | +| 3 | I Am Suspended | +| 4 | Check Driving Wheels | +| 5 | Please Help Me Out | +| 6 | Please Install Dust Bin | +| 17 | Chime / Beep | +| 18 | My Battery Is Low | +| 29 | Please power me on before charging | +| 30 | I Am Here | +| 31 | Brush is tangled please clean my brush | +| 35 | Please clean my antidrop sensors | +| 48 | Brush is tangled | +| 55 | I am relocating | +| 56 | Upgrade succeeded | +| 63 | I am returning to the charging dock | +| 65 | Cleaning paused | +| 69 | Connected please go back to ecovacs app to continue setup | +| 71 | I am restoring the map please do not stand beside me | +| 73 | My battery is low returning to the charging dock | +| 74 | Difficult to locate I am starting a new cleaning cycle | +| 75 | I am resuming the clean | +| 76 | Upgrade failed please try again | +| 77 | Please place me on the charging dock | +| 79 | Resume the clean | +| 80 | I am starting the clean | +| 81 | I am starting the clean | +| 82 | I am starting the clean | +| 84 | I am ready for mopping | +| 85 | Please remove the mopping plate when I am building the map | +| 86 | Cleaning is complete returning to the charging dock | +| 89 | LVS Malfunction please try to tap the LVS | +| 90 | I am upgrading please wait | + ### Untested commands From 10993e678a88653af190eba308bec1f28c7944c4 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 18 Jan 2019 01:37:40 -0500 Subject: [PATCH 22/48] Fix tables in protocol Fix tables in protocol --- protocol.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/protocol.md b/protocol.md index 5943dde..04af3a0 100644 --- a/protocol.md +++ b/protocol.md @@ -56,6 +56,7 @@ the XMPP/device server. There are a few different endpoints within the API that have been seen and are used in the library: + | Endpoint | Description | | ----------------------------- | ----------------------------------------- | | /users/user.do | Handles user / account functions | @@ -87,6 +88,7 @@ getProductIotMap - Provides a list of "IOT" products, the devices are referenced At this point depending on your device you will connect to either an XMPP server, or an MQTT server. This is believed to be based on the "IOT Products" vs "Non-IOT" products. + | "Non-IOT" Products | "IOT" Products | |----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Connect to an XMPP server to send commands to devices and receive status results | Connect to an MQTT server to subscribe to status messages and results. A Rest API is utilized to send commands to devices, but can also be used to obtain statuses. | @@ -140,19 +142,19 @@ Commands are sent via POST in the format of: ```json { "auth": { - "realm": EcoVacsAPI.REALM, - "resource": self.resource, - "token": self.secret, - "userid": self.uid, + "realm": "ecouser.net", + "resource": "resource", + "token": "token", + "userid": "userid", "with": "users", }, -"cmdName": cmd.name, -"payload": cmd.args_to_xml(), +"cmdName": "cmd.name", +"payload": "cmd.args", "payloadType": "x", "td": "q", -"toId": recipient, -"toRes": self.vacuum['resource'], -"toType": self.vacuum['class'] +"toId": "vacuum.serial", +"toRes": "vacuum.resource", +"toType": "vacuum.class" } ``` @@ -259,8 +261,9 @@ HostHang, then proceeds to stop and broadcasts 100 NoError. **Known error codes** + |Code|Description| -|-|-| +|-----|-----| |100|NoError: Robot is operational| |101|BatteryLow: Low battery| |102|HostHang: Robot is stuck| @@ -276,7 +279,7 @@ Different sid "Sound IDs" will play different sounds. If the vacuum has Voice R `` -| SID | Description | +|SID |Description | |-----|------------------------------------------------------------| | 0 | Startup Music Chime | | 3 | I Am Suspended | From f3bcfb4ddc0c7e7317eaba1b443f883919962ae3 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 20 Jan 2019 16:30:53 -0500 Subject: [PATCH 23/48] Add more iot tests Add more iot tests --- sucks/__init__.py | 19 +++++-- tests/test_ecovacs_iot.py | 117 +++++++++++++++++++++++++------------- tests/test_vacbot.py | 21 ++++++- 3 files changed, 110 insertions(+), 47 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 55977fd..3ce82ca 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -416,9 +416,14 @@ class VacBot(): getattr(self, method)(ctl) def _handle_error(self, event): - error = event['error'] - self.errorEvents.notify(error) - _LOGGER.debug("*** error = " + error) + if 'error' in event: + error = event['error'] + elif 'errs' in event: + error = event['errs'] + + if not error == '': + self.errorEvents.notify(error) + _LOGGER.debug("*** error = " + error) def _handle_life_span(self, event): type = event['type'] @@ -483,6 +488,10 @@ class VacBot(): elif 'errno' in event: #Handle error if event['ret'] == 'fail' and event['errno'] == '8': #Already charging status = 'slot_charging' + elif event['ret'] == 'fail' and event['errno'] == '5': #Busy with another command + status = 'idle' + elif event['ret'] == 'fail' and event['errno'] == '3': #Bot in stuck state, example dust bin out + status = 'idle' else: status = 'idle' #Fall back to Idle status _LOGGER.error("Unknown charging status '" + event['errno'] + "'") #Log this so we can identify more errors @@ -632,7 +641,9 @@ class EcoVacsIOT(): 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 )) + self._handle_ctl(action, + self.api._EcoVacsAPI__call_portal_api(self.api, self.api.IOTDEVMANAGERAPI,'',c ) + ) def _wrap_command(self, cmd, recipient): diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py index 9f4ec1b..04c2604 100644 --- a/tests/test_ecovacs_iot.py +++ b/tests/test_ecovacs_iot.py @@ -1,15 +1,21 @@ from re import compile import requests_mock +import requests from nose.tools import * from sucks import * -from test_ecovacs_api import make_api +from tests.test_ecovacs_api import make_api # There are few tests for the IOT stuff here because it's relatively complicated to test given # the library's design and its multithreaded nature and lack of explicit testing support. + +def test_is_iot(): + x = make_ecovacs_iot() + assert_equal(x.vacuum['iot'], True) + def test_wrap_command(): x = make_ecovacs_iot() @@ -18,52 +24,81 @@ def test_wrap_command(): assert_equal(c['toId'], 'E0000000001234567890') assert_equal(c['payload'], '') -def test_is_iot(): +def test_iotapi_response(): x = make_ecovacs_iot() - -# TODO - Error response from command -#'cmdName': 'Charge', 'payload': '', 'payloadType': 'x', 'td': 'q', 'toId': '0e084f6c-0846-4342-a947-fe14c293301f', 'toRes': 'wC3g', 'toType': 'ls1ok3'} -# - Already charging on dock -#{'ret': 'ok', 'resp': "", 'id': 'NLQy'} - -#Timeout -# {'ret': 'fail', 'errno': 500, 'debug': 'wait for response timed out'} - -# def test_subscribe_to_ctls(): -# response = None - -# def save_response(value): -# nonlocal response -# response = value - -# x = make_ecovacs_iot() - -# query = x.make_iq_query() -# query.set_payload( -# ET.fromstring(' ')) - -# x.subscribe_to_ctls(save_response) -# x._handle_ctl(query) -# assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) + api = make_api() + x.api = api + + with requests_mock.mock() as m: + + #Test GetCleanState + resp = {"ret":"ok","resp":"","id":"Qgxa"} + r1 = m.post(compile('iot/devmanager.do'), + json=resp) + cmd = VacBotCommand("GetCleanState") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = api._EcoVacsAPI__call_portal_api(api.IOTDEVMANAGERAPI, '', c) + assert_equal(rtnval, {'ret':'ok','resp':"",'id':'Qgxa'}) + + #Test Timeout + r2 = m.post(compile('iot/devmanager.do'),exc=requests.exceptions.ReadTimeout) + cmd = VacBotCommand("GetCleanState") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = api._EcoVacsAPI__call_portal_api(api.IOTDEVMANAGERAPI, '', c) + assert_equal(rtnval, {}) #Right now it sends back a blank object -# def test_xml_to_dict(): -# x = make_ecovacs_iot() +def test_subscribe_to_ctls(): + response = None -# assert_dict_equal( -# x._ctl_to_dict(make_ctl(' ')), -# {'event': 'clean_report', 'type': 'auto'}) -# assert_dict_equal( -# x._ctl_to_dict(make_ctl(' ')), -# {'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) + def save_response(value): + nonlocal response + response = value -# assert_dict_equal( -# x._ctl_to_dict(make_ctl('')), -# {'event': 'battery_info', 'power': '095'}) + x = make_ecovacs_iot() + + x.subscribe_to_ctls(save_response) + message = {} + message['resp'] = ' ' + + x.subscribe_to_ctls(save_response) + x._handle_ctl("Clean", message) + assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) -# assert_dict_equal( -# x._ctl_to_dict(make_ctl('# ')), -# {'event': 'life_span', 'type': 'brush', 'val': '099', 'total': '365'}) + +def test_xml_to_dict(): + x = make_ecovacs_iot() + message = {} + + cmd = VacBotCommand("Clean") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'event': 'clean_report', 'type': 'auto', 'speed': 'standard', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) + + cmd = VacBotCommand("Clean") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'event': 'clean_report', 'type': 'auto', 'speed': 'strong', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) + + cmd = VacBotCommand("GetBatteryInfo") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'event': 'battery_info', 'power': '82'}) + + cmd = VacBotCommand("GetLifeSpan") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) + + cmd = VacBotCommand("Charge") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'event': 'charge_state','ret':'fail', 'errno': '8'}) #Test fail from charge command def make_ecovacs_iot(): diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index 3276767..77a9331 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -45,10 +45,16 @@ def test_handle_charge_state(): v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) assert_equals('idle', v.charge_status) - v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '8'}) #Seen in IOT when already charging + v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '9'}) #Seen in IOT - "but on charger, but turned off" + assert_equals('idle', v.charge_status) + + v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '8'}) #Seen in IOT - could be "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 + v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '5'}) #Seen in IOT - could be "busy with another command" + assert_equals('idle', v.charge_status) + + v._handle_ctl({'event': 'charge_state', 'ret': 'fail', 'errno': '3'}) #Seen in IOT - could be "Bot in stuck state, example dust bin out" assert_equals('idle', v.charge_status) v._handle_ctl({'event': 'charge_state', 'type': 'a_type_not_supported_by_sucks'}) @@ -87,6 +93,14 @@ def test_handle_battery_info(): v._handle_ctl({'event': 'battery_info', 'power': '000'}) assert_equals(0.0, v.battery_status) + +def test_handle_geterrors(): + v = a_vacbot() + + #v._handle_error + + #ssert_equals({}, v.components) + def test_lifespan_reports(): v = a_vacbot() assert_equals({}, v.components) @@ -139,6 +153,9 @@ def test_is_charging(): v._handle_ctl({'event': 'clean_report', 'type': 'edge', 'speed': 'normal'}) assert_false(v.is_charging) + + + def test_send_ping_no_monitor(): #Test XMPP Ping v = a_vacbot() From 1812a212e17965dbf4944731357695bb3570f256 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 20 Jan 2019 22:25:59 -0500 Subject: [PATCH 24/48] Add mqtt tests Add mqtt tests --- sucks/__init__.py | 4 +- tests/test_ecovacs_iot.py | 5 +-- tests/test_ecovacs_mqtt.py | 85 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/test_ecovacs_mqtt.py diff --git a/sucks/__init__.py b/sucks/__init__.py index 3ce82ca..3fe9d17 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -814,7 +814,9 @@ class EcoVacsMQTT(ClientMQTT): result.update(xml[0].attrib) for key in result: - if not RepresentsInt(result[key]): #Fix to handle negative int values + if ',' in result[key]: #Seen in position updates + print(result[key]) + elif not RepresentsInt(result[key]): #Fix to handle negative int values result[key] = stringcase.snakecase(result[key]) return result diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py index 04c2604..fbd5979 100644 --- a/tests/test_ecovacs_iot.py +++ b/tests/test_ecovacs_iot.py @@ -115,7 +115,4 @@ def make_ecovacs_iot(): eiotvacuum = d[0] eiotvacuum['resource'] = device_resource - return EcoVacsIOT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'base64base64base64base64base64ba', 'na', eiotvacuum) - -def make_ctl(string): - return ET.fromstring('' + string + '')[0] + return EcoVacsIOT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'base64base64base64base64base64ba', 'na', eiotvacuum) \ No newline at end of file diff --git a/tests/test_ecovacs_mqtt.py b/tests/test_ecovacs_mqtt.py new file mode 100644 index 0000000..8c567a4 --- /dev/null +++ b/tests/test_ecovacs_mqtt.py @@ -0,0 +1,85 @@ +from re import search + +from nose.tools import * + +from sucks import * +import paho.mqtt + +# There are few tests for the MQTT stuff here because it's relatively complicated to test given +# the library's design and its multithreaded nature and lack of explicit testing support. + +def test_subscribe_to_ctls(): + response = None + + def save_response(value): + nonlocal response + response = value + + x = make_ecovacs_mqtt() + + x.subscribe_to_ctls(save_response) + test_message = paho.mqtt.client.MQTTMessage + test_message.topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + test_message.payload = b"" + x._handle_ctl('','',test_message) + + assert_dict_equal(response, {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + +def test_xml_to_dict(): + x = make_ecovacs_mqtt() + + test_topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + test_topic = 'iot/atr/BatteryInfo/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) + + test_topic = 'iot/atr/SleepStatus/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'sleep_status', 'ts':'1547823129670', 'st': '1'}) + + test_topic = 'iot/atr/errors/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'errors', 'ts':'1547822982581','old':'','new':'102'}) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'errors', 'ts':'1547822982581','old':'102','new':''}) + + test_topic = 'iot/atr/Pos/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'pos', 't':'p', 'p':'7,-10', 'a':'-42','valid':'0'}) + + test_topic = 'iot/atr/DustCaseST/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'dust_case_s_t', 'ts':'1547822871328','st':'1'}) + + test_topic = 'iot/atr/MapSt/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict(test_topic, ""), + {'event': 'map_st', 'ts':'1547823592934', 'st':'reloc_go_chg_start', 'method':'', 'info':''}) + + # #TODO: Find a way to check if string is b64 encoded + # test_topic = 'iot/atr/trace/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + # assert_dict_equal( + # x._ctl_to_dict(test_topic, ""), + # {'event': 'trace', 'trid':'227975', 'tf':'4', 'tr':'XQAABAAKAAAAAB4AMGAQCdAAAAA='}) + + + +def make_ecovacs_mqtt(bot=None): + if bot is None: + bot = bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} + return EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) From 2cfa5ec26b31f74bd1151eeb62e3da288e5283da Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 31 Jan 2019 09:46:04 -0500 Subject: [PATCH 25/48] Add ignore ssl to requests Added verify False to requests. Needed for work on Bumper --- sucks/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 3fe9d17..ab270b4 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -181,7 +181,8 @@ class EcoVacsAPI: params = OrderedDict(args) params['requestId'] = self.md5(time.time()) url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta) - api_response = requests.get(url, self.__sign(params)) + #Ignore SSL + api_response = requests.get(url, self.__sign(params), verify=False) json = api_response.json() _LOGGER.debug("got {}".format(json)) if json['code'] == '0000': @@ -198,7 +199,8 @@ class EcoVacsAPI: _LOGGER.debug("calling user api {} with {}".format(function, args)) params = {'todo': function} params.update(args) - response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params) + #Ignore SSL + response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=False) json = response.json() _LOGGER.debug("got {}".format(json)) if json['result'] == 'ok': @@ -221,10 +223,12 @@ class EcoVacsAPI: url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) response = None if not api == self.IOTDEVMANAGERAPI: - response = requests.post(url, json=params) + #Ignore SSL + response = requests.post(url, json=params, verify=False) else: 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 + #Ignore SSL + response = requests.post(url, json=params, timeout=3, verify=False) #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 {} @@ -764,8 +768,7 @@ class EcoVacsMQTT(ClientMQTT): _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.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 From 57979c32bd7f45f7e6b4f2c9684cb70169f808db Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sun, 3 Feb 2019 15:18:55 -0500 Subject: [PATCH 26/48] Make ssl verification optional Make ssl verification optional Can be set via config manually, or used from library --- sucks/__init__.py | 48 ++++++++++++++++++++++++++++------------------- sucks/cli.py | 4 ++-- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index ab270b4..b6cc953 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -114,6 +114,14 @@ COMPONENT_FROM_ECOVACS = { 'dust_case_heap': COMPONENT_FILTER } +def str_to_bool(s): + if s == 'True': + return True + elif s == 'False': + return False + else: + raise ValueError("Cannot covert {} to a bool".format(s)) + class EcoVacsAPI: CLIENT_KEY = "eJUWrzRv34qFSaYk" SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC" @@ -129,7 +137,7 @@ class EcoVacsAPI: REALM = 'ecouser.net' - def __init__(self, device_id, account_id, password_hash, country, continent): + def __init__(self, device_id, account_id, password_hash, country, continent, verify_ssl=True): self.meta = { 'country': country, 'lang': 'en', @@ -143,6 +151,8 @@ class EcoVacsAPI: 'deviceType': '1' #'deviceType': '2' - iphone } + + self.verify_ssl = str_to_bool(verify_ssl) _LOGGER.debug("Setting up EcoVacsAPI") self.resource = device_id[0:8] self.country = country @@ -181,8 +191,7 @@ class EcoVacsAPI: params = OrderedDict(args) params['requestId'] = self.md5(time.time()) url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta) - #Ignore SSL - api_response = requests.get(url, self.__sign(params), verify=False) + api_response = requests.get(url, self.__sign(params), verify=self.verify_ssl) json = api_response.json() _LOGGER.debug("got {}".format(json)) if json['code'] == '0000': @@ -199,8 +208,7 @@ class EcoVacsAPI: _LOGGER.debug("calling user api {} with {}".format(function, args)) params = {'todo': function} params.update(args) - #Ignore SSL - response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=False) + response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params, verify=self.verify_ssl) json = response.json() _LOGGER.debug("got {}".format(json)) if json['result'] == 'ok': @@ -210,7 +218,7 @@ class EcoVacsAPI: raise RuntimeError( "failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) - def __call_portal_api(self, api, function, args): + def __call_portal_api(self, api, function, args, verify_ssl=True): _LOGGER.debug("calling portal api {} function {} with {}".format(api, function, args)) if api == self.USERSAPI: params = {'todo': function} @@ -223,12 +231,10 @@ class EcoVacsAPI: url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) response = None if not api == self.IOTDEVMANAGERAPI: - #Ignore SSL - response = requests.post(url, json=params, verify=False) + response = requests.post(url, json=params, verify=verify_ssl) else: try: #IOT Device sometimes doesnt provide a response depending on command, reduce timeout to 3 to accomodate and make requests faster - #Ignore SSL - response = requests.post(url, json=params, timeout=3, verify=False) #May think about having timeout as an arg that could be provided in the future + 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 {} failed with ReadTimeout".format(function)) return {} @@ -271,7 +277,7 @@ class EcoVacsAPI: 'realm': EcoVacsAPI.REALM, 'userId': self.uid, 'token': self.auth_code} - ) + , verify_ssl=self.verify_ssl) @@ -285,7 +291,7 @@ class EcoVacsAPI: 'token': self.user_access_token, 'resource': self.resource } - })['devices'] + }, verify_ssl=self.verify_ssl)['devices'] def getiotProducts(self): return self.__call_portal_api(self.PRODUCTAPI + '/getProductIotMap','', { @@ -297,7 +303,7 @@ class EcoVacsAPI: 'token': self.user_access_token, 'resource': self.resource } - })['data'] + }, verify_ssl=self.verify_ssl)['data'] def SetIOTDevices(self, devices, iotproducts): for device in devices: #Check if the device is part of iotProducts @@ -352,7 +358,7 @@ class EventListener(object): self._emitter.unsubscribe(self) class VacBot(): - def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False): + def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False, verify_ssl=True): self.vacuum = vacuum @@ -387,13 +393,16 @@ class VacBot(): self.xmpp = None if vacuum['iot']: - self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum) + self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum, verify_ssl=verify_ssl) self.iot.subscribe_to_ctls(self._handle_ctl) - self.mqtt = EcoVacsMQTT(user, domain, resource, secret, continent, vacuum) + self.mqtt = EcoVacsMQTT(user, domain, resource, secret, continent, vacuum, server_address) 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 = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) self.xmpp.subscribe_to_ctls(self._handle_ctl) @@ -621,7 +630,7 @@ def RepresentsInt(stringvar): return False class EcoVacsIOT(): - def __init__(self, user, domain, resource, secret, continent, vacuum): + def __init__(self, user, domain, resource, secret, continent, vacuum, verify_ssl=True): self.uid = user self.domain = domain self.resource = resource @@ -633,6 +642,7 @@ class EcoVacsIOT(): self.api.meta = {} self.ctl_subscribers = [] self.ready_flag = Event() + self.verify_ssl = str_to_bool(verify_ssl) #TODO: Determine what to do with IOT connect and wait, or scrap # def connect_and_wait_until_ready(self): @@ -646,7 +656,7 @@ class EcoVacsIOT(): 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 ) + self.api._EcoVacsAPI__call_portal_api(self.api, self.api.IOTDEVMANAGERAPI,'',c ,verify_ssl=self.verify_ssl ) ) diff --git a/sucks/cli.py b/sucks/cli.py index 1971b2c..6c26c2f 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -215,9 +215,9 @@ def run(actions, debug): if actions: config = read_config() api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'], - config['country'], config['continent']) + config['country'], config['continent'], verify_ssl=config['verify_ssl']) vacuum = api.devices()[0] - vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent']) + vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent'], verify_ssl=config['verify_ssl']) vacbot.connect_and_wait_until_ready() for action in actions: From 5d6df85ed657ce81cbedc6f45c9db31dec94fef5 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 8 Feb 2019 01:43:00 -0500 Subject: [PATCH 27/48] Fix custom commands Fix for custom commands with multiple inner tags Updated tests --- sucks/__init__.py | 41 ++++++++++++++++++++++++----------------- tests/test_commands.py | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index b6cc953..fcd5f87 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -115,10 +115,10 @@ COMPONENT_FROM_ECOVACS = { } def str_to_bool(s): - if s == 'True': + if s == 'True' or s == True: return True - elif s == 'False': - return False + elif s == 'False' or s == False: + return False else: raise ValueError("Cannot covert {} to a bool".format(s)) @@ -661,6 +661,10 @@ class EcoVacsIOT(): 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, @@ -669,8 +673,9 @@ class EcoVacsIOT(): 'userid': self.uid, 'with': 'users', }, - "cmdName": cmd.name, - "payload": cmd.args_to_xml(), + "cmdName": cmd.name, + "payload": ET.tostring(payloadxml).decode(), + "payloadType": "x", "td": "q", "toId": recipient, @@ -997,31 +1002,33 @@ class VacBotCommand: def to_xml(self): ctl = ET.Element('ctl', {'td': self.name}) - for key, value in self.args.items(): + for key, value in self.args.items(): if type(value) is dict: inner = ET.Element(key, value) ctl.append(inner) + elif type(value) is list: + for item in value: + ixml = self.listobject_to_xml(key, item) + ctl.append(ixml) else: ctl.set(key, value) + return ctl - def args_to_xml(self): - ctl = ET.Element('ctl',{}) - for key, value in self.args.items(): - if type(value) is dict: - inner = ET.Element(key, value) - ctl.append(inner) - else: - ctl.set(key, value) - return ET.tostring(ctl).decode() - - def __str__(self, *args, **kwargs): return self.command_name() + " command" def command_name(self): return self.__class__.__name__.lower() + def listobject_to_xml(self, tag, conv_object): + rtnobject = ET.Element(tag) + if type(conv_object) is dict: + for key, value in conv_object.items(): + rtnobject.set(key, value) + else: + rtnobject.set(tag, conv_object) + return rtnobject class Clean(VacBotCommand): def __init__(self, mode='auto', speed='normal', iot=False, action='start',terminal=False, **kwargs): diff --git a/tests/test_commands.py b/tests/test_commands.py index a1f020e..c385e12 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -19,6 +19,20 @@ def test_custom_command_inner_tag(): b'') +def test_custom_command_multiple_inner_tag(): + # Ensure a custom-built command with multiple inner tags generates the expected XML payload + c = VacBotCommand('CustomCommand', {"customtag":[{"customvar":"customvalue1"},{"customvar":"customvalue2"}]}) + logging.info(ElementTree.tostring(c.to_xml())) + assert_equals(ElementTree.tostring(c.to_xml()), + b'') + +def test_custom_command_args_multiple_inner_tag(): + # Ensure a custom-built command with args and multiple inner tags generates the expected XML payload + c = VacBotCommand('CustomCommand', {"arg1":"value1","customtag":[{"customvar":"customvalue1"},{"customvar":"customvalue2"}]}) + assert_equals(ElementTree.tostring(c.to_xml()), + b'') + + def test_custom_command_noargs(): # Ensure a custom-built command with no args generates XML without an args element c = VacBotCommand('CustomCommand') From b0bf467aa49e2a14db216aca61b0a36a7a1c5e0e Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 8 Feb 2019 02:12:54 -0500 Subject: [PATCH 28/48] stop printing positiion updates stop printing positiion updates --- sucks/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index fcd5f87..1316cba 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -832,9 +832,8 @@ class EcoVacsMQTT(ClientMQTT): result.update(xml[0].attrib) for key in result: - if ',' in result[key]: #Seen in position updates - print(result[key]) - elif not RepresentsInt(result[key]): #Fix to handle negative int values + #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 From 94a7844f78a90d93bae1aceeee5a50bd637654ce Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 15 Feb 2019 22:27:47 -0500 Subject: [PATCH 29/48] Fix spot area clean Fix spot area clean --- sucks/__init__.py | 77 ++++++++++++++++++++--------------------------- sucks/cli.py | 7 ++--- 2 files changed, 35 insertions(+), 49 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 1316cba..3f37169 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -27,13 +27,14 @@ _LOGGER = logging.getLogger(__name__) CLEAN_MODE_AUTO = 'auto' CLEAN_MODE_EDGE = 'edge' CLEAN_MODE_SPOT = 'spot' -CLEAN_MODE_SPOT_AREA = 'spotarea' +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' @@ -67,13 +68,21 @@ 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, - 'SpotArea': CLEAN_MODE_SPOT_AREA, + 'spot_area': CLEAN_MODE_SPOT_AREA, 'singleroom': CLEAN_MODE_SINGLE_ROOM, 'stop': CLEAN_MODE_STOP, 'going': CHARGE_MODE_RETURNING @@ -397,12 +406,15 @@ class VacBot(): self.iot.subscribe_to_ctls(self._handle_ctl) self.mqtt = EcoVacsMQTT(user, domain, resource, secret, continent, vacuum, server_address) 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) - + #self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) + #Uncomment line to allow unencrypted plain auth + #self.xmpp['feature_mechanisms'].unencrypted_plain = True + #self.xmpp.subscribe_to_ctls(self._handle_ctl) else: self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) + #Uncomment line to allow unencrypted plain auth + #self.xmpp['feature_mechanisms'].unencrypted_plain = True self.xmpp.subscribe_to_ctls(self._handle_ctl) @@ -413,6 +425,7 @@ class VacBot(): else: self.mqtt.connect_and_wait_until_ready() self.mqtt.schedule(30, self.send_ping) + #self.xmpp.connect_and_wait_until_ready() if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds @@ -459,19 +472,15 @@ class VacBot(): type = event['type'] try: type = CLEAN_MODE_FROM_ECOVACS[type] + if self.vacuum['iot']: #Was able to parse additional status from the IOT, may apply to XMPP too + statustype = event['st'] + statustype = CLEAN_ACTION_FROM_ECOVACS[statustype] + if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE: + type = statustype except KeyError: _LOGGER.warning("Unknown cleaning status '" + type + "'") self.clean_status = type - self.vacuum_status = type - - if self.vacuum['iot']: #Was able to parse additional status from the IOT, may apply to XMPP too - cleaning = event.get('st', None) - if cleaning == 'p': - self.clean_status = 'paused' - elif cleaning == 'h': - self.clean_status = 'standby' - else: - self.clean_status = 'cleaning' + self.vacuum_status = type fan = event.get('speed', None) if fan is not None: @@ -541,13 +550,13 @@ class VacBot(): try: if not self.vacuum['iot']: self.xmpp.send_ping(self._vacuum_address()) - elif self.vacuum['iot']: + 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) + # just be an oversight in the app communications. IOT should probably be using MQTT pings (which are automatic when connected) except XMPPError as err: @@ -618,6 +627,7 @@ class VacBot(): self.xmpp.disconnect(wait=wait) else: self.mqtt._disconnect() + #self.xmpp.disconnect(wait=wait) @@ -793,7 +803,7 @@ class EcoVacsMQTT(ClientMQTT): 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")))) + #_LOGGER.debug("EcoVacs MQTT Received Message on Topic: {} - Message: {}".format(message.topic, str(message.payload.decode("utf-8")))) 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: @@ -843,7 +853,6 @@ 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 True else: return False @@ -868,33 +877,9 @@ 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) - self.user = user self.domain = domain self.resource = resource @@ -909,6 +894,7 @@ class EcoVacsXMPP(ClientXMPP): self.ctl_subscribers = [] self.ready_flag = Event() + def wait_until_ready(self): self.ready_flag.wait() @@ -979,6 +965,7 @@ class EcoVacsXMPP(ClientXMPP): q.send() def connect_and_wait_until_ready(self): + self.connect(self.server_address) self.process() self.wait_until_ready() @@ -1059,9 +1046,9 @@ class Stop(Clean): class SpotArea(Clean): def __init__(self, action='start', namedarea='', customarea='', cleanings='1'): if namedarea != '': #For cleaning specified map area - super().__init__('spotarea', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=namedarea) + super().__init__('spot_area', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=namedarea) elif customarea != '': #For cleaning custom map area, and specify deep amount 1x/2x - super().__init__('spotarea' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=customarea, deep=cleanings) + super().__init__('spot_area' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=customarea, deep=cleanings) else: #no valid entries raise ValueError("must provide namedarea or customarea for spotarea clean") diff --git a/sucks/cli.py b/sucks/cli.py index 6c26c2f..a241ddf 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -179,11 +179,10 @@ def edge(frequency, minutes): return CliAction(Edge(), wait=TimeWait(minutes * 60)) -@cli.command(help='spotcleans provided room(s) for the specified number of minutes') +@cli.command(help='spotcleans provided room(s)') @click.argument('room', type=click.STRING) -@click.argument('minutes', type=click.FLOAT) -def spotclean(room, minutes): - return CliAction(SpotArea('start', room), wait=TimeWait(minutes * 60)) +def spotclean(room): + return CliAction(SpotArea('start', room), wait=StatusWait('charge_status', 'returning')) @cli.command(help='returns to charger') def charge(): From 409b04b71356538567a2388fd2c499f4206fa706 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 16 Feb 2019 02:19:13 -0500 Subject: [PATCH 30/48] add tests --- tests/test_commands.py | 8 ++- tests/test_ecovacs_iot.py | 19 +++++++ tests/test_vacbot.py | 110 ++++++++++++++++++++++++++++++++++---- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index c385e12..d0ffd7b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -139,7 +139,6 @@ def test_get_battery_state_command(): b'') - def test_move_command(): c = Move(action='left') assert_equals(ElementTree.tostring(c.to_xml()), @@ -165,9 +164,16 @@ def test_get_lifepsan_command(): c = GetLifeSpan('main_brush') assert_equals(ElementTree.tostring(c.to_xml()), b'') + c = GetLifeSpan('side_brush') assert_equals(ElementTree.tostring(c.to_xml()), b'') + c = GetLifeSpan('filter') assert_equals(ElementTree.tostring(c.to_xml()), b'') + +def test_set_time_command(): + c = SetTime('1234', 'GMT-5') + assert_equals(ElementTree.tostring(c.to_xml()), + b'') diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py index fbd5979..aea1dec 100644 --- a/tests/test_ecovacs_iot.py +++ b/tests/test_ecovacs_iot.py @@ -47,6 +47,13 @@ def test_iotapi_response(): rtnval = api._EcoVacsAPI__call_portal_api(api.IOTDEVMANAGERAPI, '', c) assert_equal(rtnval, {}) #Right now it sends back a blank object +def test_send_command(): + from unittest.mock import MagicMock + x = make_ecovacs_iot() + x._handle_ctl = MagicMock() + x.api._EcoVacsAPI__call_portal_api = MagicMock() + x.send_command(Clean(iot=True), '123') + def test_subscribe_to_ctls(): response = None @@ -94,6 +101,18 @@ def test_xml_to_dict(): x._ctl_to_dict(cmd,message['resp']), {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) + cmd = VacBotCommand("Charge") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'type': 'going', 'h': '', 'r': 'a', 's': '', 'g': '0', 'event': 'charge_state'}) + + cmd = VacBotCommand("GetTestCommand") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict(cmd,message['resp']), + {'type': 'command', 'event': 'test_command'}) #Test action.name.replace Get + cmd = VacBotCommand("Charge") message['resp'] = "" assert_dict_equal( diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index 77a9331..d57f7cd 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -2,12 +2,11 @@ from nose.tools import * from sucks import * -from unittest.mock import Mock +from unittest.mock import Mock, patch from sleekxmpp.exceptions import XMPPError from paho.mqtt.client import MQTT_ERR_UNKNOWN as MQTTError - def test_handle_clean_report(): v = a_vacbot() assert_equals(None, v.clean_status) @@ -32,6 +31,23 @@ def test_handle_clean_report(): assert_equals('a_weird_speed', v.fan_speed) + +def test_not_iot_send_command_clean(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.xmpp.send_command = MagicMock() + v.send_command(VacBotCommand('Clean')) + assert v.xmpp.send_command.called #test when iot is False it uses xmpp.send_command + + +def test_iot_send_command_clean(): + from unittest.mock import MagicMock + v = a_vacbot(iot=True) + v.iot.send_command = MagicMock() + v.send_command(VacBotCommand('Clean')) + assert v.iot.send_command.called #test when iot is True it uses iot.send_command + + def test_handle_charge_state(): v = a_vacbot() assert_equals(None, v.clean_status) @@ -94,13 +110,6 @@ def test_handle_battery_info(): assert_equals(0.0, v.battery_status) -def test_handle_geterrors(): - v = a_vacbot() - - #v._handle_error - - #ssert_equals({}, v.components) - def test_lifespan_reports(): v = a_vacbot() assert_equals({}, v.components) @@ -119,6 +128,10 @@ def test_lifespan_reports(): v._handle_ctl({'event': 'life_span', 'type': 'a_weird_component', 'total': '100', 'val': '87'}) assert_equals({'side_brush': 0, 'main_brush': 0.01, 'a_weird_component': 0.87}, v.components) + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'left': '120'}) + assert_equals(2.0, v.components['side_brush']) #test left (2 hours / 120 mins) instead of val + + def test_is_cleaning(): v = a_vacbot() @@ -136,6 +149,14 @@ def test_is_cleaning(): v._handle_ctl({'event': 'charge_state', 'type': 'going'}) assert_false(v.is_cleaning) + v = a_vacbot(iot=True) + v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'h'}) + assert_false(v.is_cleaning) #test iot and state paused + + v = a_vacbot(iot=True) + v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'r'}) + assert_true(v.is_cleaning) #test iot and state running + def test_is_charging(): v = a_vacbot() @@ -314,7 +335,10 @@ def test_error_event_subscription(): mock = Mock() v.errorEvents.subscribe(mock) v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) - mock.assert_called_once_with('an_error_name') + v._handle_ctl({'event': 'error', 'errs': 'an_error_name2'}) #added for testing errs + assert_equals(2, mock.call_count) + #mock.assert_called_once_with('an_error_name') + # Test unsubscribe mock = Mock() @@ -346,6 +370,11 @@ def test_bot_address(): assert_equals('E0000000001234567890@126.ecorobot.net/atom', v._vacuum_address()) +def test_bot_address_iot(): + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot":True}) + assert_equals('E0000000001234567890', v._vacuum_address()) + + def test_model_variation(): v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob","iot":False}) assert_equals('E0000000001234567890@141.ecorobot.net/atom', v._vacuum_address()) @@ -357,3 +386,64 @@ def a_vacbot(bot=None, iot=False, monitor=False): bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": iot} return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', bot, 'na', monitor=monitor) + +def test_str_to_bool(): + assert_raises(ValueError, str_to_bool, None) #Value error if str_to_bool can't convert + + +def test_connect_and_wait(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False, monitor=True) + v.xmpp.connect_and_wait_until_ready = MagicMock() + v.send_ping = MagicMock() + v.xmpp.schedule = MagicMock() + v.connect_and_wait_until_ready() + assert v.xmpp.schedule.called #test when iot is False it uses xmpp.schedule + + v = a_vacbot(iot=True) + v.mqtt.connect_and_wait_until_ready = MagicMock() + v.send_ping = MagicMock() + v.mqtt.schedule = MagicMock() + v.connect_and_wait_until_ready() + assert v.mqtt.schedule.called #test when iot is True it uses mqtt.schedule + +def test_run(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.send_command = MagicMock() + v.run(VacBotCommand('Clean')) + assert v.send_command.called + +def test_disconnect(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.xmpp.disconnect = MagicMock() + v.disconnect() + assert v.xmpp.disconnect.called + + v = a_vacbot(iot=True) + v.mqtt.disconnect = MagicMock() + v.disconnect() + assert v.mqtt.disconnect.called + +def test_refresh_all(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.refresh_statuses = MagicMock() + v.refresh_components = MagicMock + v.request_all_statuses() + assert v.refresh_components.called and v.refresh_statuses.called + +def test_refresh_statuses(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.run = MagicMock() + v.refresh_statuses() + assert v.run.called + +def test_refresh_components(): + from unittest.mock import MagicMock + v = a_vacbot(iot=False) + v.run = MagicMock() + v.refresh_components() + assert v.run.called From 9919baad27f46bc1ea59f761c5bad4a7c765df1d Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Sat, 16 Feb 2019 02:54:39 -0500 Subject: [PATCH 31/48] more tests --- sucks/__init__.py | 2 +- tests/test_commands.py | 5 +++++ tests/test_ecovacs_mqtt.py | 11 ++++++++++- tests/test_ecovacs_xmpp.py | 3 +-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 3f37169..7c17979 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -1018,7 +1018,7 @@ class VacBotCommand: class Clean(VacBotCommand): def __init__(self, mode='auto', speed='normal', iot=False, action='start',terminal=False, **kwargs): - if kwargs is None: + if kwargs == {}: if not iot: super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) else: diff --git a/tests/test_commands.py b/tests/test_commands.py index d0ffd7b..63f4ae2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -44,9 +44,14 @@ def test_clean_command(): c = Clean() assert_equals(ElementTree.tostring(c.to_xml()), b'') # protocol has attribs in other order + c = Clean('edge', 'high') assert_equals(ElementTree.tostring(c.to_xml()), b'') # protocol has attribs in other order + + c = Clean(iot=True) + assert_equals(ElementTree.tostring(c.to_xml()), + b'') # test for iot act is added def test_spotarea_command(): diff --git a/tests/test_ecovacs_mqtt.py b/tests/test_ecovacs_mqtt.py index 8c567a4..e750118 100644 --- a/tests/test_ecovacs_mqtt.py +++ b/tests/test_ecovacs_mqtt.py @@ -71,15 +71,24 @@ def test_xml_to_dict(): x._ctl_to_dict(test_topic, ""), {'event': 'map_st', 'ts':'1547823592934', 'st':'reloc_go_chg_start', 'method':'', 'info':''}) + # #TODO: Find a way to check if string is b64 encoded # test_topic = 'iot/atr/trace/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) # assert_dict_equal( # x._ctl_to_dict(test_topic, ""), # {'event': 'trace', 'trid':'227975', 'tf':'4', 'tr':'XQAABAAKAAAAAB4AMGAQCdAAAAA='}) +def test_bad_port(): + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} + mqtt = EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:f123') + assert_equal(8883, mqtt.port) +def test_good_port(): + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} + mqtt = EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:8000') + assert_equal(8000, mqtt.port) def make_ecovacs_mqtt(bot=None): if bot is None: - bot = bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} return EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index cdfc3c7..b76aebf 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -14,7 +14,6 @@ def test_wrap_command(): assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) - def test_subscribe_to_ctls(): response = None @@ -58,7 +57,7 @@ def test_xml_to_dict(): def make_ecovacs_xmpp(bot=None): if bot is None: - bot = bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} + bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) From 597129dda0294545d6d4dca01e9c0c517162de61 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Feb 2019 17:44:41 -0500 Subject: [PATCH 32/48] Combine MQTT and IOT into IOTMQ Combined MQTT and IOT into IOTMQ Updated tests - now with 99% coverage for __init__ --- sucks/__init__.py | 412 +++++++++++++++++------------------- tests/test_commands.py | 2 +- tests/test_ecovacs_api.py | 109 +++++++++- tests/test_ecovacs_iot.py | 137 ------------ tests/test_ecovacs_iotmq.py | 236 +++++++++++++++++++++ tests/test_ecovacs_mqtt.py | 94 -------- tests/test_ecovacs_xmpp.py | 12 +- tests/test_vacbot.py | 89 ++------ 8 files changed, 559 insertions(+), 532 deletions(-) delete mode 100644 tests/test_ecovacs_iot.py create mode 100644 tests/test_ecovacs_iotmq.py delete mode 100644 tests/test_ecovacs_mqtt.py diff --git a/sucks/__init__.py b/sucks/__init__.py index 7c17979..2b90f34 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -140,8 +140,8 @@ class EcoVacsAPI: PORTAL_URL_FORMAT = 'https://portal-{continent}.ecouser.net/api' USERSAPI = 'users/user.do' - IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via API, no longer XMPP - PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products, which is assumed should use IOT API instead of XMPP + IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via RestAPI, some bots use this instead of XMPP + PRODUCTAPI = 'pim/product' # Leaving this open, the only endpoint known currently is "Product IOT Map" - pim/product/getProductIotMap - This provides a list of "IOT" products. Not sure what this provides the app. REALM = 'ecouser.net' @@ -236,40 +236,16 @@ class EcoVacsAPI: params = {} params.update(args) - - url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) - response = None - if not api == self.IOTDEVMANAGERAPI: - response = requests.post(url, json=params, verify=verify_ssl) - else: - 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, 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 {} failed with ReadTimeout".format(function)) - return {} + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=self.continent, **self.meta) + + response = requests.post(url, json=params, verify=verify_ssl) json = response.json() _LOGGER.debug("got {}".format(json)) if api == self.USERSAPI: if json['result'] == 'ok': return json - - if api == self.IOTDEVMANAGERAPI: - 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 {} failed with {}".format(function, json)) - return {} - else: - #TODO - Not sure if we want to raise an error yet, just return empty for now - _LOGGER.error("call to {} failed with {}".format(function, json)) - return {} - #raise RuntimeError( - #"failure {} ({}) for call {} and parameters {}".format(json['error'], json['errno'], function, params)) - + if api.startswith(self.PRODUCTAPI): if json['code'] == 0: return json @@ -287,9 +263,7 @@ class EcoVacsAPI: 'userId': self.uid, 'token': self.auth_code} , verify_ssl=self.verify_ssl) - - def getdevices(self): return self.__call_portal_api(self.USERSAPI,'GetDeviceList', { 'userid': self.uid, @@ -315,15 +289,33 @@ class EcoVacsAPI: }, verify_ssl=self.verify_ssl)['data'] def SetIOTDevices(self, devices, iotproducts): + #Originally added for D900, and not actively used in code now - Not sure what the app checks the items in this list for for device in devices: #Check if the device is part of iotProducts + device['iot_product'] = False for iotProduct in iotproducts: if device['class'] in iotProduct['classid']: - device['iot'] = True + device['iot_product'] = True return devices + + def SetIOTMQDevices(self, devices): + #Added for devices that utilize MQTT instead of XMPP for communication + #At this time the list is updated manually, so far only the D900 has been seen to use this + #These items were found in the Android app source by searching for "new IOTMqDevice(" + iotmqdevices = [ + 'ls1ok3', #D900 / DE5G + # Possibly the Atmobot AA30 - qqy0di + # Possibly the Slim4 - wbueya + ] + for device in devices: + device['iotmq'] = False + if device['class'] in iotmqdevices: #Check if the device is part of the list + device['iotmq'] = True + + return devices def devices(self): - return self.SetIOTDevices(self.getdevices(), self.getiotProducts()) + return self.SetIOTMQDevices(self.getdevices()) @staticmethod def md5(text): @@ -396,45 +388,40 @@ class VacBot(): self.lifespanEvents = EventEmitter() self.errorEvents = EventEmitter() - #Set none for clients to start - self.mqtt = None - self.iot = None + #Set none for clients to start self.xmpp = None + self.iotmq = None - if vacuum['iot']: - self.iot = EcoVacsIOT(user, domain, resource, secret, continent, vacuum, verify_ssl=verify_ssl) - self.iot.subscribe_to_ctls(self._handle_ctl) - self.mqtt = EcoVacsMQTT(user, domain, resource, secret, continent, vacuum, server_address) - self.mqtt.subscribe_to_ctls(self._handle_ctl) - #self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) - #Uncomment line to allow unencrypted plain auth - #self.xmpp['feature_mechanisms'].unencrypted_plain = True - #self.xmpp.subscribe_to_ctls(self._handle_ctl) - - else: + if not vacuum['iotmq']: self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) #Uncomment line to allow unencrypted plain auth #self.xmpp['feature_mechanisms'].unencrypted_plain = True - self.xmpp.subscribe_to_ctls(self._handle_ctl) - + self.xmpp.subscribe_to_ctls(self._handle_ctl) + + else: + self.iotmq = EcoVacsIOTMQ(user, domain, resource, secret, continent, vacuum, server_address, verify_ssl=verify_ssl) + self.iotmq.subscribe_to_ctls(self._handle_ctl) + #self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) + #Uncomment line to allow unencrypted plain auth + #self.xmpp['feature_mechanisms'].unencrypted_plain = True + #self.xmpp.subscribe_to_ctls(self._handle_ctl) def connect_and_wait_until_ready(self): - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: 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() - self.mqtt.schedule(30, self.send_ping) + self.iotmq.connect_and_wait_until_ready() + self.iotmq.schedule(30, self.send_ping) #self.xmpp.connect_and_wait_until_ready() if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds self.send_ping() - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) else: - self.mqtt.schedule(3600,self.refresh_components) - + self.iotmq.schedule(3600,self.refresh_components) def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] @@ -472,7 +459,7 @@ class VacBot(): type = event['type'] try: type = CLEAN_MODE_FROM_ECOVACS[type] - if self.vacuum['iot']: #Was able to parse additional status from the IOT, may apply to XMPP too + if self.vacuum['iotmq']: #Was able to parse additional status from the IOTMQ, may apply to XMPP too statustype = event['st'] statustype = CLEAN_ACTION_FROM_ECOVACS[statustype] if statustype == CLEAN_ACTION_STOP or statustype == CLEAN_ACTION_PAUSE: @@ -533,10 +520,10 @@ class VacBot(): _LOGGER.debug("*** charge_status = " + self.charge_status) def _vacuum_address(self): - if self.vacuum['iot']: - return self.vacuum['did'] + if not self.vacuum['iotmq']: + return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' else: - return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' + return self.vacuum['did'] #IOTMQ only uses the did @property def is_charging(self) -> bool: @@ -548,10 +535,10 @@ class VacBot(): def send_ping(self): try: - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: self.xmpp.send_ping(self._vacuum_address()) - elif self.vacuum['iot']: - if not self.mqtt.send_ping(): + elif self.vacuum['iotmq']: + if not self.iotmq.send_ping(): raise RuntimeError() #self.xmpp.send_ping(EcoVacsAPI.REALM) #IOT vacuums are using the realm instead @@ -612,26 +599,23 @@ class VacBot(): self.refresh_components() def send_command(self, action): - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: self.xmpp.send_command(action.to_xml(), self._vacuum_address()) 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 + #IOTMQ issues commands via RestAPI, and listens on MQTT for status updates + self.iotmq.send_command(action, self._vacuum_address()) #IOTMQ devices need the full action for additional parsing def run(self, action): self.send_command(action) - def disconnect(self, wait=False): - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: self.xmpp.disconnect(wait=wait) else: - self.mqtt._disconnect() - #self.xmpp.disconnect(wait=wait) - - + self.iotmq._disconnect() + #self.xmpp.disconnect(wait=wait) -#This is used by EcoVacsIOT, EcoVacsXMPP, and EcoVacsMQTT for _ctl_to_dict +#This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict def RepresentsInt(stringvar): try: int(stringvar) @@ -639,117 +623,19 @@ def RepresentsInt(stringvar): except ValueError: return False -class EcoVacsIOT(): - def __init__(self, user, domain, resource, secret, continent, vacuum, verify_ssl=True): - self.uid = user - self.domain = domain - self.resource = resource - self.secret = secret - self.continent = continent - self.vacuum = vacuum - self.api = EcoVacsAPI - self.api.continent = continent - self.api.meta = {} - self.ctl_subscribers = [] - self.ready_flag = Event() - self.verify_ssl = str_to_bool(verify_ssl) - - #TODO: Determine what to do with IOT connect and wait, or scrap - # def connect_and_wait_until_ready(self): - # self.connect(EcoVacsAPI._EcoVacsAPI__call_portal_api()) - # self.process() - # 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 ,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.uid, - '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 subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) - - - def _handle_ctl(self, action, message): - if not message == {}: - resp = self._ctl_to_dict(action, message['resp']) - if resp is not None: - for s in self.ctl_subscribers: - s(resp) - - - def _ctl_to_dict(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 IOT 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 - -class EcoVacsMQTT(ClientMQTT): - def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): +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(verify_ssl) if server_address is None: self.hostname = ('mq-{}.ecouser.net'.format(self.continent)) @@ -768,6 +654,28 @@ class EcoVacsMQTT(ClientMQTT): 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 subscribe_to_ctls_mqtt(self, function): + # self.ctl_subscribers.append(function) + def _disconnect(self): self.disconnect() #disconnect mqtt connection self.scheduler.empty() #Clear schedule queue @@ -798,19 +706,122 @@ class EcoVacsMQTT(ClientMQTT): #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 subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) + 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 _handle_ctl(self, client, userdata, message): + 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(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(self, topic, xmlstring): + 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 @@ -846,37 +857,9 @@ class EcoVacsMQTT(ClientMQTT): if not RepresentsInt(result[key]) and ',' not in result[key]: result[key] = stringcase.snakecase(result[key]) - return result - - - 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 + return result - - 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 - 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 ): ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) @@ -952,7 +935,7 @@ class EcoVacsXMPP(ClientXMPP): return q def _my_address(self): - if not self.vacuum['iot']: + if not self.vacuum['iotmq']: return self.user + '@' + self.domain + '/' + self.boundjid.resource else: return self.user + '@' + self.domain + '/' + self.resource @@ -964,8 +947,7 @@ class EcoVacsXMPP(ClientXMPP): _LOGGER.debug("*** sending ping ***") q.send() - def connect_and_wait_until_ready(self): - + def connect_and_wait_until_ready(self): self.connect(self.server_address) self.process() self.wait_until_ready() @@ -1017,9 +999,9 @@ class VacBotCommand: return rtnobject class Clean(VacBotCommand): - def __init__(self, mode='auto', speed='normal', iot=False, action='start',terminal=False, **kwargs): + def __init__(self, mode='auto', speed='normal', iotmq=False, action='start',terminal=False, **kwargs): if kwargs == {}: - if not iot: + if not iotmq: 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]}}) diff --git a/tests/test_commands.py b/tests/test_commands.py index 63f4ae2..7afa2f5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -49,7 +49,7 @@ def test_clean_command(): assert_equals(ElementTree.tostring(c.to_xml()), b'') # protocol has attribs in other order - c = Clean(iot=True) + c = Clean(iotmq=True) assert_equals(ElementTree.tostring(c.to_xml()), b'') # test for iot act is added diff --git a/tests/test_ecovacs_api.py b/tests/test_ecovacs_api.py index 49d9d76..63fb4f1 100644 --- a/tests/test_ecovacs_api.py +++ b/tests/test_ecovacs_api.py @@ -39,6 +39,23 @@ def test_main_api_setup(): assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") + #Test old user api endpoint + postdata = {'country': 'US', + 'resource': "f8d99c4d", + 'realm': EcoVacsAPI.REALM, + 'userId': "2017102559f0ee63c588d", + 'token': "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s"} + + r = api._EcoVacsAPI__call_user_api("loginByItToken", postdata) + assert_equals(r3.call_count, 2) + # verify state + assert_equals(api.uid, "2017102559f0ee63c588d") + assert_equals(api.login_access_token, "7a375650b0b1efd780029284479c4e41") + assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") + assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") + + + def test_main_api_setup_with_alternate_uid(): # Under mysterious circumstances, for certain people the last call sometimes returns a different userId @@ -64,30 +81,61 @@ def test_main_api_setup_with_alternate_uid(): assert_equals(api.auth_code, "5c28dac1ff580210e11292df57e87bef") assert_equals(api.user_access_token, "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s") +def test_main_api_errorcode(): + with requests_mock.mock() as m: + r1 = m.get(compile('user/login'), #test with 0004 (invalid token) + text='{"time": 1511200804243, "code": "0004", "msg": "X", "data": null}') + + assert_raises(RuntimeError, EcoVacsAPI, "long_device_id", "account_id", "password_hash", 'us', 'na') #Runtime error from code 0004 + + +def test_main_api_badpassword(): + with requests_mock.mock() as m: + r1 = m.get(compile('user/login'), #test with 1005 (incorrect email or password) + text='{"time": 1511200804243, "code": "1005", "msg": "X", "data": null}') + + assert_raises(ValueError, EcoVacsAPI, "long_device_id", "account_id", "password_hash", 'us', 'na') #ValueError error from code 1005 def test_device_lookup(): api = make_api() with requests_mock.mock() as m: - device_id = 'E0000001234567890123' + #Not IOTMQ + device_id = 'E0000001234567890123' + device_class = '126' r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "class": "126", "nick": "bob"}], "result": "ok"}' % device_id) - r = m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) + d = api.devices() assert_equals(r.call_count, 1) assert_equals(len(d), 1) vacuum = d[0] assert_equals(vacuum['did'], device_id) assert_equals(vacuum['class'], '126') + assert_equals(vacuum['iotmq'], False) -def test_device_lookup_is_IOT(): + #Is IOTMQ + device_class = 'ls1ok3' #D900 + r = m.post(compile('user.do'), + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) + + d = api.devices() + assert_equals(r.call_count, 1) + assert_equals(len(d), 1) + vacuum = d[0] + assert_equals(vacuum['did'], device_id) + assert_equals(vacuum['class'], device_class) + assert_equals(vacuum['iotmq'], True) + + +def test_device_lookup_IOTProduct(): api = make_api() with requests_mock.mock() as m: + + #Is IOTProduct device_id = 'E0000001234567890123' - device_class = 'ls1ok3' + device_class = 'ls1ok3' #D900 r = m.post(compile('user.do'), text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) @@ -95,12 +143,57 @@ def test_device_lookup_is_IOT(): text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') d = api.devices() + d = api.SetIOTDevices(d, api.getiotProducts()) + assert_equals(r.call_count, 1) assert_equals(len(d), 1) vacuum = d[0] assert_equals(vacuum['did'], device_id) assert_equals(vacuum['class'], device_class) - assert_equals(vacuum['iot'], True) + assert_equals(vacuum['iot_product'], True) + assert_equals(vacuum['iotmq'], True) + + #Not IOTProduct + device_id = 'E0000001234567890123' + device_class = '126' + + r = m.post(compile('user.do'), + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) + r = m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') + + d = api.devices() + d = api.SetIOTDevices(d, api.getiotProducts()) + + assert_equals(r.call_count, 1) + assert_equals(len(d), 1) + vacuum = d[0] + assert_equals(vacuum['did'], device_id) + assert_equals(vacuum['class'], device_class) + assert_equals(vacuum['iot_product'], False) + assert_equals(vacuum['iotmq'], False) + +def test_device_lookup_is_IOTProduct_not_IOTMQ(): + api = make_api() + with requests_mock.mock() as m: + device_id = 'E0000001234567890123' + device_class = 'dl8fht' #D600 + + r = m.post(compile('user.do'), + text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) + r = m.post(compile('pim/product/getProductIotMap'), + text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') + + d = api.devices() + d = api.SetIOTDevices(d, api.getiotProducts()) + + assert_equals(r.call_count, 1) + assert_equals(len(d), 1) + vacuum = d[0] + assert_equals(vacuum['did'], device_id) + assert_equals(vacuum['class'], device_class) + assert_equals(vacuum['iot_product'], True) + assert_equals(vacuum['iotmq'], False) def make_api(): diff --git a/tests/test_ecovacs_iot.py b/tests/test_ecovacs_iot.py deleted file mode 100644 index aea1dec..0000000 --- a/tests/test_ecovacs_iot.py +++ /dev/null @@ -1,137 +0,0 @@ -from re import compile - -import requests_mock -import requests -from nose.tools import * - -from sucks import * -from tests.test_ecovacs_api import make_api - - -# There are few tests for the IOT stuff here because it's relatively complicated to test given -# the library's design and its multithreaded nature and lack of explicit testing support. - - -def test_is_iot(): - x = make_ecovacs_iot() - assert_equal(x.vacuum['iot'], True) - -def test_wrap_command(): - x = make_ecovacs_iot() - - c = x._wrap_command(Charge(), 'E0000000001234567890') - assert_equal(c['cmdName'], Charge().name) - assert_equal(c['toId'], 'E0000000001234567890') - assert_equal(c['payload'], '') - -def test_iotapi_response(): - x = make_ecovacs_iot() - api = make_api() - x.api = api - - with requests_mock.mock() as m: - - #Test GetCleanState - resp = {"ret":"ok","resp":"","id":"Qgxa"} - r1 = m.post(compile('iot/devmanager.do'), - json=resp) - cmd = VacBotCommand("GetCleanState") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = api._EcoVacsAPI__call_portal_api(api.IOTDEVMANAGERAPI, '', c) - assert_equal(rtnval, {'ret':'ok','resp':"",'id':'Qgxa'}) - - #Test Timeout - r2 = m.post(compile('iot/devmanager.do'),exc=requests.exceptions.ReadTimeout) - cmd = VacBotCommand("GetCleanState") - c = x._wrap_command(cmd, x.vacuum['did']) - rtnval = api._EcoVacsAPI__call_portal_api(api.IOTDEVMANAGERAPI, '', c) - assert_equal(rtnval, {}) #Right now it sends back a blank object - -def test_send_command(): - from unittest.mock import MagicMock - x = make_ecovacs_iot() - x._handle_ctl = MagicMock() - x.api._EcoVacsAPI__call_portal_api = MagicMock() - x.send_command(Clean(iot=True), '123') - - -def test_subscribe_to_ctls(): - response = None - - def save_response(value): - nonlocal response - response = value - - x = make_ecovacs_iot() - - x.subscribe_to_ctls(save_response) - message = {} - message['resp'] = ' ' - - x.subscribe_to_ctls(save_response) - x._handle_ctl("Clean", message) - assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) - - -def test_xml_to_dict(): - x = make_ecovacs_iot() - message = {} - - cmd = VacBotCommand("Clean") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'event': 'clean_report', 'type': 'auto', 'speed': 'standard', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) - - cmd = VacBotCommand("Clean") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'event': 'clean_report', 'type': 'auto', 'speed': 'strong', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) - - cmd = VacBotCommand("GetBatteryInfo") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'event': 'battery_info', 'power': '82'}) - - cmd = VacBotCommand("GetLifeSpan") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) - - cmd = VacBotCommand("Charge") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'type': 'going', 'h': '', 'r': 'a', 's': '', 'g': '0', 'event': 'charge_state'}) - - cmd = VacBotCommand("GetTestCommand") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'type': 'command', 'event': 'test_command'}) #Test action.name.replace Get - - cmd = VacBotCommand("Charge") - message['resp'] = "" - assert_dict_equal( - x._ctl_to_dict(cmd,message['resp']), - {'event': 'charge_state','ret':'fail', 'errno': '8'}) #Test fail from charge command - - -def make_ecovacs_iot(): - eapi = make_api() - - with requests_mock.mock() as m: - device_resource = 'test_resource' - device_class = 'ls1ok3' #this is for a D900 series - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "E0000000001234567890", "class": "%s", "nick": "bob"}], "result": "ok"}' % (device_class)) - r = m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - d = eapi.devices() - - eiotvacuum = d[0] - eiotvacuum['resource'] = device_resource - return EcoVacsIOT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'base64base64base64base64base64ba', 'na', eiotvacuum) \ No newline at end of file diff --git a/tests/test_ecovacs_iotmq.py b/tests/test_ecovacs_iotmq.py new file mode 100644 index 0000000..ceeff32 --- /dev/null +++ b/tests/test_ecovacs_iotmq.py @@ -0,0 +1,236 @@ +from re import search + +from nose.tools import * + +import requests_mock +import requests + +from sucks import * +import paho.mqtt + +# There are few tests for the MQTT stuff here because it's relatively complicated to test given +# the library's design and its multithreaded nature and lack of explicit testing support. + +def test_subscribe_to_ctls(): + response = None + + def save_response(value): + nonlocal response + response = value + + x = make_ecovacs_iotmq() + x.subscribe_to_ctls(save_response) + + #Test MQTT ctl + mqtt_message = paho.mqtt.client.MQTTMessage + mqtt_message.topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + mqtt_message.payload = b"" + x._handle_ctl_mqtt('','',mqtt_message) + assert_dict_equal(response, {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + #Test API ctl + api_message = {} + api_message['resp'] = ' ' + x.subscribe_to_ctls(save_response) + x._handle_ctl_api("Clean", api_message) + assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) + + +def test_is_iotmq(): + x = make_ecovacs_iotmq() + assert_equal(x.vacuum['iotmq'], True) + +def test_wrap_command(): + x = make_ecovacs_iotmq() + + c = x._wrap_command(Charge(), 'E0000000001234567890') + assert_equal(c['cmdName'], Charge().name) + assert_equal(c['toId'], 'E0000000001234567890') + assert_equal(c['payload'], '') + + +def test_iotapi_response(): + x = make_ecovacs_iotmq() + + with requests_mock.mock() as m: + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/iot/devmanager.do").format(continent=x.continent) + #Test GetCleanState + resp = {"ret":"ok","resp":"","id":"Qgxa"} + r1 = m.post(url, json=resp) + #r1 = m.post(compile('devmanager.do'), json=resp) + cmd = VacBotCommand("GetCleanState") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) + assert_equal(rtnval, {'ret':'ok','resp':"",'id':'Qgxa'}) + + #Test Exception ReadTimeout + r2 = m.post(url, exc=requests.exceptions.ReadTimeout) + #r2 = m.post(compile('iot/devmanager.do'),exc=requests.exceptions.ReadTimeout) + cmd = VacBotCommand("GetCleanState") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) + assert_equal(rtnval, {}) #Right now it sends back a blank object + + #Test Response Fail - Timeout + resp = {"ret":"fail","resp": None, "debug":"wait for response timed out" ,"id":"Qgxa"} + r2 = m.post(url, json=resp) + #r1 = m.post(compile('devmanager.do'), json=resp) + cmd = VacBotCommand("TestCommand") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) + assert_equal(rtnval, {}) + + #Test Response Fail - No debug + resp = {"ret":"fail","resp": None ,"id":"Qgxa"} + r2 = m.post(url, json=resp) + #r1 = m.post(compile('devmanager.do'), json=resp) + cmd = VacBotCommand("TestCommand") + c = x._wrap_command(cmd, x.vacuum['did']) + rtnval = x._EcoVacsIOTMQ__call_iotdevmanager_api(c) + assert_equal(rtnval, {}) + +def test_send_command(): + from unittest.mock import MagicMock + x = make_ecovacs_iotmq() + x._handle_ctl_api = MagicMock() + EcoVacsIOTMQ._EcoVacsIOTMQ__call_iotdevmanager_api = MagicMock() + x.send_command(Clean(iotmq=True), '123') + +def test_send_ping(): + from unittest.mock import MagicMock + x = make_ecovacs_iotmq() + EcoVacsIOTMQ._send_simple_command = MagicMock(return_value=MQTTPublish.paho.MQTT_ERR_SUCCESS) + assert_true(x.send_ping()) #Test ping response success + + EcoVacsIOTMQ._send_simple_command = MagicMock(return_value=MQTTPublish.paho.MQTT_ERR_NOT_FOUND) + assert_false(x.send_ping()) #Test ping response fail + +def test_on_connect_rc_nonzero(): + x = make_ecovacs_iotmq() + assert_raises(RuntimeError, x.on_connect, "client", "userdata", "flags", 1) + +def test_xml_to_dict_mqtt(): + x = make_ecovacs_iotmq() + + test_topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) + + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) #Test without td + + test_topic = 'iot/atr/BatteryInfo/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) + + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) #Test without td + + test_topic = 'iot/atr/SleepStatus/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'sleep_status', 'ts':'1547823129670', 'st': '1'}) + + test_topic = 'iot/atr/errors/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'errors', 'ts':'1547822982581','old':'','new':'102'}) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'errors', 'ts':'1547822982581','old':'102','new':''}) + + test_topic = 'iot/atr/Pos/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'pos', 't':'p', 'p':'7,-10', 'a':'-42','valid':'0'}) + + test_topic = 'iot/atr/DustCaseST/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'dust_case_s_t', 'ts':'1547822871328','st':'1'}) + + test_topic = 'iot/atr/MapSt/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'map_st', 'ts':'1547823592934', 'st':'reloc_go_chg_start', 'method':'', 'info':''}) + + test_topic = 'iot/atr/LifeSpan/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ""), + {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) + + test_topic = 'iot/atr/CustomCommand/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) + assert_dict_equal( + x._ctl_to_dict_mqtt(test_topic, ''), + {'event': 'custom_command', 'customvar': 'customvalue1'}) + + +def test_xml_to_dict_api(): + x = make_ecovacs_iotmq() + message = {} + + cmd = VacBotCommand("Clean") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'event': 'clean_report', 'type': 'auto', 'speed': 'standard', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) + + cmd = VacBotCommand("Clean") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'event': 'clean_report', 'type': 'auto', 'speed': 'strong', 'st':'h','t':'1159','a':'15','s':'0','tr':''}) + + cmd = VacBotCommand("GetBatteryInfo") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'event': 'battery_info', 'power': '82'}) + + cmd = VacBotCommand("GetLifeSpan") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'event': 'life_span','ret':'ok', 'type': 'brush', 'left': '9876', 'total': '18000'}) + + cmd = VacBotCommand("Charge") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'type': 'going', 'h': '', 'r': 'a', 's': '', 'g': '0', 'event': 'charge_state'}) + + cmd = VacBotCommand("GetTestCommand") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'type': 'command', 'event': 'test_command'}) #Test action.name.replace Get + + cmd = VacBotCommand("Charge") + message['resp'] = "" + assert_dict_equal( + x._ctl_to_dict_api(cmd,message['resp']), + {'event': 'charge_state','ret':'fail', 'errno': '8'}) #Test fail from charge command + + +def test_bad_port(): + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} + mqtt = EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:f123') + assert_equal(8883, mqtt.port) + +def test_good_port(): + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} + mqtt = EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:8000') + assert_equal(8000, mqtt.port) + +def make_ecovacs_iotmq(bot=None): + if bot is None: + bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iotmq": True} + return EcoVacsIOTMQ('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) diff --git a/tests/test_ecovacs_mqtt.py b/tests/test_ecovacs_mqtt.py deleted file mode 100644 index e750118..0000000 --- a/tests/test_ecovacs_mqtt.py +++ /dev/null @@ -1,94 +0,0 @@ -from re import search - -from nose.tools import * - -from sucks import * -import paho.mqtt - -# There are few tests for the MQTT stuff here because it's relatively complicated to test given -# the library's design and its multithreaded nature and lack of explicit testing support. - -def test_subscribe_to_ctls(): - response = None - - def save_response(value): - nonlocal response - response = value - - x = make_ecovacs_mqtt() - - x.subscribe_to_ctls(save_response) - test_message = paho.mqtt.client.MQTTMessage - test_message.topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - test_message.payload = b"" - x._handle_ctl('','',test_message) - - assert_dict_equal(response, {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - -def test_xml_to_dict(): - x = make_ecovacs_mqtt() - - test_topic = 'iot/atr/CleanReport/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'standard', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'clean_report', 'ts':'1547824270099','type': 'auto','speed':'strong', 'st':'h','rsn':'a', 'a':'', 'l':'', 'sts':''}) - - test_topic = 'iot/atr/BatteryInfo/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'battery_info', 'ts':'1547823289924', 'power': '64'}) - - test_topic = 'iot/atr/SleepStatus/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'sleep_status', 'ts':'1547823129670', 'st': '1'}) - - test_topic = 'iot/atr/errors/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'errors', 'ts':'1547822982581','old':'','new':'102'}) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'errors', 'ts':'1547822982581','old':'102','new':''}) - - test_topic = 'iot/atr/Pos/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'pos', 't':'p', 'p':'7,-10', 'a':'-42','valid':'0'}) - - test_topic = 'iot/atr/DustCaseST/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'dust_case_s_t', 'ts':'1547822871328','st':'1'}) - - test_topic = 'iot/atr/MapSt/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - assert_dict_equal( - x._ctl_to_dict(test_topic, ""), - {'event': 'map_st', 'ts':'1547823592934', 'st':'reloc_go_chg_start', 'method':'', 'info':''}) - - - # #TODO: Find a way to check if string is b64 encoded - # test_topic = 'iot/atr/trace/%s/%s/%s/x'.format(x.vacuum['did'], x.vacuum['class'], x.vacuum['resource']) - # assert_dict_equal( - # x._ctl_to_dict(test_topic, ""), - # {'event': 'trace', 'trid':'227975', 'tf':'4', 'tr':'XQAABAAKAAAAAB4AMGAQCdAAAAA='}) - -def test_bad_port(): - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} - mqtt = EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:f123') - assert_equal(8883, mqtt.port) - -def test_good_port(): - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} - mqtt = EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address='test.com:8000') - assert_equal(8000, mqtt.port) - -def make_ecovacs_mqtt(bot=None): - if bot is None: - bot = {"did": "E0000000001234567890", "class": "126","resource":"test_resource", "nick": "bob", "iot": True} - return EcoVacsMQTT('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index b76aebf..1bd6b23 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -31,7 +31,6 @@ def test_subscribe_to_ctls(): x._handle_ctl(query) assert_dict_equal(response, {'event': 'clean_report', 'type': 'auto'}) - def test_xml_to_dict(): x = make_ecovacs_xmpp() @@ -53,13 +52,18 @@ def test_xml_to_dict(): assert_dict_equal( x._ctl_to_dict(make_ctl('')), {'event': 'life_span', 'type': 'dust_case_heap', 'val': '-050', 'total': '365'}) + + assert_equals(x._ctl_to_dict(make_ctl('')), None) -def make_ecovacs_xmpp(bot=None): +def make_ecovacs_xmpp(bot=None, server_address=None): if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": False} - return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot) + bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq": False} + return EcoVacsXMPP('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', 'na', bot, server_address=server_address) +def test_xmpp_customaddress(): + x = make_ecovacs_xmpp(server_address="test.xmppserver.com") + assert_equals(x.server_address, "test.xmppserver.com") def make_ctl(string): return ET.fromstring('' + string + '')[0] diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index d57f7cd..8bb66b2 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -34,7 +34,7 @@ def test_handle_clean_report(): def test_not_iot_send_command_clean(): from unittest.mock import MagicMock - v = a_vacbot(iot=False) + v = a_vacbot(iotmq=False) v.xmpp.send_command = MagicMock() v.send_command(VacBotCommand('Clean')) assert v.xmpp.send_command.called #test when iot is False it uses xmpp.send_command @@ -42,10 +42,10 @@ def test_not_iot_send_command_clean(): def test_iot_send_command_clean(): from unittest.mock import MagicMock - v = a_vacbot(iot=True) - v.iot.send_command = MagicMock() + v = a_vacbot(iotmq=True) + v.iotmq.send_command = MagicMock() v.send_command(VacBotCommand('Clean')) - assert v.iot.send_command.called #test when iot is True it uses iot.send_command + assert v.iotmq.send_command.called #test when iot is True it uses iotmq.send_command def test_handle_charge_state(): @@ -149,11 +149,11 @@ def test_is_cleaning(): v._handle_ctl({'event': 'charge_state', 'type': 'going'}) assert_false(v.is_cleaning) - v = a_vacbot(iot=True) + v = a_vacbot(iotmq=True) v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'h'}) assert_false(v.is_cleaning) #test iot and state paused - v = a_vacbot(iot=True) + v = a_vacbot(iotmq=True) v._handle_ctl({'event': 'clean_report', 'type': 'spot_area', 'speed':'normal','st':'r'}) assert_true(v.is_cleaning) #test iot and state running @@ -198,8 +198,8 @@ def test_send_ping_no_monitor(): assert_equals(None, v.vacuum_status) #Test MQTT Ping - v = a_vacbot(iot=True) - mock = v.mqtt.send_ping = Mock() + v = a_vacbot(iotmq=True) + mock = v.iotmq.send_ping = Mock() v.send_ping() # On four failed pings, vacuum state gets set to 'offline' @@ -246,9 +246,9 @@ def test_send_ping_with_monitor(): assert_equals(1, request_statuses_mock.call_count) #Test MQTT Ping - v = a_vacbot(iot=True, monitor=True) + v = a_vacbot(iotmq=True, monitor=True) - ping_mock = v.mqtt.send_ping = Mock() + ping_mock = v.iotmq.send_ping = Mock() request_statuses_mock = v.request_all_statuses = Mock() # First ping should try to fetch statuses @@ -366,84 +366,27 @@ def test_handle_unknown_ctl(): # plus errors! def test_bot_address(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot":False}) + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq":False}) assert_equals('E0000000001234567890@126.ecorobot.net/atom', v._vacuum_address()) def test_bot_address_iot(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot":True}) + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq":True}) assert_equals('E0000000001234567890', v._vacuum_address()) def test_model_variation(): - v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob","iot":False}) + v = a_vacbot(bot={"did": "E0000000001234567890", "class": "141", "nick": "bob","iotmq":False}) assert_equals('E0000000001234567890@141.ecorobot.net/atom', v._vacuum_address()) -def a_vacbot(bot=None, iot=False, monitor=False): +def a_vacbot(bot=None, iotmq=False, monitor=False): if bot is None: - bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iot": iot} + bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob", "iotmq": iotmq} return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', bot, 'na', monitor=monitor) def test_str_to_bool(): assert_raises(ValueError, str_to_bool, None) #Value error if str_to_bool can't convert - - -def test_connect_and_wait(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False, monitor=True) - v.xmpp.connect_and_wait_until_ready = MagicMock() - v.send_ping = MagicMock() - v.xmpp.schedule = MagicMock() - v.connect_and_wait_until_ready() - assert v.xmpp.schedule.called #test when iot is False it uses xmpp.schedule - - v = a_vacbot(iot=True) - v.mqtt.connect_and_wait_until_ready = MagicMock() - v.send_ping = MagicMock() - v.mqtt.schedule = MagicMock() - v.connect_and_wait_until_ready() - assert v.mqtt.schedule.called #test when iot is True it uses mqtt.schedule - -def test_run(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False) - v.send_command = MagicMock() - v.run(VacBotCommand('Clean')) - assert v.send_command.called - -def test_disconnect(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False) - v.xmpp.disconnect = MagicMock() - v.disconnect() - assert v.xmpp.disconnect.called - - v = a_vacbot(iot=True) - v.mqtt.disconnect = MagicMock() - v.disconnect() - assert v.mqtt.disconnect.called - -def test_refresh_all(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False) - v.refresh_statuses = MagicMock() - v.refresh_components = MagicMock - v.request_all_statuses() - assert v.refresh_components.called and v.refresh_statuses.called - -def test_refresh_statuses(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False) - v.run = MagicMock() - v.refresh_statuses() - assert v.run.called - -def test_refresh_components(): - from unittest.mock import MagicMock - v = a_vacbot(iot=False) - v.run = MagicMock() - v.refresh_components() - assert v.run.called + \ No newline at end of file From ab303710a79e126e799ebf18f1510185ea75273b Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Feb 2019 21:26:49 -0500 Subject: [PATCH 33/48] Update protocol.md --- protocol.md | 112 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/protocol.md b/protocol.md index 04af3a0..68c97d9 100644 --- a/protocol.md +++ b/protocol.md @@ -60,8 +60,8 @@ There are a few different endpoints within the API that have been seen and are u | Endpoint | Description | | ----------------------------- | ----------------------------------------- | | /users/user.do | Handles user / account functions | -| /iot/devmanager.do | Handles sending commands to "IOT" devices | -| /pim/product/getProductIotMap | Provides the "IOT" Product map | +| /iot/devmanager.do | Provides a RestAPI that handles sending commands to "IOTMQ" devices | +| /pim/product/getProductIotMap | Provides a listing of "IOT" Products | @@ -74,27 +74,16 @@ from step 4, gets the list of devices; that's needed for talking to the vacuum via XMPP 7. POST portal-na.ecouser.net/api/pim/product/getProductIotMap -getProductIotMap - Provides a list of "IOT" products, the devices are referenced in the table below and these are assumed to be "IOT" devices within the library. +getProductIotMap - Provides a list of "IOT" products, it isn't clear what the app uses these for at this time, possibly for determining how to get updates. - |IOT Products | - |---| - |DEEBOT 600 Series| - |DEEBOT OZMO Slim10 Series | - |DEEBOT OZMO 900| - |DEEBOT 711| - |DEEBOT 710| - |DEEBOT 900 Series| +At this point depending on your device you will connect to either an XMPP server, or an MQTT server. - - -At this point depending on your device you will connect to either an XMPP server, or an MQTT server. This is believed to be based on the "IOT Products" vs "Non-IOT" products. - -| "Non-IOT" Products | "IOT" Products | +| "IOT XMPP" Products | "IOT MQ" Products | |----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Connect to an XMPP server to send commands to devices and receive status results | Connect to an MQTT server to subscribe to status messages and results. A Rest API is utilized to send commands to devices, but can also be used to obtain statuses. | -## XMPP - ("Non-IOT") +## XMPP - ("IOT XMPP") The app establishes a connection to an XMPP server and logs in using a secret that comes from the earlier HTTPS calls. It then sends XMPP IQ @@ -111,7 +100,7 @@ The Android App uses the following XMPP messaging servers: |FR, ES, UK, NO, MX, DE, PT, CH, AU, IT, NL, SE, BE, DK|msg-eu.ecouser.net| |Any other|msg-ww.ecouser.net| -## MQTT - ("IOT") +## MQTT - ("IOT MQ") The app establishes a connection to an MQTT server and logs in using a secret that comes from the earlier HTTPS calls. @@ -130,9 +119,9 @@ It is believed the MQTT servers mirror the XMPP servers, but only the NA and WW |US|mq-na.ecouser.net| |"World-wide"|mq-ww.ecouser.net| -## Rest API - ("IOT") +## Rest API - ("IOT MQ") -For IOT devices the app sends commands to the device over a Rest API utilizing the secret that comes from the earlier HTTPS calls. This API has only been tested from an "IOT" device, but could possibly work for "Non-IOT" devices as well. +For IOT MQ devices the app sends commands to the device over a Rest API utilizing the secret that comes from the earlier HTTPS calls. This API has only been tested from an "IOT MQ" device, but could possibly work for other devices as well. The Rest API utilizes the same portal URL as used previously, but with the iot/devmanager endpoint: ` @@ -171,7 +160,7 @@ Commands are sent via POST in the format of: - type `spot` spot cleaning program - type `singleroom` cleaning a single room - type `stop` bot at full stop - - type `SpotArea` cleaning a mapped room + - type `SpotArea` cleaning a mapped room (mapping robots only) - speed `standard` regular fan speed (suction) - speed `strong` high fan speed (suction) @@ -195,7 +184,7 @@ Commands are sent via POST in the format of: ### Battery State Battery charge level. 080 = 80% charged. State is broadcast -continously when the robot is running och charging, but can also +continously when the robot is running or charging, but can also be requested manually. - *Request* `` @@ -230,17 +219,42 @@ It's presumed that the timers need to be reset manually. ### Configuration -**Set/get robot internal clock** +#### Set/get robot internal clock - `` - `` - Time is specified as a UNIX timestamp and timezone + or - UTC offset. -**Get firmware version** +#### Get firmware version `` -**Get robot logs** +#### Get robot logs `` +#### Get/Set option value +Gets or sets value for option (0==Off, 1==On) +##### GetOnOff + - Do Not Disturb - `` + - Continuous Cleaning - `` + - Silence Voice Report - `` + + Returns `` +##### SetOnOff + - Do Not Disturb - `` + - Continuous Cleaning - `` + - Silence Voice Report - `` + + Returns `` + +#### Schedules +##### GetSched +`` + +Gets any schedules for the robot. + +- No Schedules + - `` +- Schedule + - `` ### Errors @@ -266,18 +280,37 @@ HostHang, then proceeds to stop and broadcasts 100 NoError. |-----|-----| |100|NoError: Robot is operational| |101|BatteryLow: Low battery| -|102|HostHang: Robot is stuck| -|103|WheelAbnormal: Wheels are not moving as expected| -|104|DownSensorAbnormal: Down sensor is getting abnormal values| +|102|HostHang: Robot is off the floor| +|103|WheelAbnormal: Driving Wheel malfunction| +|104|DownSensorAbnormal: Excess dust on the Anti-Drop Sensors| +|105|Stuck: Robot is stuck| +|106|SideBrushExhausted: Side Brushes have expired| +|107|DustCaseHeapExhausted: Dust case filter expired| +|108|SideAbnormal: Side Brushes are tangled| +|109|RollAbnormal: Main Brush is tangled| |110|NoDustBox: Dust Bin Not installed| +|111|BumpAbnormal: Bump sensor stuck| +|112|LDS: LDS "Laser Distance Sensor" malfunction| +|113|MainBrushExhausted: Main brush has expired| +|114|DustCaseFilled: Dust bin full| +|115|BatteryError: | +|116|ForwardLookingError: | +|117|GyroscopeError: | +|118|StrainerBlock: | +|119|FanError: | +|120|WaterBoxError: | +|201|AirFilterUninstall: | +|202|UltrasonicComponentAbnormal| +|203|SmallWheelError| +|UNKNOW|"unknow"| -These codes are taken from model M81 Pro. Error codes may differ +These codes were gathered from the Android app source, but may differ between models. ### Sounds -Different sid "Sound IDs" will play different sounds. If the vacuum has Voice Report disabled, these won't play. +Different sid "Sound IDs" will play different sounds. If the vacuum has Voice Report disabled, these won't play. The table below was compiled by testing against a D900 series. -`` +`` |SID |Description | |-----|------------------------------------------------------------| @@ -311,19 +344,6 @@ Different sid "Sound IDs" will play different sounds. If the vacuum has Voice R | 84 | I am ready for mopping | | 85 | Please remove the mopping plate when I am building the map | | 86 | Cleaning is complete returning to the charging dock | -| 89 | LVS Malfunction please try to tap the LVS | -| 90 | I am upgrading please wait | +| 89 | LDS Malfunction please try to tap the LDS | +| 90 | I am upgrading please wait | - -### Untested commands - -``` - - - - -``` - -It appears that it adds an extra id when it cares to receive a specific response. -This is a little odd in that the iq blocks already contain ids, but perhaps one -is more a server id and the other is used by the robot itself. From 6b23d8553e3bd27d3ada74fab20747571f3a155b Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Feb 2019 21:37:49 -0500 Subject: [PATCH 34/48] Add SpotArea details --- protocol.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/protocol.md b/protocol.md index 68c97d9..0a50031 100644 --- a/protocol.md +++ b/protocol.md @@ -347,3 +347,48 @@ Different sid "Sound IDs" will play different sounds. If the vacuum has Voice R | 89 | LDS Malfunction please try to tap the LDS | | 90 | I am upgrading please wait | +### SpotAreas +For bots with mapping capability this tells a bot to clean specified rooms. + +For the CLI - the `spotclean` command takes a csv of ints - ex `spotclean 0,1` + +For the Library - you could use `vacbot.run(SpotArea('start', '0,1'))` + +"0,1" is a list of mapIDs the bot should clean. Each of these corresponds to a room or area the bot mapped. In the app, these are what show the letters over rooms mapID (0) == room ("A"), (1) == "B", etc. + +If you want to see your MapSet areas, you can use the library. Set --debug for sucks and then use a custom command: +`vacbot.run(VacBotCommand("GetMapSet", {"tp":"sa"}))` + +You'll see in DEBUG something like: +``` +sucks DEBUG got {'id': 'ralnsy', 'ret': 'ok', 'resp': ""} +``` +This tells you I have 9 rooms mapped (mid= 0 - 8) or A-I, but you should be able to compare to the map in the app now to know which mid == what room. + +#### SpotArea Friendly Names +For bots with mapping capability the app automatically names areas (rooms) A-Z. You can rename these to "friendly names" - something the app won't let you do natively. + +Use the above "GetMapSet" custom command and then convert the xml to json: +``` xml + +``` +becomes +``` javascript +{"ctl":{"ret":"ok","tp":"sa","msid":"11","m":[{"mid":"0","p":"1"},{"mid":"1","p":"1"},{"mid":"2","p":"1"},{"mid":"3","p":"1"},{"mid":"4","p":"1"},{"mid":"5","p":"1"},{"mid":"6","p":"1"},{"mid":"7","p":"1"},{"mid":"8","p":"1"}]}} +``` +Now you need to add a "n" attribute which contains the friendly name: +``` javascript +{"ctl":{"ret":"ok","tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]}} +``` +Remove the api response details ("ctl" and "ret"): +``` javascript +{"tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]} +``` +Lastly use the below command to issue the rename: +``` +vacbot.run(VacBotCommand("RenameM", {"tp":"sa","msid":"11","m":[{"mid":"0","n":"Entry"},{"mid":"1","n":"Master Bath"},{"mid":"2","n":"Master"},{"mid":"3","n":"Office"},{"mid":"4","n":"Play Room"},{"mid":"5","n":"Craft Room"},{"mid":"6","n":"Kitchen"},{"mid":"7","n":"Sun Room"},{"mid":"8","n":"Garage Entry"}]})) +``` +You should then see the friendly names in the app when selecting an area to clean. + +**Note:** You cannot use the friendly names when starting a clean, you must use the mid. + From f1257b80d0553d2f57577e2e7823a42f3118ff77 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Feb 2019 21:45:09 -0500 Subject: [PATCH 35/48] format updates --- protocol.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/protocol.md b/protocol.md index 0a50031..40c7e8d 100644 --- a/protocol.md +++ b/protocol.md @@ -57,15 +57,14 @@ the XMPP/device server. There are a few different endpoints within the API that have been seen and are used in the library: -| Endpoint | Description | -| ----------------------------- | ----------------------------------------- | -| /users/user.do | Handles user / account functions | -| /iot/devmanager.do | Provides a RestAPI that handles sending commands to "IOTMQ" devices | -| /pim/product/getProductIotMap | Provides a listing of "IOT" Products | +| Endpoint | Description | +| - | - | +| /users/user.do | Handles user / account functions | +| /iot/devmanager.do | Provides a RestAPI that handles sending commands to "IOTMQ" devices | +| /pim/product/getProductIotMap | Provides a listing of "IOT" Products | - -4. POST portal-na.ecouser.net/api/users/user.do loginByItToken - trades the +1. POST portal-na.ecouser.net/api/users/user.do loginByItToken - trades the authCode from the previous call for yet another token 5. POST ne-na.ecouser.net:8018/notify_engine.do - not sure what this is for; my script skips this and seems to work fine @@ -78,8 +77,8 @@ getProductIotMap - Provides a list of "IOT" products, it isn't clear what the ap At this point depending on your device you will connect to either an XMPP server, or an MQTT server. -| "IOT XMPP" Products | "IOT MQ" Products | -|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| "IOT XMPP" Products | "IOT MQ" Products | +| - | - | | Connect to an XMPP server to send commands to devices and receive status results | Connect to an MQTT server to subscribe to status messages and results. A Rest API is utilized to send commands to devices, but can also be used to obtain statuses. | @@ -93,7 +92,7 @@ elements that appear to be commands. The Android App uses the following XMPP messaging servers: |Country|URL| -|------|--------| +| - | - | |CH|msg.ecouser.net| |TW, MY, JP, SG, TH, HK, IN, KR|msg-as.ecouser.net| |US|msg-na.ecouser.net| @@ -115,7 +114,7 @@ iot/atr/+/{deviceID}/{deviceClass}/{deviceResource}/+ It is believed the MQTT servers mirror the XMPP servers, but only the NA and WW have been tested so far. |Country|URL| -|------|--------| +|-|-| |US|mq-na.ecouser.net| |"World-wide"|mq-ww.ecouser.net| @@ -147,6 +146,8 @@ Commands are sent via POST in the format of: } ``` +## Commands + ### Cleaning **Command** @@ -180,7 +181,6 @@ Commands are sent via POST in the format of: - `WireCharging` currently charging by cable - ### Battery State Battery charge level. 080 = 80% charged. State is broadcast @@ -277,7 +277,7 @@ HostHang, then proceeds to stop and broadcasts 100 NoError. **Known error codes** |Code|Description| -|-----|-----| +|-|-| |100|NoError: Robot is operational| |101|BatteryLow: Low battery| |102|HostHang: Robot is off the floor| @@ -312,8 +312,8 @@ Different sid "Sound IDs" will play different sounds. If the vacuum has Voice R `` -|SID |Description | -|-----|------------------------------------------------------------| +| SID | Description | +|-|-| | 0 | Startup Music Chime | | 3 | I Am Suspended | | 4 | Check Driving Wheels | From 6c31e0752dac4f4c9a7294305e68a35ea83cb510 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Feb 2019 22:22:38 -0500 Subject: [PATCH 36/48] add comments and cleanup --- sucks/__init__.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 2b90f34..711d156 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -401,6 +401,8 @@ class VacBot(): else: self.iotmq = EcoVacsIOTMQ(user, domain, resource, secret, continent, vacuum, server_address, verify_ssl=verify_ssl) self.iotmq.subscribe_to_ctls(self._handle_ctl) + #The app still connects to XMPP as well, but only issues ping commands. + #Everything works without XMPP, so leaving the below commented out. #self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, vacuum, server_address) #Uncomment line to allow unencrypted plain auth #self.xmpp['feature_mechanisms'].unencrypted_plain = True @@ -413,7 +415,7 @@ class VacBot(): else: self.iotmq.connect_and_wait_until_ready() self.iotmq.schedule(30, self.send_ping) - #self.xmpp.connect_and_wait_until_ready() + #self.xmpp.connect_and_wait_until_ready() #Leaving in case xmpp is given to iotmq in the future if self._monitor: # Do a first ping, which will also fetch initial statuses if the ping succeeds @@ -539,12 +541,7 @@ class VacBot(): self.xmpp.send_ping(self._vacuum_address()) elif self.vacuum['iotmq']: if not self.iotmq.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 communications. IOT should probably be using MQTT pings (which are automatic when connected) - + raise RuntimeError() except XMPPError as err: _LOGGER.warning("Ping did not reach VacBot. Will retry.") @@ -613,7 +610,7 @@ class VacBot(): self.xmpp.disconnect(wait=wait) else: self.iotmq._disconnect() - #self.xmpp.disconnect(wait=wait) + #self.xmpp.disconnect(wait=wait) #Leaving in case xmpp is added to iotmq in the future #This is used by EcoVacsIOTMQ and EcoVacsXMPP for _ctl_to_dict def RepresentsInt(stringvar): @@ -671,10 +668,7 @@ class EcoVacsIOTMQ(ClientMQTT): self.wait_until_ready() def subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) - - #def subscribe_to_ctls_mqtt(self, function): - # self.ctl_subscribers.append(function) + self.ctl_subscribers.append(function) def _disconnect(self): self.disconnect() #disconnect mqtt connection From 7fac965ff17d1329b505e42504c7b9543d65d0f5 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 19 Feb 2019 02:12:43 -0500 Subject: [PATCH 37/48] Update setup.py Added paho-mqtt dependency. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 3fbe013..b6a6ce7 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ setup( 'requests>=2.18', 'pycryptodome>=3.4', 'pycountry-convert>=0.5', + 'paho-mqtt>=1.4', 'stringcase>=1.2' ], From b7341b07359c7e778465676a43d67f0cd5741dd4 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 20 Feb 2019 10:45:24 -0500 Subject: [PATCH 38/48] Update SpotArea Update SpotArea and CLI command to more closely reflect the app Library: SpotArea namedarea -> area SpotArea customarea -> map_position CLI Commands: spotclean -> area Ex: sucks area 0,1 - will clean areas 0 and 1 / A and B area options: --map-postion|-p - will clean a specified map coordinate Ex: sucks area -p "-602,1812,800,723" - will clean the specified custom map coordinates --- sucks/__init__.py | 12 ++++++------ sucks/cli.py | 13 +++++++++---- tests/test_commands.py | 22 +++++++++++----------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 711d156..f8b7c4e 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -1020,14 +1020,14 @@ class Stop(Clean): super().__init__('stop', 'normal') class SpotArea(Clean): - def __init__(self, action='start', namedarea='', customarea='', cleanings='1'): - if namedarea != '': #For cleaning specified map area - super().__init__('spot_area', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=namedarea) - elif customarea != '': #For cleaning custom map area, and specify deep amount 1x/2x - super().__init__('spot_area' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=customarea, deep=cleanings) + def __init__(self, action='start', area='', map_position='', cleanings='1'): + if area != '': #For cleaning specified area + super().__init__('spot_area', 'normal', act=CLEAN_ACTION_TO_ECOVACS[action], mid=area) + elif map_position != '': #For cleaning custom map area, and specify deep amount 1x/2x + super().__init__('spot_area' ,'normal',act=CLEAN_ACTION_TO_ECOVACS[action], p=map_position, deep=cleanings) else: #no valid entries - raise ValueError("must provide namedarea or customarea for spotarea clean") + raise ValueError("must provide area or map_position for spotarea clean") class Charge(VacBotCommand): def __init__(self): diff --git a/sucks/cli.py b/sucks/cli.py index a241ddf..042d5bb 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -179,10 +179,15 @@ def edge(frequency, minutes): return CliAction(Edge(), wait=TimeWait(minutes * 60)) -@cli.command(help='spotcleans provided room(s)') -@click.argument('room', type=click.STRING) -def spotclean(room): - return CliAction(SpotArea('start', room), wait=StatusWait('charge_status', 'returning')) +@cli.command(help='cleans provided area(s), ex: "0,1"',context_settings={"ignore_unknown_options": True}) #ignore_unknown for map coordinates with negatives +@click.option("--map-position","-p", is_flag=True, help='clean provided map position instead of area, ex: "-602,1812,800,723"') +@click.argument('area', type=click.STRING, required=True) +def area(area, map_position): + if map_position: + return CliAction(SpotArea('start', map_position=area), wait=StatusWait('charge_status', 'returning')) + else: + return CliAction(SpotArea('start', area=area), wait=StatusWait('charge_status', 'returning')) + @cli.command(help='returns to charger') def charge(): diff --git a/tests/test_commands.py b/tests/test_commands.py index 7afa2f5..bbdd4a9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -61,31 +61,31 @@ def test_spotarea_command(): assert_equals(ElementTree.tostring(c.to_xml()), b'') #Test namedarea clean - c = SpotArea('start', namedarea='0') + c = SpotArea('start', area='0') assert_equals(ElementTree.tostring(c.to_xml()), b'') #Test namedarea keyword clean - c = SpotArea('start', '', '01234,56789') + c = SpotArea('start', '', '-602,1812,800,723') assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea clean + b'') #Test customarea clean - c = SpotArea('start', '', '01234,56789', '2') + c = SpotArea('start', '', '-602,1812,800,723', '2') assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea clean with deep 2 + b'') #Test customarea clean with deep 2 - c = SpotArea('start', '', customarea='01234,56789') + c = SpotArea('start', '', map_position='-602,1812,800,723') assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea keyword clean with deep default + b'') #Test customarea keyword clean with deep default - c = SpotArea('start', customarea='01234,56789', cleanings='2') + c = SpotArea('start', map_position='-602,1812,800,723', cleanings='2') assert_equals(ElementTree.tostring(c.to_xml()), - b'') #Test customarea keyword and cleanings keyword clean with deep default + b'') #Test customarea keyword and cleanings keyword clean with deep default - c = SpotArea('start', namedarea='0', customarea='01234,56789', cleanings='2') + c = SpotArea('start', area='0', map_position='-602,1812,800,723', cleanings='2') assert_equals(ElementTree.tostring(c.to_xml()), b'') #Test all keywords specified, should default to only mid - c = SpotArea('start', '0', '01234,56789','2') + c = SpotArea('start', '0', '-602,1812,800,723','2') assert_equals(ElementTree.tostring(c.to_xml()), b'') #Test all keywords specified, should default to only mid From 0fa5fe4aaed1064b8befa30318dcaa629d6c34e1 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 20 Feb 2019 20:51:34 -0500 Subject: [PATCH 39/48] Update protocol.md Update protocol.md to reflect changes to CLI area command. --- protocol.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/protocol.md b/protocol.md index 40c7e8d..4267ef2 100644 --- a/protocol.md +++ b/protocol.md @@ -350,7 +350,9 @@ Different sid "Sound IDs" will play different sounds. If the vacuum has Voice R ### SpotAreas For bots with mapping capability this tells a bot to clean specified rooms. -For the CLI - the `spotclean` command takes a csv of ints - ex `spotclean 0,1` +For the CLI - the `area` command takes a csv of ints - ex `area 0,1` + +You can add the option `--map-position` or `-p` to clean a specified map coordinate - ex `area -p "-602,1812,800,723"` For the Library - you could use `vacbot.run(SpotArea('start', '0,1'))` From c37ebdd79435c334915def76e85207a69ddce0c0 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 25 Feb 2019 09:50:20 -0500 Subject: [PATCH 40/48] Ozmo930 working - Changed ClientXMPP to login/bind with resource - Changed clean to always add the action - Wrap command calls a new getReqID to add an ID to the ctl if needed - This was required for the ozmo commands, and shouldn't affect other boths - Updated tests --- sucks/__init__.py | 28 +++++++++++++++++++--------- tests/test_commands.py | 10 +++++----- tests/test_ecovacs_xmpp.py | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index f8b7c4e..2b32ba5 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -6,9 +6,11 @@ from collections import OrderedDict from threading import Event import threading import sched - +import random +import ssl import requests import stringcase + from sleekxmpp import ClientXMPP, Callback, MatchXPath from sleekxmpp.xmlstream import ET from sleekxmpp.exceptions import XMPPError @@ -17,8 +19,6 @@ 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. @@ -856,7 +856,7 @@ class EcoVacsIOTMQ(ClientMQTT): class EcoVacsXMPP(ClientXMPP): def __init__(self, user, domain, resource, secret, continent, vacuum, server_address=None ): - ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret) + ClientXMPP.__init__(self, "{}@{}/{}".format(user, domain,resource), '0/' + resource + '/' + secret) #Init with resource to bind it self.user = user self.domain = domain self.resource = resource @@ -922,12 +922,24 @@ class EcoVacsXMPP(ClientXMPP): def _wrap_command(self, ctl, recipient): q = self.make_iq_query(xmlns=u'com:ctl', ito=recipient, ifrom=self._my_address()) - q['type'] = 'set' + 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 @@ -995,10 +1007,8 @@ class VacBotCommand: class Clean(VacBotCommand): def __init__(self, mode='auto', speed='normal', iotmq=False, action='start',terminal=False, **kwargs): if kwargs == {}: - if not iotmq: - 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]}}) + #Looks like action is needed for some bots, shouldn't affect older models + 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(): diff --git a/tests/test_commands.py b/tests/test_commands.py index bbdd4a9..c40baf4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -43,11 +43,11 @@ def test_custom_command_noargs(): def test_clean_command(): c = Clean() assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order + b'') # protocol has attribs in other order c = Clean('edge', 'high') assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order + b'') # protocol has attribs in other order c = Clean(iotmq=True) assert_equals(ElementTree.tostring(c.to_xml()), @@ -93,13 +93,13 @@ def test_spotarea_command(): def test_edge_command(): c = Edge() assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order + b'') # protocol has attribs in other order def test_spot_command(): c = Spot() assert_equals(ElementTree.tostring(c.to_xml()), - b'') # protocol has attribs in other order + b'') # protocol has attribs in other order def test_charge_command(): @@ -111,7 +111,7 @@ def test_charge_command(): def test_stop_command(): c = Stop() assert_equals(ElementTree.tostring(c.to_xml()), - b'') + b'') def test_play_sound_command(): diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index 1bd6b23..7f5331a 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -13,6 +13,20 @@ def test_wrap_command(): c = str(x._wrap_command(Clean().to_xml(), 'E0000000001234567890@126.ecorobot.net/atom')) assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) + assert_true(search(r'td="Clean" id="',c)) #Check that an id was added to ctl + + cwithid = Clean().to_xml() + cwithid.attrib["id"] = "12345678" + c = str(x._wrap_command(cwithid, 'E0000000001234567890@126.ecorobot.net/atom')) + assert_true(search(r'td="Clean" id="12345678',c)) #Check that customid was added to ctl + +def test_getReqID(): + x = make_ecovacs_xmpp() + rid = x.getReqID("12345678") + assert_equals(rid, "12345678") #Check returned ID is the same as provided + + rid2 = x.getReqID() + assert_true(len(rid2) >= 8) #Check returned random ID is at least 8 chars def test_subscribe_to_ctls(): response = None From 3dbaccd2e85b90a362bfdd694a294516b3644367 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 27 Feb 2019 23:29:21 -0500 Subject: [PATCH 41/48] Update test_ecovacs_xmpp.py Remove failing python 3.5 test for now --- tests/test_ecovacs_xmpp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index 7f5331a..21e0278 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -13,7 +13,7 @@ def test_wrap_command(): c = str(x._wrap_command(Clean().to_xml(), 'E0000000001234567890@126.ecorobot.net/atom')) assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) - assert_true(search(r'td="Clean" id="',c)) #Check that an id was added to ctl + #Remove failing Python 3.5 test for now - assert_true(search(r'td="Clean" id="',c)) #Check that an id was added to ctl cwithid = Clean().to_xml() cwithid.attrib["id"] = "12345678" From 91e3f3d5776dba43a35a615d6748b4bb03f75835 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 28 Feb 2019 08:49:05 -0500 Subject: [PATCH 42/48] fix failing tests? Is 3.5 failing because of a space? --- tests/test_ecovacs_xmpp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index 21e0278..47bc020 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -13,12 +13,12 @@ def test_wrap_command(): c = str(x._wrap_command(Clean().to_xml(), 'E0000000001234567890@126.ecorobot.net/atom')) assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) - #Remove failing Python 3.5 test for now - assert_true(search(r'td="Clean" id="',c)) #Check that an id was added to ctl + assert_true(search(r'td="Clean" id="', c)) #Check that an id was added to ctl cwithid = Clean().to_xml() cwithid.attrib["id"] = "12345678" c = str(x._wrap_command(cwithid, 'E0000000001234567890@126.ecorobot.net/atom')) - assert_true(search(r'td="Clean" id="12345678',c)) #Check that customid was added to ctl + assert_true(search(r'td="Clean" id="12345678', c)) #Check that customid was added to ctl def test_getReqID(): x = make_ecovacs_xmpp() From e4848cb4d8c0a53eef205a3550d3ab86a200ed2a Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Thu, 28 Feb 2019 09:20:29 -0500 Subject: [PATCH 43/48] Fix failing test It wasn't because of a space.... The tests were failing depending on the run because the xml string may have the id in a different order from run to run. Changed test to convert string to xml for testing id exists or matches customid. --- tests/test_ecovacs_xmpp.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_ecovacs_xmpp.py b/tests/test_ecovacs_xmpp.py index 47bc020..184aeaa 100644 --- a/tests/test_ecovacs_xmpp.py +++ b/tests/test_ecovacs_xmpp.py @@ -12,13 +12,21 @@ def test_wrap_command(): x = make_ecovacs_xmpp() c = str(x._wrap_command(Clean().to_xml(), 'E0000000001234567890@126.ecorobot.net/atom')) assert_true(search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c)) - assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) - assert_true(search(r'td="Clean" id="', c)) #Check that an id was added to ctl + assert_true(search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c)) + #Convert to XML to make it easy to see if id was added to ctl + xml_test = ET.fromstring(c) + ctl = xml_test.getchildren()[0][0] + assert_true(ctl.get("id")) #Check that an id was added to ctl + #Test if customid is added to ctl cwithid = Clean().to_xml() - cwithid.attrib["id"] = "12345678" + cwithid.attrib["id"] = "12345678" #customid 12345678 c = str(x._wrap_command(cwithid, 'E0000000001234567890@126.ecorobot.net/atom')) - assert_true(search(r'td="Clean" id="12345678', c)) #Check that customid was added to ctl + #Convert to XML to make it easy to see if id was added to ctl + xml_test = ET.fromstring(c) + ctl = xml_test.getchildren()[0][0] + assert_equals(ctl.get("id"), "12345678") #Check that an id was added to ctl + def test_getReqID(): x = make_ecovacs_xmpp() From 3942e22205ff8e907a7a12f667328b873f945b51 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 13 Mar 2019 20:24:30 -0400 Subject: [PATCH 44/48] Add D600 to iotmqdevices Add D600 (dl8fht) to iotmqdevices - CR: Fredric Palmgren --- sucks/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sucks/__init__.py b/sucks/__init__.py index 2b32ba5..da326f4 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -304,6 +304,7 @@ class EcoVacsAPI: #These items were found in the Android app source by searching for "new IOTMqDevice(" iotmqdevices = [ 'ls1ok3', #D900 / DE5G + 'dl8fht', #D600 # Possibly the Atmobot AA30 - qqy0di # Possibly the Slim4 - wbueya ] From 70ecd776c88408e2c35381c96a432df6720592cf Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Wed, 13 Mar 2019 20:35:15 -0400 Subject: [PATCH 45/48] Removing test made on assumptions Removing a test based on the assumption that iot products may not work as iotmq. This broke because D600 was added to the list of iotmq devices, but we can't assume the other devices from the productiotmap may not be added to the iotmq list in the future and this will just keep breaking. It really doesn't provide much value from a test perspective. --- tests/test_ecovacs_api.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/tests/test_ecovacs_api.py b/tests/test_ecovacs_api.py index 63fb4f1..9166f12 100644 --- a/tests/test_ecovacs_api.py +++ b/tests/test_ecovacs_api.py @@ -173,29 +173,6 @@ def test_device_lookup_IOTProduct(): assert_equals(vacuum['iot_product'], False) assert_equals(vacuum['iotmq'], False) -def test_device_lookup_is_IOTProduct_not_IOTMQ(): - api = make_api() - with requests_mock.mock() as m: - device_id = 'E0000001234567890123' - device_class = 'dl8fht' #D600 - - r = m.post(compile('user.do'), - text='{"todo": "result", "devices": [{"did": "%s", "class": "%s", "nick": "bob"}], "result": "ok"}' %(device_id, device_class)) - r = m.post(compile('pim/product/getProductIotMap'), - text='{"code":0,"data":[{"classid":"dl8fht","product":{"_id":"5acb0fa87c295c0001876ecf","name":"DEEBOT 600 Series","icon":"5acc32067c295c0001876eea","UILogicId":"dl8fht","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5acc32067c295c0001876eea"}},{"classid":"02uwxm","product":{"_id":"5ae1481e7ccd1a0001e1f69e","name":"DEEBOT OZMO Slim10 Series","icon":"5b1dddc48bc45700014035a1","UILogicId":"02uwxm","ota":false,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b1dddc48bc45700014035a1"}},{"classid":"y79a7u","product":{"_id":"5b04c0227ccd1a0001e1f6a8","name":"DEEBOT OZMO 900","icon":"5b04c0217ccd1a0001e1f6a7","UILogicId":"y79a7u","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b04c0217ccd1a0001e1f6a7"}},{"classid":"jr3pqa","product":{"_id":"5b43077b8bc457000140363e","name":"DEEBOT 711","icon":"5b5ac4cc8d5a56000111e769","UILogicId":"jr3pqa","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4cc8d5a56000111e769"}},{"classid":"uv242z","product":{"_id":"5b5149b4ac0b87000148c128","name":"DEEBOT 710","icon":"5b5ac4e45f21100001882bb9","UILogicId":"uv242z","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5b5ac4e45f21100001882bb9"}},{"classid":"ls1ok3","product":{"_id":"5b6561060506b100015c8868","name":"DEEBOT 900 Series","icon":"5ba4a2cb6c2f120001c32839","UILogicId":"ls1ok3","ota":true,"iconUrl":"https://portal-ww.ecouser.net/api/pim/file/get/5ba4a2cb6c2f120001c32839"}}]}') - - d = api.devices() - d = api.SetIOTDevices(d, api.getiotProducts()) - - assert_equals(r.call_count, 1) - assert_equals(len(d), 1) - vacuum = d[0] - assert_equals(vacuum['did'], device_id) - assert_equals(vacuum['class'], device_class) - assert_equals(vacuum['iot_product'], True) - assert_equals(vacuum['iotmq'], False) - - def make_api(): with requests_mock.mock() as m: m.get(compile('user/login'), From 79f9d679bf63a44502debc4fa7b561f0a13203d0 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Mar 2019 22:42:16 -0400 Subject: [PATCH 46/48] Update protocol.md with mopping setting Add SetWaterPermeability setting to protocol.md - #64 --- protocol.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/protocol.md b/protocol.md index 4267ef2..8569236 100644 --- a/protocol.md +++ b/protocol.md @@ -245,6 +245,12 @@ Gets or sets value for option (0==Off, 1==On) Returns `` +#### Mopping Water Amount +Models with mopping capability (Ozmo) allow for changing the amount of water dispersed. The value ranges from 1 (low) to 3 (high). + +`` + + #### Schedules ##### GetSched `` From 6da9a96441fe788d81a7e4b8c44e681f5d66d839 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Mar 2019 22:45:34 -0400 Subject: [PATCH 47/48] Update README API example Update README API example to show import properly per #65. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d1f8101..99f99cf 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ shaping the API. A simple usage might go something like this: -``` -import sucks +```python +from sucks import * config = ... From 5fda81e1835198508e534018a9d0d78272e0355d Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Mon, 18 Mar 2019 22:55:23 -0400 Subject: [PATCH 48/48] Add verify_ssl to cli login Add verify_ssl option to cli login --- sucks/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sucks/cli.py b/sucks/cli.py index 042d5bb..c7c9739 100644 --- a/sucks/cli.py +++ b/sucks/cli.py @@ -141,7 +141,8 @@ def cli(debug): @click.option('--country-code', prompt='your two-letter country code', default=lambda: current_country()) @click.option('--continent-code', prompt='your two-letter continent code', default=lambda: continent_for_country(click.get_current_context().params['country_code'])) -def login(email, password, country_code, continent_code): +@click.option('--verify-ssl', prompt='Verify SSL for API requests', default=True) +def login(email, password, country_code, continent_code, verify_ssl): if config_file_exists() and not click.confirm('overwrite existing config?'): click.echo("Skipping login.") exit(0) @@ -149,7 +150,7 @@ def login(email, password, country_code, continent_code): password_hash = EcoVacsAPI.md5(password) device_id = EcoVacsAPI.md5(str(time.time())) try: - EcoVacsAPI(device_id, email, password_hash, country_code, continent_code) + EcoVacsAPI(device_id, email, password_hash, country_code, continent_code, verify_ssl) except ValueError as e: click.echo(e.args[0]) exit(1) @@ -158,6 +159,7 @@ def login(email, password, country_code, continent_code): config['device_id'] = device_id config['country'] = country_code.lower() config['continent'] = continent_code.lower() + config['verify_ssl'] = verify_ssl write_config(config) click.echo("Config saved.") exit(0)