From 1346fcb39c54a433109edfc0d5e4baf2b0ed9241 Mon Sep 17 00:00:00 2001 From: Andrea <31848430+And3rsL@users.noreply.github.com> Date: Tue, 9 Mar 2021 15:05:52 +0100 Subject: [PATCH 1/4] Events and scheduled updates --- deebotozmo/__init__.py | 938 ++++++++++++++++++++++++-------------- deebotozmo/ecovacsjson.py | 284 ++++++++---- setup.py | 72 ++- 3 files changed, 798 insertions(+), 496 deletions(-) diff --git a/deebotozmo/__init__.py b/deebotozmo/__init__.py index 568923e..379f954 100644 --- a/deebotozmo/__init__.py +++ b/deebotozmo/__init__.py @@ -17,290 +17,380 @@ _LOGGER = logging.getLogger(__name__) + class EcoVacsAPI: CLIENT_KEY = "1520391301804" SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9" - PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ' - MAIN_URL_FORMAT = 'https://gl-{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' - PORTAL_URL_FORMAT_CN = 'https://portal.ecouser.net/api/' + PUBLIC_KEY = "MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ" + MAIN_URL_FORMAT = "https://gl-{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" + PORTAL_URL_FORMAT_CN = "https://portal.ecouser.net/api/" # New Auth Code Method - PORTAL_GLOBAL_AUTHCODE = 'https://gl-{country}-openapi.ecovacs.com/v1/global/auth/getAuthCode' + PORTAL_GLOBAL_AUTHCODE = ( + "https://gl-{country}-openapi.ecovacs.com/v1/global/auth/getAuthCode" + ) AUTH_CLIENT_KEY = "1520391491841" AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9" - USERSAPI = 'users/user.do' - IOTDEVMANAGERAPI = 'iot/devmanager.do' # IOT Device Manager - This provides control of "IOT" products via RestAPI - 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. + USERSAPI = "users/user.do" + IOTDEVMANAGERAPI = "iot/devmanager.do" # IOT Device Manager - This provides control of "IOT" products via RestAPI + 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' + REALM = "ecouser.net" - def __init__(self, device_id, account_id, password_hash, country, continent, verify_ssl=True): + def __init__( + self, device_id, account_id, password_hash, country, continent, verify_ssl=True + ): urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) self.meta = { - 'country': country, - 'lang': 'EN', - 'deviceId': device_id, - 'appCode': 'global_e', - 'appVersion': '1.6.3', - 'channel': 'google_play', - 'deviceType': '1' + "country": country, + "lang": "EN", + "deviceId": device_id, + "appCode": "global_e", + "appVersion": "1.6.3", + "channel": "google_play", + "deviceType": "1", } - + self.verify_ssl = str_to_bool_or_cert(verify_ssl) _LOGGER.debug("Setting up EcoVacsAPI") self.resource = device_id[0:8] self.country = country self.continent = continent - self.login_path = 'user/login' + self.login_path = "user/login" + + if self.country.lower() == "cn": + self.login_path = "user/loginCheckMobile" - if self.country.lower() == 'cn': - self.login_path = 'user/loginCheckMobile' - - - login_info = self.__call_main_api(self.login_path, - ('account', account_id), - ('password', password_hash)) - self.uid = login_info['uid'] - self.login_access_token = login_info['accessToken'] + login_info = self.__call_main_api( + self.login_path, ("account", account_id), ("password", password_hash) + ) + self.uid = login_info["uid"] + self.login_access_token = login_info["accessToken"] - self.auth_code = self.__call_auth_api(device_id, ('uid', self.uid), - ('accessToken', self.login_access_token))['authCode'] + self.auth_code = self.__call_auth_api( + device_id, ("uid", self.uid), ("accessToken", self.login_access_token) + )["authCode"] login_response = self.__call_login_by_it_token() - self.user_access_token = login_response['token'] - if login_response['userId'] != self.uid: - logging.debug("Switching to shorter UID " + login_response['userId']) - self.uid = login_response['userId'] + self.user_access_token = login_response["token"] + if login_response["userId"] != self.uid: + logging.debug("Switching to shorter UID " + login_response["userId"]) + self.uid = login_response["userId"] logging.debug("EcoVacsAPI connection complete") def __sign(self, params): result = params.copy() - result['authTimespan'] = int(time.time() * 1000) - result['authTimeZone'] = 'GMT-8' + result["authTimespan"] = int(time.time() * 1000) + result["authTimeZone"] = "GMT-8" sign_on = self.meta.copy() sign_on.update(result) - sign_on_text = EcoVacsAPI.CLIENT_KEY + ''.join( - [k + '=' + str(sign_on[k]) for k in sorted(sign_on.keys())]) + EcoVacsAPI.SECRET - - result['authAppkey'] = EcoVacsAPI.CLIENT_KEY - result['authSign'] = self.md5(sign_on_text) + sign_on_text = ( + EcoVacsAPI.CLIENT_KEY + + "".join([k + "=" + str(sign_on[k]) for k in sorted(sign_on.keys())]) + + EcoVacsAPI.SECRET + ) + + result["authAppkey"] = EcoVacsAPI.CLIENT_KEY + result["authSign"] = self.md5(sign_on_text) return result def __signAuth(self, params): result = params.copy() - result['authTimespan'] = int(time.time() * 1000) + result["authTimespan"] = int(time.time() * 1000) paramsSignIn = result.copy() - paramsSignIn['openId'] = "global" + paramsSignIn["openId"] = "global" - sign_on_text = EcoVacsAPI.AUTH_CLIENT_KEY + ''.join( - [k + '=' + str(paramsSignIn[k]) for k in sorted(paramsSignIn.keys())]) + EcoVacsAPI.AUTH_CLIENT_SECRET + sign_on_text = ( + EcoVacsAPI.AUTH_CLIENT_KEY + + "".join( + [k + "=" + str(paramsSignIn[k]) for k in sorted(paramsSignIn.keys())] + ) + + EcoVacsAPI.AUTH_CLIENT_SECRET + ) - result['authAppkey'] = EcoVacsAPI.AUTH_CLIENT_KEY - result['authSign'] = self.md5(sign_on_text) + result["authAppkey"] = EcoVacsAPI.AUTH_CLIENT_KEY + result["authSign"] = self.md5(sign_on_text) return result def __call_main_api(self, function, *args): - _LOGGER.debug("calling main api {} with {}".format(function, args)) + # _LOGGER.debug("calling main api {} with {}".format(function, args)) params = OrderedDict(args) - params['requestId'] = self.md5(time.time()) + params["requestId"] = self.md5(time.time()) - if self.country.lower() == 'cn': - url = (EcoVacsAPI.MAIN_URL_FORMAT.replace(".com",".cn") + "/" + function).format(**self.meta) + if self.country.lower() == "cn": + url = ( + EcoVacsAPI.MAIN_URL_FORMAT.replace(".com", ".cn") + "/" + function + ).format(**self.meta) else: url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + function).format(**self.meta) - api_response = requests.get(url, self.__sign(params), timeout=60, verify=self.verify_ssl) + api_response = requests.get( + url, self.__sign(params), timeout=60, verify=self.verify_ssl + ) json = api_response.json() - _LOGGER.debug("got {}".format(json)) - if json['code'] == '0000': - return json['data'] - elif json['code'] == '1005': + # _LOGGER.debug("got {}".format(json)) + if json["code"] == "0000": + return json["data"] + elif json["code"] == "1005": _LOGGER.warning("incorrect email or password") raise ValueError("incorrect email or password") else: _LOGGER.error("call to {} failed with {}".format(function, json)) - raise RuntimeError("failure code {} ({}) for call {} and parameters {}".format( - json['code'], json['msg'], function, args)) + raise RuntimeError( + "failure code {} ({}) for call {} and parameters {}".format( + json["code"], json["msg"], function, args + ) + ) def __call_auth_api(self, device_id, *args): - _LOGGER.debug("calling auth api with {}".format(args)) + # _LOGGER.debug("calling auth api with {}".format(args)) params = OrderedDict(args) - params['bizType'] = 'ECOVACS_IOT' - params['deviceId'] = device_id + params["bizType"] = "ECOVACS_IOT" + params["deviceId"] = device_id - if self.country.lower() == 'cn': - url = (EcoVacsAPI.PORTAL_GLOBAL_AUTHCODE.replace(".com",".cn")).format(**self.meta) + if self.country.lower() == "cn": + url = (EcoVacsAPI.PORTAL_GLOBAL_AUTHCODE.replace(".com", ".cn")).format( + **self.meta + ) else: url = (EcoVacsAPI.PORTAL_GLOBAL_AUTHCODE).format(**self.meta) - api_response = requests.get(url, self.__signAuth(params), timeout=60, verify=self.verify_ssl) + api_response = requests.get( + url, self.__signAuth(params), timeout=60, verify=self.verify_ssl + ) json = api_response.json() - _LOGGER.debug("got {}".format(json)) + # _LOGGER.debug("got {}".format(json)) - if json['code'] == '0000': - return json['data'] - elif json['code'] == '1005': + if json["code"] == "0000": + return json["data"] + elif json["code"] == "1005": _LOGGER.warning("incorrect email or password") raise ValueError("incorrect email or password") else: _LOGGER.error("call to {} failed with {}".format(json)) - raise RuntimeError("failure code {} ({}) for call and parameters {}".format( - json['code'], json['msg'], args)) + raise RuntimeError( + "failure code {} ({}) for call and parameters {}".format( + json["code"], json["msg"], args + ) + ) def __call_user_api(self, function, args): - _LOGGER.debug("calling user api {} with {}".format(function, args)) - params = {'todo': function} + # _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, timeout=60, verify=self.verify_ssl) + response = requests.post( + EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), + json=params, + timeout=60, + verify=self.verify_ssl, + ) json = response.json() - _LOGGER.debug("got {}".format(json)) - if json['result'] == 'ok': + # _LOGGER.debug("got {}".format(json)) + if json["result"] == "ok": 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)) + "failure {} ({}) for call {} and parameters {}".format( + json["error"], json["errno"], function, params + ) + ) - def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): - _LOGGER.debug("calling user api {} with {}".format(function, args)) + def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): + # _LOGGER.debug("calling user api {} with {}".format(function, args)) if api == self.USERSAPI: - params = {'todo': function} + params = {"todo": function} params.update(args) else: params = {} - params.update(args) - + params.update(args) + continent = self.continent - if 'continent' in kwargs: - continent = kwargs.get('continent') + if "continent" in kwargs: + continent = kwargs.get("continent") - if self.country.lower() == 'cn': - url = (EcoVacsAPI.PORTAL_URL_FORMAT_CN + "/" + api).format(continent=continent, **self.meta) + if self.country.lower() == "cn": + url = (EcoVacsAPI.PORTAL_URL_FORMAT_CN + "/" + api).format( + continent=continent, **self.meta + ) else: - url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format(continent=continent, **self.meta) - - response = requests.post(url, json=params, timeout=60, verify=verify_ssl) + url = (EcoVacsAPI.PORTAL_URL_FORMAT + "/" + api).format( + continent=continent, **self.meta + ) + + response = requests.post(url, json=params, timeout=60, verify=verify_ssl) json = response.json() - _LOGGER.debug("got {}".format(json)) - if api == self.USERSAPI: - if json['result'] == 'ok': + # _LOGGER.debug("got {}".format(json)) + if api == self.USERSAPI: + if json["result"] == "ok": return json - elif json['result'] == 'fail': - if json['error'] == 'set token error.': # If it is a set token error try again - if not 'set_token' in kwargs: - _LOGGER.warning("loginByItToken set token error, trying again (2/3)") - return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=1) - elif kwargs.get('set_token') == 1: - _LOGGER.warning("loginByItToken set token error, trying again with ww (3/3)") - return self.__call_portal_api(self.USERSAPI, function, args, verify_ssl=verify_ssl, set_token=2, continent="ww") + elif json["result"] == "fail": + if ( + json["error"] == "set token error." + ): # If it is a set token error try again + if not "set_token" in kwargs: + _LOGGER.warning( + "loginByItToken set token error, trying again (2/3)" + ) + return self.__call_portal_api( + self.USERSAPI, + function, + args, + verify_ssl=verify_ssl, + set_token=1, + ) + elif kwargs.get("set_token") == 1: + _LOGGER.warning( + "loginByItToken set token error, trying again with ww (3/3)" + ) + return self.__call_portal_api( + self.USERSAPI, + function, + args, + verify_ssl=verify_ssl, + set_token=2, + continent="ww", + ) else: - _LOGGER.warning("loginByItToken set token error, failed after 3 attempts") - + _LOGGER.warning( + "loginByItToken set token error, failed after 3 attempts" + ) + if api.startswith(self.PRODUCTAPI): - if json['code'] == 0: - return json + 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)) + "failure {} ({}) for call {} and parameters {}".format( + json["error"], json["errno"], function, params + ) + ) def __call_login_by_it_token(self): - if self.country.lower() == 'cn': - org = 'ECOCN' - country = 'Chinese' + if self.country.lower() == "cn": + org = "ECOCN" + country = "Chinese" else: - org = 'ECOWW' - country = self.meta['country'].upper() - - return self.__call_portal_api(self.USERSAPI,'loginByItToken', - {'edition': 'ECOGLOBLE', - 'userId': self.uid, - 'token': self.auth_code, - 'realm': EcoVacsAPI.REALM, - 'resource': self.resource, - 'org': org, - 'last': '', - 'country': country - } - , verify_ssl=self.verify_ssl) - + org = "ECOWW" + country = self.meta["country"].upper() + + return self.__call_portal_api( + self.USERSAPI, + "loginByItToken", + { + "edition": "ECOGLOBLE", + "userId": self.uid, + "token": self.auth_code, + "realm": EcoVacsAPI.REALM, + "resource": self.resource, + "org": org, + "last": "", + "country": country, + }, + verify_ssl=self.verify_ssl, + ) + def getdevices(self): - return self.__call_portal_api(self.USERSAPI,'GetDeviceList', { - 'userid': self.uid, - 'auth': { - 'with': 'users', - 'userid': self.uid, - 'realm': EcoVacsAPI.REALM, - 'token': self.user_access_token, - 'resource': self.resource - } - }, verify_ssl=self.verify_ssl)['devices'] + return self.__call_portal_api( + self.USERSAPI, + "GetDeviceList", + { + "userid": self.uid, + "auth": { + "with": "users", + "userid": self.uid, + "realm": EcoVacsAPI.REALM, + "token": self.user_access_token, + "resource": self.resource, + }, + }, + verify_ssl=self.verify_ssl, + )["devices"] def getiotProducts(self): - return 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 - } - }, verify_ssl=self.verify_ssl)['data'] + return 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, + }, + }, + verify_ssl=self.verify_ssl, + )["data"] def SetJSONDevices(self, devices): for device in devices: - if device['class'] == 'bs40nz': # DEEBOT T8 AIVI - device['API_CLEANINFO'] = 'getCleanInfo_V2' - _LOGGER.debug('Found robot version DEEBOT T8 AIVI ') - elif device['class'] == 'a1nNMoAGAsH': # DEEBOT T8 MAX - device['API_CLEANINFO'] = 'getCleanInfo_V2' - _LOGGER.debug('Found robot version DEEBOT T8 MAX ') - elif device['class'] == 'vdehg6': # DEEBOT T8 AIVI + - device['API_CLEANINFO'] = 'getCleanInfo_V2' - _LOGGER.debug('Found robot version DEEBOT T8 AIVI +') - elif device['class'] == 'no61kx': # DEEBOT T8 POWER - device['API_CLEANINFO'] = 'getCleanInfo_V2' - _LOGGER.debug('Found robot version DEEBOT T8 POWER ') - elif device['class'] == 'yna5xi': # DEEBOT OZMO 950 - device['API_CLEANINFO'] = 'getCleanInfo' - _LOGGER.debug('Found robot version DEEBOT OZMO 950 ') - else: # Others - device['API_CLEANINFO'] = 'getCleanInfo' - _LOGGER.debug('Found robot version OTHER -> ' + device['class']) + if device["class"] == "bs40nz": # DEEBOT T8 AIVI + device["API_CLEANINFO"] = "getCleanInfo_V2" + _LOGGER.debug("Found robot version DEEBOT T8 AIVI ") + elif device["class"] == "a1nNMoAGAsH": # DEEBOT T8 MAX + device["API_CLEANINFO"] = "getCleanInfo_V2" + _LOGGER.debug("Found robot version DEEBOT T8 MAX ") + elif device["class"] == "vdehg6": # DEEBOT T8 AIVI + + device["API_CLEANINFO"] = "getCleanInfo_V2" + _LOGGER.debug("Found robot version DEEBOT T8 AIVI +") + elif device["class"] == "no61kx": # DEEBOT T8 POWER + device["API_CLEANINFO"] = "getCleanInfo_V2" + _LOGGER.debug("Found robot version DEEBOT T8 POWER ") + elif device["class"] == "yna5xi": # DEEBOT OZMO 950 + device["API_CLEANINFO"] = "getCleanInfo" + _LOGGER.debug("Found robot version DEEBOT OZMO 950 ") + else: # Others + device["API_CLEANINFO"] = "getCleanInfo" + _LOGGER.debug("Found robot version OTHER -> " + device["class"]) return devices - + def devices(self): return self.SetJSONDevices(self.getdevices()) @staticmethod def md5(text): - return hashlib.md5(bytes(str(text), 'utf8')).hexdigest() + return hashlib.md5(bytes(str(text), "utf8")).hexdigest() @staticmethod def encrypt(text): from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 + key = RSA.import_key(b64decode(EcoVacsAPI.PUBLIC_KEY)) cipher = PKCS1_v1_5.new(key) - result = cipher.encrypt(bytes(text, 'utf8')) - return str(b64encode(result), 'utf8') - -class VacBot(): - def __init__(self, user, resource, secret, vacuum, country, continent, live_map_enabled = True, show_rooms_color = False, verify_ssl=True): + result = cipher.encrypt(bytes(text, "utf8")) + return str(b64encode(result), "utf8") + + +class VacBot: + def __init__( + self, + user, + resource, + secret, + vacuum, + country, + continent, + live_map_enabled=True, + show_rooms_color=False, + verify_ssl=True, + ): self.vacuum = vacuum @@ -322,20 +412,37 @@ def __init__(self, user, resource, secret, vacuum, country, continent, live_map_ # Map Components self.__map = Map() self.__map.draw_rooms = show_rooms_color - + self.live_map = None self.lastCleanLogs = [] self.last_clean_image = None - #Set none for clients to start + # Set none for clients to start self.json = None - - if country.lower() == 'cn': - self.json = EcoVacsJSON(user, resource, secret, continent, vacuum, EcoVacsAPI.REALM, EcoVacsAPI.PORTAL_URL_FORMAT_CN, verify_ssl=verify_ssl) + if country.lower() == "cn": + self.json = EcoVacsJSON( + user, + resource, + secret, + continent, + vacuum, + EcoVacsAPI.REALM, + EcoVacsAPI.PORTAL_URL_FORMAT_CN, + verify_ssl=verify_ssl, + ) else: - self.json = EcoVacsJSON(user, resource, secret, continent, vacuum, EcoVacsAPI.REALM, EcoVacsAPI.PORTAL_URL_FORMAT, verify_ssl=verify_ssl) + self.json = EcoVacsJSON( + user, + resource, + secret, + continent, + vacuum, + EcoVacsAPI.REALM, + EcoVacsAPI.PORTAL_URL_FORMAT, + verify_ssl=verify_ssl, + ) self.json.subscribe_to_ctls(self._handle_ctl) @@ -346,260 +453,313 @@ def __init__(self, user, resource, secret, vacuum, country, continent, live_map_ self.stats_cid = None self.stats_time = None self.stats_type = None - - # Threads - self.thread_statuses = threading.Thread(target=self.refresh_statuses, daemon=False, name="schedule_thread_statuses") - self.thread_livemap = threading.Thread(target=self.refresh_liveMap, daemon=False, name="schedule_thread_livemap") - self.thread_components = threading.Thread(target=self.refresh_components, daemon=False, name="schedule_thread_components") + self.inuse_mapid = None self.errorEvents = EventEmitter() self.lifespanEvents = EventEmitter() self.fanspeedEvents = EventEmitter() self.cleanLogsEvents = EventEmitter() - self.waterEvents = EventEmitter() + self.waterEvents = EventEmitter() self.batteryEvents = EventEmitter() self.statusEvents = EventEmitter() self.statsEvents = EventEmitter() + self.roomEvents = EventEmitter() + self.livemapEvents = EventEmitter() + ########### HANDLERS ############### def _handle_ctl(self, ctl): - method = '_handle_' + ctl['event'] + method = "_handle_" + ctl["event"] if hasattr(self, method): getattr(self, method)(ctl) def _handle_error(self, event): - if 'error' in event: - error = event['error'] - elif 'errs' in event: - error = event['errs'] + if "error" in event: + error = event["error"] + elif "errs" in event: + error = event["errs"] - if not error == '': + if not error == "": _LOGGER.warning("*** error = " + error) - self.errorEvents.notify(event) + self.errorEvents.notify(event) def _handle_life_span(self, event): - response = event['body']['data'][0] - type = response['type'] + response = event["body"]["data"][0] + type = response["type"] try: type = COMPONENT_FROM_ECOVACS[type] except KeyError: _LOGGER.warning("Unknown component type: '" + type + "'") - - left = int(response['left']) - total = int(response['total']) - - lifespan = (left/total) * 100 - + + left = int(response["left"]) + total = int(response["total"]) + + lifespan = (left / total) * 100 + self.components[type] = lifespan self.lifespanEvents.notify(event) def _handle_fan_speed(self, event): - response = event['body']['data'] - speed = response['speed'] + response = event["body"]["data"] + speed = response["speed"] try: speed = FAN_SPEED_FROM_ECOVACS[speed] except KeyError: _LOGGER.warning("Unknown fan speed: '" + str(speed) + "'") - + self.fan_speed = speed self.fanspeedEvents.notify(self.fan_speed) def _handle_clean_logs(self, event): - response = event.get('logs') + response = event.get("logs") self.lastCleanLogs = [] try: # Ecovacs API is changing their API, this request may not working properly if response is not None and len(response) >= 0: - self.last_clean_image = response[0]['imageUrl'] + self.last_clean_image = response[0]["imageUrl"] for cleanLog in response: - self.lastCleanLogs.append({'timestamp': cleanLog['ts'], 'imageUrl': cleanLog['imageUrl'], - 'type': cleanLog['type']}) - - self.cleanLogsEvents.notify(event=(self.lastCleanLogs, self.last_clean_image)) + self.lastCleanLogs.append( + { + "timestamp": cleanLog["ts"], + "imageUrl": cleanLog["imageUrl"], + "type": cleanLog["type"], + } + ) + + self.cleanLogsEvents.notify( + event=(self.lastCleanLogs, self.last_clean_image) + ) except: _LOGGER.warning("No last clean image found") def _handle_water_info(self, event): - response = event['body']['data'] - amount = response['amount'] + response = event["body"]["data"] + amount = response["amount"] try: amount = WATER_LEVEL_FROM_ECOVACS[amount] except KeyError: _LOGGER.warning("Unknown water level: '" + str(amount) + "'") - + self.water_level = amount self.mop_attached = bool(response.get("enable")) self.waterEvents.notify(event=(self.water_level, self.mop_attached)) def _handle_clean_report(self, event): - response = event['body']['data'] - if response['state'] == 'clean': - if response['trigger'] == 'app' or response['trigger'] == 'shed': - if response['cleanState']['motionState'] == 'working': - self.vacuum_status = 'STATE_CLEANING' - elif response['cleanState']['motionState'] == 'pause': - self.vacuum_status = 'STATE_PAUSED' + response = event["body"]["data"] + if response["state"] == "clean": + if response["trigger"] == "app" or response["trigger"] == "sched": + if response["cleanState"]["motionState"] == "working": + self.vacuum_status = "STATE_CLEANING" + elif response["cleanState"]["motionState"] == "pause": + self.vacuum_status = "STATE_PAUSED" else: - self.vacuum_status = 'STATE_RETURNING' - elif response['trigger'] == 'alert': - self.vacuum_status = 'STATE_ERROR' - - self.is_available = True + self.vacuum_status = "STATE_RETURNING" + elif response["trigger"] == "alert": + self.vacuum_status = "STATE_ERROR" + self.is_available = True self.statusEvents.notify(self.vacuum_status) + # if STATE_CLEANING we should update stats and components, otherwise just the standard slow update + if self.vacuum_status == "STATE_CLEANING": + self.refresh_stats() + self.refresh_components() + + if self.vacuum_status == "STATE_DOCKED": + self.refresh_cleanLogs() + def _handle_map_trace(self, event): - response = event['body']['data'] - totalCount = int(response['totalCount']) - traceStart = int(response['traceStart']) + response = event["body"]["data"] + totalCount = int(response["totalCount"]) + traceStart = int(response["traceStart"]) pointCount = 200 # No trace value avaiable - if 'traceValue' in response: + if "traceValue" in response: if traceStart == 0: self.__map.traceValues = [] - _LOGGER.debug("Trace Request: TotalCount=" + str(totalCount) + ' traceStart=' + str(traceStart)) - self.__map.updateTracePoints(response['traceValue']) - - if (traceStart+pointCount) < totalCount: - self.exc_command('getMapTrace',{'pointCount':pointCount,'traceStart':traceStart+pointCount}) + # _LOGGER.debug( + # "Trace Request: TotalCount=" + # + str(totalCount) + # + " traceStart=" + # + str(traceStart) + # ) + self.__map.updateTracePoints(response["traceValue"]) + if (traceStart + pointCount) < totalCount: + self.exc_command( + "getMapTrace", + {"pointCount": pointCount, "traceStart": traceStart + pointCount}, + ) def _handle_set_position(self, event): - response = event['body']['data'] + response = event["body"]["data"] # Charger - if 'chargePos' in response: - charger_pos = response['chargePos'] - self.__map.updateChargerPosition(charger_pos[0]['x'], charger_pos[0]['y']) + if "chargePos" in response: + charger_pos = response["chargePos"] + self.__map.updateChargerPosition(charger_pos[0]["x"], charger_pos[0]["y"]) - if 'deebotPos' in response: + if "deebotPos" in response: # Robot - robot_pos = response['deebotPos'] - self.__map.updateRobotPosition(robot_pos['x'], robot_pos['y']) + robot_pos = response["deebotPos"] + self.__map.updateRobotPosition(robot_pos["x"], robot_pos["y"]) def _handle_minor_map(self, event): - response = event['body']['data'] + response = event["body"]["data"] - _LOGGER.debug("Handled minor_map : " + str(response['pieceIndex'])) + _LOGGER.debug("Handled minor_map : " + str(response["pieceIndex"])) - self.__map.AddMapPiece(response['pieceIndex'], response['pieceValue']) + self.__map.AddMapPiece(response["pieceIndex"], response["pieceValue"]) def _handle_major_map(self, event): _LOGGER.debug("_handle_major_map begin") - response = event['body']['data'] - - values = response['value'].split(",") + response = event["body"]["data"] + + values = response["value"].split(",") for i in range(64): if self.__map.isUpdatePiece(i, values[i]): - _LOGGER.debug("MapPiece" + str(i) + ' needs to be updated') - self.exc_command('getMinorMap', {'mid': response['mid'],'type': 'ol', 'pieceIndex': i}) + _LOGGER.debug("MapPiece" + str(i) + " needs to be updated") + self.exc_command( + "getMinorMap", + {"mid": response["mid"], "type": "ol", "pieceIndex": i}, + ) def _handle_cached_map(self, event): - response = event['body']['data'] - + response = event["body"]["data"] + try: - for mapstatus in response['info']: - if mapstatus['using'] == 1: - mapid = mapstatus['mid'] - + for mapstatus in response["info"]: + if mapstatus["using"] == 1: + mapid = mapstatus["mid"] + + # IF MAP CHANGED WE SHOULD REFRESH ROOMS + if self.inuse_mapid != mapid: + self.inuse_mapid = mapid + self.refresh_rooms() + _LOGGER.debug("Using Map: " + str(mapid)) self.__map.rooms = [] - self.exc_command('getMapSet', {'mid': mapid,'type': 'ar'}) + self.exc_command("getMapSet", {"mid": mapid, "type": "ar"}) except: - _LOGGER.warning("MapID not found -- did you finish your first auto cleaning?") + _LOGGER.warning( + "MapID not found -- did you finish your first auto cleaning?" + ) def _handle_map_set(self, event): - response = event['body']['data'] - - mid = response['mid'] - msid = response['msid'] - typemap = response['type'] + response = event["body"]["data"] - for s in response['subsets']: - self.exc_command('getMapSubSet', {'mid': mid,'msid': msid,'type': typemap, 'mssid': s['mssid']}) + mid = response["mid"] + msid = response["msid"] + typemap = response["type"] - def _handle_map_sub_set(self, event): - response = event['body']['data'] - subtype = int(response['subtype']) - value = response['value'] + for s in response["subsets"]: + self.exc_command( + "getMapSubSet", + {"mid": mid, "msid": msid, "type": typemap, "mssid": s["mssid"]}, + ) - self.__map.rooms.append({'subtype':ROOMS_FROM_ECOVACS[subtype],'id': int(response['mssid']), 'values': value}) + def _handle_map_sub_set(self, event): + response = event["body"]["data"] + subtype = int(response["subtype"]) + value = response["value"] + + self.__map.rooms.append( + { + "subtype": ROOMS_FROM_ECOVACS[subtype], + "id": int(response["mssid"]), + "values": value, + } + ) def _handle_battery_info(self, event): - response = event['body'] + response = event["body"] try: - self.battery_status = response['data']['value'] + self.battery_status = response["data"]["value"] except ValueError: _LOGGER.warning("couldn't parse battery status " + response) self.batteryEvents.notify(self.battery_status) def _handle_charge_state(self, event): - response = event['body'] - status = 'none' + response = event["body"] + status = "none" - if response['code'] == 0: - if response['data']['isCharging'] == 1: - status = 'STATE_DOCKED' + if response["code"] == 0: + if response["data"]["isCharging"] == 1: + status = "STATE_DOCKED" else: - if response['msg'] == 'fail' and response['code'] == '30007': #Already charging - status = 'STATE_DOCKED' - elif response['msg'] == 'fail' and response['code'] == '5': #Busy with another command - status = 'STATE_ERROR' - elif response['msg'] == 'fail' and response['code'] == '3': #Bot in stuck state, example dust bin out - status = 'STATE_ERROR' - else: - _LOGGER.error("Unknown charging status '" + response['code'] + "'") #Log this so we can identify more errors - - if status != 'none': + if ( + response["msg"] == "fail" and response["code"] == "30007" + ): # Already charging + status = "STATE_DOCKED" + elif ( + response["msg"] == "fail" and response["code"] == "5" + ): # Busy with another command + status = "STATE_ERROR" + elif ( + response["msg"] == "fail" and response["code"] == "3" + ): # Bot in stuck state, example dust bin out + status = "STATE_ERROR" + else: + _LOGGER.error( + "Unknown charging status '" + response["code"] + "'" + ) # Log this so we can identify more errors + + if status != "none": self.vacuum_status = status self.is_available = True self.statusEvents.notify(self.vacuum_status) def _handle_stats(self, event): - response = event['body'] - - if response['code'] == 0: - if 'area' in response['data']: - self.stats_area = response['data']['area'] - - if 'cid' in response['data']: - self.stats_cid = response['data']['cid'] - - if 'time' in response['data']: - self.stats_time = response['data']['time'] - - if 'type' in response['data']: - self.stats_type = response['data']['type'] + response = event["body"] + + if response["code"] == 0: + if "area" in response["data"]: + self.stats_area = response["data"]["area"] + + if "cid" in response["data"]: + self.stats_cid = response["data"]["cid"] + + if "time" in response["data"]: + self.stats_time = response["data"]["time"] + + if "type" in response["data"]: + self.stats_type = response["data"]["type"] else: - _LOGGER.error("Error in finding stats, status code = " + response['code']) #Log this so we can identify more errors + _LOGGER.error( + "Error in finding stats, status code = " + response["code"] + ) # Log this so we can identify more errors self.statsEvents.notify(event) + ################################################################ + def _vacuum_address(self): - return self.vacuum['did'] + return self.vacuum["did"] + ############### REFRESH ROUTINES ############################### def refresh_components(self): try: _LOGGER.debug("[refresh_components] Begin") - self.exc_command('getLifeSpan',[COMPONENT_TO_ECOVACS["brush"]]) - self.exc_command('getLifeSpan',[COMPONENT_TO_ECOVACS["sideBrush"]]) - self.exc_command('getLifeSpan',[COMPONENT_TO_ECOVACS["heap"]]) - self.exc_command('GetCleanLogs') + self.exc_command("getLifeSpan", [COMPONENT_TO_ECOVACS["brush"]]) + self.exc_command("getLifeSpan", [COMPONENT_TO_ECOVACS["sideBrush"]]) + self.exc_command("getLifeSpan", [COMPONENT_TO_ECOVACS["heap"]]) except XMPPError as err: - _LOGGER.warning("Component refresh requests failed to reach VacBot. Will try again later.") + _LOGGER.warning( + "Component refresh requests failed to reach VacBot. Will try again later." + ) _LOGGER.warning("*** Error type: " + err.etype) _LOGGER.warning("*** Error condition: " + err.condition) @@ -607,114 +767,188 @@ def refresh_statuses(self): try: _LOGGER.debug("[refresh_statuses] Begin") - self.exc_command(self.vacuum['API_CLEANINFO']) - self.exc_command('getChargeState') - self.exc_command('getBattery') - self.exc_command('getSpeed') - self.exc_command('getWaterInfo') - self.exc_command('getCachedMapInfo') - self.exc_command('getStats') + self.exc_command(self.vacuum["API_CLEANINFO"]) + self.exc_command("getChargeState") + self.exc_command("getBattery") + self.exc_command("getSpeed") + self.exc_command("getWaterInfo") except XMPPError as err: - _LOGGER.warning("Initial status requests failed to reach VacBot. Will try again on next ping.") + _LOGGER.warning( + "Initial status requests failed to reach VacBot. Will try again on next ping." + ) _LOGGER.warning("*** Error type: " + err.etype) _LOGGER.warning("*** Error condition: " + err.condition) - def refresh_liveMap(self): + def refresh_rooms(self): try: - _LOGGER.debug("[refresh_liveMap] Begin") - self.exc_command('getMapTrace',{'pointCount':200,'traceStart':0}) - self.exc_command('getPos',['chargePos','deebotPos']) - self.exc_command('getMajorMap') - self.live_map = self.__map.GetBase64Map() + _LOGGER.debug("[refresh_rooms] Begin") + self.exc_command("getCachedMapInfo") + self.roomEvents.notify(None) + except XMPPError as err: - _LOGGER.warning("Initial live map failed to reach VacBot. Will try again on next ping.") + _LOGGER.warning( + "Initial rooms requests failed to reach VacBot. Will try again on next ping." + ) _LOGGER.warning("*** Error type: " + err.etype) _LOGGER.warning("*** Error condition: " + err.condition) - def request_all_statuses(self): - if not self.thread_statuses.is_alive(): - self.thread_statuses = threading.Thread(target=self.refresh_statuses, daemon=False, name="schedule_thread_statuses") - self.thread_statuses.start() + def refresh_stats(self): + try: + _LOGGER.debug("[refresh_stats] Begin") + self.exc_command("getStats") + except XMPPError as err: + _LOGGER.warning( + "Initial Stats requests failed to reach VacBot. Will try again on next ping." + ) + _LOGGER.warning("*** Error type: " + err.etype) + _LOGGER.warning("*** Error condition: " + err.condition) - if self.live_map_enabled: - if not self.thread_livemap.is_alive(): - self.thread_livemap = threading.Thread(target=self.refresh_liveMap, daemon=False, name="schedule_thread_livemap") - self.thread_livemap.start() - - if not self.thread_components.is_alive(): - self.thread_components = threading.Thread(target=self.refresh_components, daemon=False, name="schedule_thread_components") - self.thread_components.start() - - def setScheduleUpdates(self, livemap_cycle = 15, status_cycle = 30, components_cycle = 60): - # It will refresh all statuses very X seconds - if self.live_map_enabled: - self.json.schedule(livemap_cycle, self.refresh_liveMap) + def refresh_cleanLogs(self): + try: + _LOGGER.debug("[refresh_cleanLogs] Begin") + self.exc_command("GetCleanLogs") + except XMPPError as err: + _LOGGER.warning( + "Initial CleanLogs requests failed to reach VacBot. Will try again on next ping." + ) + _LOGGER.warning("*** Error type: " + err.etype) + _LOGGER.warning("*** Error condition: " + err.condition) + + def refresh_liveMap(self, force=False): + try: + if self.vacuum_status == "STATE_CLEANING" or force == True: + _LOGGER.debug("[refresh_liveMap] Begin") + self.exc_command("getMapTrace", {"pointCount": 200, "traceStart": 0}) + self.exc_command("getPos", ["chargePos", "deebotPos"]) + self.exc_command("getMajorMap") + self.live_map = self.__map.GetBase64Map() + + self.livemapEvents.notify(self.live_map) + except XMPPError as err: + _LOGGER.warning( + "Initial live map failed to reach VacBot. Will try again on next ping." + ) + _LOGGER.warning("*** Error type: " + err.etype) + _LOGGER.warning("*** Error condition: " + err.condition) + + ################################################################### + + def setScheduleUpdates( + self, + status_cycle=10, + components_cycle=60, + stats_cycle=60, + rooms_cycle=60, + liveMap_cycle=5, + ): + + self.updateEverythingNOW() self.json.schedule(status_cycle, self.refresh_statuses) + self.json.schedule(stats_cycle, self.refresh_stats) + self.json.schedule(rooms_cycle, self.refresh_rooms) self.json.schedule(components_cycle, self.refresh_components) + if self.live_map_enabled: + self.json.scheduleLiveMap(liveMap_cycle, self.refresh_liveMap) + + def updateEverythingNOW(self): + if self.live_map_enabled: + self.refresh_liveMap(True) + + self.refresh_statuses() + self.refresh_stats() + self.refresh_rooms() + self.refresh_components() + self.refresh_cleanLogs() + def getSavedRooms(self): return self.__map.rooms def getTypeRooms(self): return ROOMS_FROM_ECOVACS - #Common ecovacs commands - def Clean(self, type='auto'): + # Common ecovacs commands + def Clean(self, type="auto"): _LOGGER.debug("[Command] Clean Start TYPE: " + type) - self.exc_command('clean', {'act': CLEAN_ACTION_START,'type': type}) + self.exc_command("clean", {"act": CLEAN_ACTION_START, "type": type}) self.refresh_statuses() def CleanPause(self): _LOGGER.debug("[Command] Clean Pause") - self.exc_command('clean', {'act': CLEAN_ACTION_PAUSE}) + self.exc_command("clean", {"act": CLEAN_ACTION_PAUSE}) self.refresh_statuses() def CleanResume(self): - if self.vacuum_status == 'STATE_PAUSED': + if self.vacuum_status == "STATE_PAUSED": _LOGGER.debug("[Command] Clean Resume - Resume") - self.exc_command('clean', {'act': CLEAN_ACTION_RESUME}) + self.exc_command("clean", {"act": CLEAN_ACTION_RESUME}) else: _LOGGER.debug("[Command] Clean Resume - ActionStart") - self.exc_command('clean', {'act': CLEAN_ACTION_START,'type': 'auto'}) + self.exc_command("clean", {"act": CLEAN_ACTION_START, "type": "auto"}) self.refresh_statuses() def Charge(self): _LOGGER.debug("[Command] Charge") - self.exc_command('charge', {'act': CHARGE_MODE_TO_ECOVACS['return']}) + self.exc_command("charge", {"act": CHARGE_MODE_TO_ECOVACS["return"]}) self.refresh_statuses() def PlaySound(self): _LOGGER.debug("[Command] PlaySound") - self.exc_command('playSound', {'count': 1, 'sid': 30}) + self.exc_command("playSound", {"count": 1, "sid": 30}) def Relocate(self): _LOGGER.debug("[Command] Relocate") - self.exc_command('setRelocationState', {'mode': 'manu'}) + self.exc_command("setRelocationState", {"mode": "manu"}) def GetCleanLogs(self): _LOGGER.debug("[Command] GetCleanLogs") - self.exc_command('GetCleanLogs') + self.exc_command("GetCleanLogs") def CustomArea(self, map_position, cleanings=1): - _LOGGER.debug("[Command] CustomArea content=" + str(map_position) + " count=" + str(cleanings)) - self.exc_command('clean', {'act': 'start', 'content': str(map_position), 'count': int(cleanings), 'type': 'customArea'}) + _LOGGER.debug( + "[Command] CustomArea content=" + + str(map_position) + + " count=" + + str(cleanings) + ) + self.exc_command( + "clean", + { + "act": "start", + "content": str(map_position), + "count": int(cleanings), + "type": "customArea", + }, + ) self.refresh_statuses() def SpotArea(self, area, cleanings=1): - _LOGGER.debug("[Command] SpotArea content=" + str(area) + " count=" + str(cleanings)) - self.exc_command('clean', {'act': 'start', 'content': str(area), 'count': int(cleanings), 'type': 'spotArea'}) + _LOGGER.debug( + "[Command] SpotArea content=" + str(area) + " count=" + str(cleanings) + ) + self.exc_command( + "clean", + { + "act": "start", + "content": str(area), + "count": int(cleanings), + "type": "spotArea", + }, + ) self.refresh_statuses() def SetFanSpeed(self, speed=1): _LOGGER.debug("[Command] setSpeed speed=" + str(speed)) - self.exc_command('setSpeed', {'speed': FAN_SPEED_TO_ECOVACS[speed]}) + self.exc_command("setSpeed", {"speed": FAN_SPEED_TO_ECOVACS[speed]}) self.refresh_statuses() def SetWaterLevel(self, amount=1): _LOGGER.debug("[Command] setWaterInfo amount=" + str(amount)) - self.exc_command('setWaterInfo', {'amount': WATER_LEVEL_TO_ECOVACS[amount], 'enable': 0}) + self.exc_command( + "setWaterInfo", {"amount": WATER_LEVEL_TO_ECOVACS[amount], "enable": 0} + ) self.refresh_statuses() def exc_command(self, action, params=None, **kwargs): @@ -723,6 +957,7 @@ def exc_command(self, action, params=None, **kwargs): def send_command(self, action): self.json.send_command(action, self._vacuum_address()) + class VacBotCommand: def __init__(self, name, args=None, **kwargs): if args is None: @@ -736,8 +971,10 @@ def __str__(self, *args, **kwargs): def command_name(self): return self.__class__.__name__.lower() + class EventEmitter(object): """A very simple event emitting system.""" + def __init__(self): self._subscribers = [] @@ -756,6 +993,7 @@ def notify(self, event): class EventListener(object): """Object that allows event consumers to easily unsubscribe from events.""" + def __init__(self, emitter, callback): self._emitter = emitter self.callback = callback diff --git a/deebotozmo/ecovacsjson.py b/deebotozmo/ecovacsjson.py index b706762..fc39bc5 100644 --- a/deebotozmo/ecovacsjson.py +++ b/deebotozmo/ecovacsjson.py @@ -11,128 +11,197 @@ _LOGGER = logging.getLogger(__name__) + def str_to_bool_or_cert(s): - if s == 'True' or s == True: + if s == "True" or s == True: return True - elif s == 'False' or s == False: - return False + elif s == "False" or s == False: + return False else: if not s == None: - if os.path.exists(s): # User could provide a path to a CA Cert as well, which is useful for Bumper + if os.path.exists( + s + ): # User could provide a path to a CA Cert as well, which is useful for Bumper if os.path.isfile(s): return s - else: - raise ValueError("Certificate path provided is not a file - {}".format(s)) - + else: + raise ValueError( + "Certificate path provided is not a file - {}".format(s) + ) + raise ValueError("Cannot covert {} to a bool or certificate path".format(s)) -class EcoVacsJSON(): - def __init__(self, user, resource, secret, continent, vacuum, realm, portal_url_format, verify_ssl=True): - self.ctl_subscribers = [] +class EcoVacsJSON: + def __init__( + self, + user, + resource, + secret, + continent, + vacuum, + realm, + portal_url_format, + verify_ssl=True, + ): + self.ctl_subscribers = [] self.user = user 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="schedule_thread") + + self.scheduler_thread = threading.Thread( + target=self.scheduler.run, daemon=True, name="schedule_thread" + ) + + self.schedulerLV = sched.scheduler(time.time, time.sleep) + self.scheduler_LiveMap = threading.Thread( + target=self.schedulerLV.run, daemon=True, name="schedule_livemap" + ) + self.verify_ssl = str_to_bool_or_cert(verify_ssl) self.realm = realm self.portal_url_format = portal_url_format - + def subscribe_to_ctls(self, function): - self.ctl_subscribers.append(function) + self.ctl_subscribers.append(function) def _disconnect(self): - self.scheduler.empty() #Clear schedule queue + self.scheduler.empty() # Clear schedule queue + # Schedule Thread 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)) + self.scheduler.enter( + timer_seconds, 1, self._run_scheduled_func, (timer_seconds, timer_function) + ) if not self.scheduler_thread.is_alive(): self.scheduler_thread.start() + # Schedule Live Map + def _run_scheduled_LiveMap_func(self, timer_seconds, timer_function): + timer_function() + self.scheduleLiveMap(timer_seconds, timer_function) + + def scheduleLiveMap(self, timer_seconds, timer_function): + self.schedulerLV.enter( + timer_seconds, + 1, + self._run_scheduled_LiveMap_func, + (timer_seconds, timer_function), + ) + + if not self.scheduler_LiveMap.is_alive(): + self.scheduler_LiveMap.start() + def send_command(self, action, recipient): - if action.name.lower() == 'getcleanlogs': - - self._handle_ctl_api(action, self.CallCleanLogsApi(self.jsonRequestHeaderCleanLogs(action, recipient) ,verify_ssl=self.verify_ssl )) + if action.name.lower() == "getcleanlogs": + + self._handle_ctl_api( + action, + self.CallCleanLogsApi( + self.jsonRequestHeaderCleanLogs(action, recipient), + verify_ssl=self.verify_ssl, + ), + ) else: - self._handle_ctl_api(action, self.CallIOTApi(self.jsonRequestHeader(action, recipient) ,verify_ssl=self.verify_ssl )) + self._handle_ctl_api( + action, + self.CallIOTApi( + self.jsonRequestHeader(action, recipient), + verify_ssl=self.verify_ssl, + ), + ) def jsonRequestHeaderCleanLogs(self, cmd, recipient): return { - 'auth': { - 'realm': self.realm, - 'resource': self.resource, - 'token': self.secret, - 'userid': self.user, - 'with': 'users', + "auth": { + "realm": self.realm, + "resource": self.resource, + "token": self.secret, + "userid": self.user, + "with": "users", }, "td": cmd.name, "did": recipient, - "resource": self.vacuum['resource'], - } + "resource": self.vacuum["resource"], + } def jsonRequestHeader(self, cmd, recipient): - #All requests need to have this header -- not sure about timezone and ver + # All requests need to have this header -- not sure about timezone and ver payloadRequest = OrderedDict() - - payloadRequest['header'] = OrderedDict() - payloadRequest['header']['pri'] = '2' - payloadRequest['header']['ts'] = datetime.datetime.now().timestamp() - payloadRequest['header']['tmz'] = 480 - payloadRequest['header']['ver'] = '0.0.22' - + + payloadRequest["header"] = OrderedDict() + payloadRequest["header"]["pri"] = "2" + payloadRequest["header"]["ts"] = datetime.datetime.now().timestamp() + payloadRequest["header"]["tmz"] = 480 + payloadRequest["header"]["ver"] = "0.0.22" + if len(cmd.args) > 0: - payloadRequest['body'] = OrderedDict() - payloadRequest['body']['data'] = cmd.args - + payloadRequest["body"] = OrderedDict() + payloadRequest["body"]["data"] = cmd.args + payload = payloadRequest payloadType = "j" return { - 'auth': { - 'realm': self.realm, - 'resource': self.resource, - 'token': self.secret, - 'userid': self.user, - 'with': 'users', + "auth": { + "realm": self.realm, + "resource": self.resource, + "token": self.secret, + "userid": self.user, + "with": "users", }, - "cmdName": cmd.name, - "payload": payload, - + "cmdName": cmd.name, + "payload": payload, "payloadType": payloadType, "td": "q", "toId": recipient, - "toRes": self.vacuum['resource'], - "toType": self.vacuum['class'] - } + "toRes": self.vacuum["resource"], + "toType": self.vacuum["class"], + } def CallIOTApi(self, args, verify_ssl=True): params = {} params.update(args) - _LOGGER.debug(f"Calling IOT api with {args}") + # _LOGGER.debug(f"Calling IOT api with {args}") + + headers = { + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)", + } + url = ( + self.portal_url_format + + "/iot/devmanager.do?mid=" + + params["toType"] + + "&did=" + + params["toId"] + + "&td=" + + params["td"] + + "&u=" + + params["auth"]["userid"] + + "&cv=1.67.3&t=a&av=1.3.1" + ).format(continent=self.continent) - headers = {'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)',} - url = (self.portal_url_format + "/iot/devmanager.do?mid=" + params['toType'] + "&did=" + params['toId'] + "&td=" + params['td'] + "&u=" + params['auth']['userid'] + "&cv=1.67.3&t=a&av=1.3.1").format(continent=self.continent) - - try: - with requests.post(url, headers=headers, json=params, timeout=60, verify=verify_ssl) as response: + try: + with requests.post( + url, headers=headers, json=params, timeout=60, verify=verify_ssl + ) as response: if response.status_code == 502: - _LOGGER.info("Error calling API (502): Unfortunately the ecovacs api is unreliable. Retrying in a few moments") - _LOGGER.debug(f"URL was: {str(url)}") + # _LOGGER.info("Error calling API (502): Unfortunately the ecovacs api is unreliable. Retrying in a few moments") + # _LOGGER.debug(f"URL was: {str(url)}") return {} elif response.status_code != 200: - _LOGGER.warning(f"Error calling API ({response.status_code}): {str(url)}") + # _LOGGER.warning(f"Error calling API ({response.status_code}): {str(url)}") return {} data = response.json() - _LOGGER.debug(f"Got {data}") + # _LOGGER.debug(f"Got {data}") return data except requests.exceptions.HTTPError as errh: @@ -140,17 +209,28 @@ def CallIOTApi(self, args, verify_ssl=True): except requests.exceptions.ConnectionError as errc: _LOGGER.debug("Error Connecting: " + str(errc)) except requests.exceptions.Timeout as errt: - _LOGGER.debug("Timeout Error: " + str(errt)) - + _LOGGER.debug("Timeout Error: " + str(errt)) + def CallCleanLogsApi(self, args, verify_ssl=True): params = {} params.update(args) - headers = {'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)',} - url = (self.portal_url_format + "/lg/log.do?td=" + params['td'] + "&u=" + params['auth']['userid'] + "&cv=1.67.3&t=a&av=1.3.1").format(continent=self.continent) + headers = { + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)", + } + url = ( + self.portal_url_format + + "/lg/log.do?td=" + + params["td"] + + "&u=" + + params["auth"]["userid"] + + "&cv=1.67.3&t=a&av=1.3.1" + ).format(continent=self.continent) try: - with requests.post(url, headers=headers, json=params, timeout=60, verify=verify_ssl) as response: + with requests.post( + url, headers=headers, json=params, timeout=60, verify=verify_ssl + ) as response: data = response.json() if response.status_code != 200: _LOGGER.warning("Error calling API " + str(url)) @@ -163,10 +243,10 @@ def _handle_ctl_api(self, action, message): eventname = action.name.lower() if message is not None: - if eventname == 'getcleanlogs': + if eventname == "getcleanlogs": resp = self._ctl_to_dict_api(eventname, message) else: - resp = self._ctl_to_dict_api(eventname, message.get('resp')) + resp = self._ctl_to_dict_api(eventname, message.get("resp")) if resp is not None: for s in self.ctl_subscribers: @@ -176,44 +256,46 @@ def _ctl_to_dict_api(self, eventname: str, jsonstring: dict): if jsonstring is None or jsonstring == {}: return - if eventname == 'getcleanlogs': - jsonstring['event'] = "clean_logs" - elif jsonstring['body']['msg'] == 'ok': - if 'cleaninfo' in eventname: - jsonstring['event'] = "clean_report" - elif 'chargestate' in eventname: - jsonstring['event'] = "charge_state" - elif 'battery' in eventname: - jsonstring['event'] = "battery_info" - elif 'lifespan' in eventname: - jsonstring['event'] = "life_span" - elif 'getspeed' in eventname: - jsonstring['event'] = "fan_speed" - elif 'cachedmapinfo' in eventname: - jsonstring['event'] = "cached_map" - elif 'minormap' in eventname: - jsonstring['event'] = "minor_map" - elif 'majormap' in eventname: - jsonstring['event'] = "major_map" - elif 'mapset' in eventname: - jsonstring['event'] = "map_set" - elif 'mapsubset' in eventname: - jsonstring['event'] = "map_sub_set" - elif 'getwater' in eventname: - jsonstring['event'] = "water_info" - elif 'getpos' in eventname: - jsonstring['event'] = "set_position" - elif 'getmaptrace' in eventname: - jsonstring['event'] = "map_trace" - elif 'getstats' in eventname: - jsonstring['event'] = "stats" + if eventname == "getcleanlogs": + jsonstring["event"] = "clean_logs" + elif jsonstring["body"]["msg"] == "ok": + if "cleaninfo" in eventname: + jsonstring["event"] = "clean_report" + elif "chargestate" in eventname: + jsonstring["event"] = "charge_state" + elif "battery" in eventname: + jsonstring["event"] = "battery_info" + elif "lifespan" in eventname: + jsonstring["event"] = "life_span" + elif "getspeed" in eventname: + jsonstring["event"] = "fan_speed" + elif "cachedmapinfo" in eventname: + jsonstring["event"] = "cached_map" + elif "minormap" in eventname: + jsonstring["event"] = "minor_map" + elif "majormap" in eventname: + jsonstring["event"] = "major_map" + elif "mapset" in eventname: + jsonstring["event"] = "map_set" + elif "mapsubset" in eventname: + jsonstring["event"] = "map_sub_set" + elif "getwater" in eventname: + jsonstring["event"] = "water_info" + elif "getpos" in eventname: + jsonstring["event"] = "set_position" + elif "getmaptrace" in eventname: + jsonstring["event"] = "map_trace" + elif "getstats" in eventname: + jsonstring["event"] = "stats" else: # No need to handle other events return else: - if jsonstring['body']['msg'] == 'fail': - if eventname == "charge": #So far only seen this with Charge, when already docked - jsonstring['event'] = "charge_state" + if jsonstring["body"]["msg"] == "fail": + if ( + eventname == "charge" + ): # So far only seen this with Charge, when already docked + jsonstring["event"] = "charge_state" return else: return diff --git a/setup.py b/setup.py index 0298e00..bd5ad42 100644 --- a/setup.py +++ b/setup.py @@ -6,93 +6,75 @@ here = path.abspath(path.dirname(__file__)) # Get the long description from the README file -long_description = '' +long_description = "" setup( - name='deebotozmo', - version='1.7.8', - - description='a library for controlling certain robot vacuums [SUCKS FORK]', + name="deebotozmo", + version="10.9.9", + description="a library for controlling certain robot vacuums [SUCKS FORK]", long_description=long_description, - # Author details - author='Andrea Liosi', - author_email='andrea.liosi@gmail.com', - + author="Andrea Liosi", + author_email="andrea.liosi@gmail.com", # Choose your license - license='GPL-3.0', - + license="GPL-3.0", # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ # How mature is this project? Common values are # 3 - Alpha # 4 - Beta # 5 - Production/Stable - 'Development Status :: 4 - Beta', - + "Development Status :: 4 - Beta", # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries', - 'Topic :: Home Automation', - + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Topic :: Home Automation", # Pick your license as you wish (should match "license" above) - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python :: 3.5', + "Programming Language :: Python :: 3.5", ], - # What does your project relate to? - keywords='home automation vacuum robot', - + keywords="home automation vacuum robot", # You can just specify the packages manually here if your project is # simple. Or you can use find_packages(). - packages=find_packages(exclude=['contrib', 'docs', 'tests']), - + packages=find_packages(exclude=["contrib", "docs", "tests"]), # List run-time dependencies here. These will be installed by pip when # your project is installed. For an analysis of "install_requires" vs pip's # requirements files see: # https://packaging.python.org/en/latest/requirements.html - install_requires=[ - 'sleekxmppfs>=1.3.4', - 'click>=6', - 'requests>=2.18', - 'pycryptodome>=3.4', - 'pycountry-convert>=0.5', - 'paho-mqtt>=1.4', - 'stringcase>=1.2', - 'numpy>=1.18.3', - 'Pillow>=7.1.2' + "sleekxmppfs>=1.3.4", + "click>=6", + "requests>=2.18", + "pycryptodome>=3.4", + "pycountry-convert>=0.5", + "paho-mqtt>=1.4", + "stringcase>=1.2", + "numpy>=1.18.3", + "Pillow>=7.1.2", ], - #'matplotlib>=3.2.1' - # List additional groups of dependencies here (e.g. development # dependencies). You can install these using the following syntax, # for example: # $ pip install -e .[dev,test] extras_require={ - 'dev': [ - 'nose', - 'requests-mock>=1.3' - ], + "dev": ["nose", "requests-mock>=1.3"], }, - # If there are data files included in your packages that need to be # installed, specify them here. If using Python 2.6 or less, then these # have to be included in MANIFEST.in as well. # package_data={ # 'sample': ['package_data.dat'], # }, - # To provide executable scripts, use entry points in preference to the # "scripts" keyword. Entry points provide cross-platform support and allow # pip to create the appropriate form of executable for the target platform. entry_points={ - 'console_scripts': [ - 'deebotozmo=deebotozmo.cli:cli', + "console_scripts": [ + "deebotozmo=deebotozmo.cli:cli", ], }, ) From b9a68493d4bb9bcee9732383ad4be18378c059c5 Mon Sep 17 00:00:00 2001 From: Andrea <31848430+And3rsL@users.noreply.github.com> Date: Tue, 9 Mar 2021 15:33:51 +0100 Subject: [PATCH 2/4] Published to Beta Channel --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index bd5ad42..260f256 100644 --- a/setup.py +++ b/setup.py @@ -9,9 +9,9 @@ long_description = "" setup( - name="deebotozmo", - version="10.9.9", - description="a library for controlling certain robot vacuums [SUCKS FORK]", + name="deebotozmo-beta", + version="0.0.1", + description="a library for controlling certain robot vacuums - BETA", long_description=long_description, # Author details author="Andrea Liosi", From 49405920d67b9f669006035d3eeffe55e94e5e7e Mon Sep 17 00:00:00 2001 From: Andrea <31848430+And3rsL@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:54:52 +0100 Subject: [PATCH 3/4] Add disconnect function --- deebotozmo/__init__.py | 4 ++++ deebotozmo/ecovacsjson.py | 5 +++-- setup.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/deebotozmo/__init__.py b/deebotozmo/__init__.py index 379f954..2cfc8cf 100644 --- a/deebotozmo/__init__.py +++ b/deebotozmo/__init__.py @@ -852,6 +852,10 @@ def setScheduleUpdates( if self.live_map_enabled: self.json.scheduleLiveMap(liveMap_cycle, self.refresh_liveMap) + def disconnect(self): + _LOGGER.debug("vacbot disconnected schedule") + self.json.disconnect() + def updateEverythingNOW(self): if self.live_map_enabled: self.refresh_liveMap(True) diff --git a/deebotozmo/ecovacsjson.py b/deebotozmo/ecovacsjson.py index fc39bc5..232b7ba 100644 --- a/deebotozmo/ecovacsjson.py +++ b/deebotozmo/ecovacsjson.py @@ -68,8 +68,9 @@ def __init__( def subscribe_to_ctls(self, function): self.ctl_subscribers.append(function) - def _disconnect(self): - self.scheduler.empty() # Clear schedule queue + def disconnect(self): + list(map(self.scheduler.cancel, self.scheduler.queue)) + list(map(self.schedulerLV.cancel, self.schedulerLV.queue)) # Schedule Thread def _run_scheduled_func(self, timer_seconds, timer_function): diff --git a/setup.py b/setup.py index 260f256..fa5037a 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="deebotozmo-beta", - version="0.0.1", + version="0.0.2", description="a library for controlling certain robot vacuums - BETA", long_description=long_description, # Author details From d9e0e360670ccb60a3a4926707eef1f71769f339 Mon Sep 17 00:00:00 2001 From: Andrea <31848430+And3rsL@users.noreply.github.com> Date: Thu, 11 Mar 2021 12:51:39 +0100 Subject: [PATCH 4/4] Addded name and fwVersion --- deebotozmo/__init__.py | 32 ++++++++++++++++++++------------ setup.py | 2 +- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/deebotozmo/__init__.py b/deebotozmo/__init__.py index 2cfc8cf..8a3b067 100644 --- a/deebotozmo/__init__.py +++ b/deebotozmo/__init__.py @@ -37,7 +37,7 @@ class EcoVacsAPI: USERSAPI = "users/user.do" IOTDEVMANAGERAPI = "iot/devmanager.do" # IOT Device Manager - This provides control of "IOT" products via RestAPI 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. - + APPAPI = "appsvr/app.do" REALM = "ecouser.net" def __init__( @@ -120,7 +120,7 @@ def __signAuth(self, params): return result def __call_main_api(self, function, *args): - # _LOGGER.debug("calling main api {} with {}".format(function, args)) + _LOGGER.debug("calling main api {} with {}".format(function, args)) params = OrderedDict(args) params["requestId"] = self.md5(time.time()) @@ -136,7 +136,7 @@ def __call_main_api(self, function, *args): ) json = api_response.json() - # _LOGGER.debug("got {}".format(json)) + _LOGGER.debug("got {}".format(json)) if json["code"] == "0000": return json["data"] elif json["code"] == "1005": @@ -151,7 +151,7 @@ def __call_main_api(self, function, *args): ) def __call_auth_api(self, device_id, *args): - # _LOGGER.debug("calling auth api with {}".format(args)) + _LOGGER.debug("calling auth api with {}".format(args)) params = OrderedDict(args) params["bizType"] = "ECOVACS_IOT" params["deviceId"] = device_id @@ -168,7 +168,7 @@ def __call_auth_api(self, device_id, *args): ) json = api_response.json() - # _LOGGER.debug("got {}".format(json)) + _LOGGER.debug("got {}".format(json)) if json["code"] == "0000": return json["data"] @@ -184,7 +184,7 @@ def __call_auth_api(self, device_id, *args): ) def __call_user_api(self, function, args): - # _LOGGER.debug("calling user api {} with {}".format(function, args)) + _LOGGER.debug("calling user api {} with {}".format(function, args)) params = {"todo": function} params.update(args) response = requests.post( @@ -194,7 +194,7 @@ def __call_user_api(self, function, args): verify=self.verify_ssl, ) json = response.json() - # _LOGGER.debug("got {}".format(json)) + _LOGGER.debug("got {}".format(json)) if json["result"] == "ok": return json else: @@ -206,9 +206,9 @@ def __call_user_api(self, function, args): ) def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): - # _LOGGER.debug("calling user api {} with {}".format(function, args)) + _LOGGER.debug("calling user api {} with {}".format(function, args)) - if api == self.USERSAPI: + if api == self.USERSAPI or api == self.APPAPI: params = {"todo": function} params.update(args) else: @@ -231,7 +231,7 @@ def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): response = requests.post(url, json=params, timeout=60, verify=verify_ssl) json = response.json() - # _LOGGER.debug("got {}".format(json)) + _LOGGER.debug("got {}".format(json)) if api == self.USERSAPI: if json["result"] == "ok": return json @@ -271,6 +271,9 @@ def __call_portal_api(self, api, function, args, verify_ssl=True, **kwargs): if json["code"] == 0: return json + if api.startswith(self.APPAPI): + if json["code"] == 0: + return json else: _LOGGER.error("call to {} failed with {}".format(function, json)) raise RuntimeError( @@ -305,8 +308,8 @@ def __call_login_by_it_token(self): def getdevices(self): return self.__call_portal_api( - self.USERSAPI, - "GetDeviceList", + self.APPAPI, + "GetGlobalDeviceList", { "userid": self.uid, "auth": { @@ -406,6 +409,9 @@ def __init__( self.water_level = None self.mop_attached: bool = False + self.fwversion = None + self.modelVersion = self.vacuum["deviceName"] + # Populated by component Lifespan reports self.components = {} @@ -552,6 +558,8 @@ def _handle_water_info(self, event): self.waterEvents.notify(event=(self.water_level, self.mop_attached)) def _handle_clean_report(self, event): + self.fwversion = event["header"]["fwVer"] + response = event["body"]["data"] if response["state"] == "clean": if response["trigger"] == "app" or response["trigger"] == "sched": diff --git a/setup.py b/setup.py index fa5037a..12d9b9e 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="deebotozmo-beta", - version="0.0.2", + version="0.0.3", description="a library for controlling certain robot vacuums - BETA", long_description=long_description, # Author details