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