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")