diff --git a/README.md b/README.md index b81c99c..c318109 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ As work to reverse the protocols and provide a self-hosted central server is sti | Model | Protocol Used | Bumper Version | |--|--|--| | Deebot 900/901 | MQTT | master | +| Deebot Ozmo 930 | XMPP | master | | Deebot M81 Pro | XMPP | v0.1.0 | For more information about the protocols and how it works, see the [How does it work?](#how-does-it-work) section at the end. If you test against another model and it works, please report it so it can be added to the list. diff --git a/bumper/__init__.py b/bumper/__init__.py index 77e54be..3ae2598 100644 --- a/bumper/__init__.py +++ b/bumper/__init__.py @@ -89,7 +89,7 @@ class VacBotDevice(object): resource="", name="", nick="", - company="eco-ng", + company="", ): self.vac_bot_device_class = vac_bot_device_class self.company = company @@ -132,13 +132,14 @@ def check_authcode(uid, authcode): return False -def add_bot(sn, did, devclass, resource): +def add_bot(sn, did, devclass, resource, company): newbot = VacBotDevice() newbot.did = did newbot.name = sn newbot.vac_bot_device_class = devclass newbot.resource = resource + newbot.company = company bots = bumper_bots_var.get() existingbot = False diff --git a/bumper/confserver.py b/bumper/confserver.py index 70c37d8..6cb58e4 100644 --- a/bumper/confserver.py +++ b/bumper/confserver.py @@ -581,9 +581,28 @@ class ConfServer: self.bumper_bots.set(bots) body = {"result": "ok", "todo": "result"} + + elif todo == "AddOneDevice": + bots = self.bumper_bots.get() + for bot in bots: + if postbody["did"] == bot.did: + bot.nick = postbody["nick"] + self.bumper_bots.set(bots) + body = {"result": "ok", "todo": "result"} + + elif todo == "DeleteOneDevice": + bots = self.bumper_bots.get() + for bot in bots: + if postbody["did"] == bot.did: + #bots.remove(bot) + #self.bumper_bots.set(bots) + body = {"result": "ok", "todo": "result"} + confserverlog.debug( "\r\n POST: {} \r\n Response: {}".format(postbody, body) ) + + return web.json_response(body) except Exception as e: @@ -606,18 +625,24 @@ class ConfServer: if todo == "FindBest": service = postbody["service"] if service == "EcoMsgNew": - body = { - "result": "ok", - "ip": socket.gethostbyname(socket.gethostname()), - "port": 5223, - } + + srvip = socket.gethostbyname(socket.gethostname()) + msgserver = {"ip":srvip,"port":5223,"result":"ok"} + msgserver = json.dumps(msgserver) + msgserver = msgserver.replace(" ","") #bot seems to be very picky about having no spaces, only way was with text + + confserverlog.debug( + "\r\n POST: {} \r\n Response: {}".format(postbody, msgserver) + ) + return web.json_response(text=msgserver) + elif service == "EcoUpdate": body = {"result": "ok", "ip": "47.88.66.164", "port": 8005} confserverlog.debug( "\r\n POST: {} \r\n Response: {}".format(postbody, body) ) - return web.json_response(body) + return web.json_response(body) except Exception as e: confserverlog.exception("{}".format(e)) @@ -627,22 +652,32 @@ class ConfServer: json_body = json.loads(await request.text()) randomid = "".join(random.sample(string.ascii_letters, 6)) bots = self.bumper_bots.get() - for bot in bots: - if bot.did == json_body["toId"] and bot.mqtt_connection == True: - retcmd = await self.helperbot.send_command(json_body, randomid) - body = retcmd - confserverlog.debug( - "\r\n POST: {} \r\n Response: {}".format(json_body, body) + if "toId" in json_body: #Its a command + for bot in bots: + if bot.company == 'eco-ng': + if bot.did == json_body["toId"] and bot.mqtt_connection == True: + retcmd = await self.helperbot.send_command(json_body, randomid) + body = retcmd + confserverlog.debug( + "\r\n POST: {} \r\n Response: {}".format(json_body, body) + ) + return web.json_response(body) + + #No response, send error back + confserverlog.error( + "No bots with DID: {} connected to MQTT".format( + json_body["toId"] ) - return web.json_response(body) - else: - confserverlog.error( - "No bots with DID: {} connected to MQTT".format( - json_body["toId"] - ) - ) - body = {"id": randomid, "errno": bumper.ERR_COMMON, "ret": "fail"} - return web.json_response(body) + ) + body = {"id": randomid, "errno": bumper.ERR_COMMON, "ret": "fail"} + return web.json_response(body) + else: + if "td" in json_body: #Seen when doing initial wifi config + if json_body["td"] == "PollSCResult": + body = { + "ret": "ok" + } + return web.json_response(body) except Exception as e: confserverlog.exception("{}".format(e)) diff --git a/bumper/mqttserver.py b/bumper/mqttserver.py index 35285b2..6dc3f10 100644 --- a/bumper/mqttserver.py +++ b/bumper/mqttserver.py @@ -333,7 +333,7 @@ class BumperMQTTServer_Plugin: ): tmpbotdetail = str(didsplit[1]).split("/") bumper.add_bot( - username, didsplit[0], tmpbotdetail[0], tmpbotdetail[1] + username, didsplit[0], tmpbotdetail[0], tmpbotdetail[1], "eco-ng" ) mqttserverlog.debug( "new bot authenticated SN: {} DID: {}".format( diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py index f20081f..78cf78b 100644 --- a/bumper/xmppserver.py +++ b/bumper/xmppserver.py @@ -11,8 +11,7 @@ xmppserverlog = logging.getLogger("xmppserver") class XMPPServer: - server_id = "bumper" - bot_id = "bumpy" + server_id = "ecouser.net" client_id = None clients = [] exit_flag = False @@ -193,6 +192,8 @@ class Client(threading.Thread): self.connection = connection self.address = client_address[0] self.clientresource = "" + self.devclass = "" + self.bumper_jid = "" self.uid = "" self.log_sent_message = False # Set to true to log sends self.log_incoming_data = True # Set to true to log sends @@ -216,7 +217,7 @@ class Client(threading.Thread): except BrokenPipeError as e: xmppserverlog.error("{}".format(e)) - # self._set_state('DISCONNECT') + self._set_state('DISCONNECT') except ConnectionResetError as e: xmppserverlog.error("{}".format(e)) @@ -280,7 +281,7 @@ class Client(threading.Thread): def _handle_ctl(self, xml, data): try: - if data.decode("utf-8").find("roster") > -1: + if "roster" in data: # Return not-implemented for roster self.send( ''.format( @@ -291,23 +292,18 @@ class Client(threading.Thread): if xml.get("type") == "set": if ( - data.decode("utf-8").find("com:sf") > -1 + "com:sf" in data and xml.get("to") == "rl.ecorobot.net" ): # Android bind? Not sure what this does yet. self.send( ''.format( xml.get("id"), self.uid, - XMPPServer.bot_id, + XMPPServer.server_id, self.clientresource, ) ) - else: - xmppserverlog.debug( - "Unknown set type: {}".format(data.decode("utf-8")) - ) - if xml[0][0]: ctl = xml[0][0] if ctl.get("admin") and self.type == self.BOT: @@ -319,108 +315,190 @@ class Client(threading.Thread): # forward for client in XMPPServer.clients: - if client.address != self.address and client.state == client.READY: + if client.bumper_jid != self.bumper_jid and client.state == client.READY: + ctl_to = xml.get("to") + xml.attrib["from"] = "{}".format(self.bumper_jid) + rxmlstring = ET.tostring(xml).decode("utf-8") + #clean up string to remove namespaces added by ET + rxmlstring = rxmlstring.replace("xmlns:ns0=", "xmlns=") + rxmlstring = rxmlstring.replace("ns0:", "") + rxmlstring = rxmlstring.replace('iq xmlns="com:ctl"', "iq") + rxmlstring = rxmlstring.replace(' -1: - data = ( - data[:id_index] - + 'from="' - + XMPPServer.client_id - + '" ' - + data[id_index:] - ) - data = data.encode() - client.send(data.decode("utf-8")) + if client.uid.lower() in ctl_to.lower(): + xmppserverlog.info("Sending ctl to bot: {}".format(rxmlstring)) + client.send(rxmlstring) + except Exception as e: xmppserverlog.exception("{}".format(e)) def _handle_ping(self, xml, data): try: - if xml.get("to").find("@") == -1: + if xml.get("to").find("@") == -1: #No to address # Ping to server - respond - self.send( - ''.format( + pingresp = ''.format( xml.get("id"), xml.get("to") ) - ) + #xmppserverlog.debug("Server Ping resp: {}".format(pingresp)) + self.send(pingresp) else: + pingto = xml.get("to") + pingfrom = self.bumper_jid + + xml.attrib["from"] = pingfrom + pingstring = ET.tostring(xml).decode("utf-8") + #clean up string to remove namespaces added by ET + pingstring = pingstring.replace("xmlns:ns0=", "xmlns=") + pingstring = pingstring.replace("ns0:", "") + pingstring = pingstring.replace('iq xmlns="com:ctl"', "iq") + pingstring = pingstring.replace(''.format( + uuid.uuid4(), adminuser, self.bumper_jid, newuser) + xmppserverlog.debug("Add User: {}".format(adduser)) + self.send(adduser) + + #Add user ACs - Manage users, settings, and clean (full access) + adduseracs = ''.format( + uuid.uuid4(), adminuser, self.bumper_jid, newuser) + xmppserverlog.debug("Add User ACs: {}".format(adduseracs)) + self.send(adduseracs) + + #GetUserInfo - Just to confirm it set correctly + self.send( + ''.format( + uuid.uuid4(), adminuser, self.bumper_jid) + ) + + else: + rxmlstring = ET.tostring(xml).decode("utf-8") + #clean up string to remove namespaces added by ET + rxmlstring = rxmlstring.replace("xmlns:ns0=", "xmlns=") + rxmlstring = rxmlstring.replace("ns0:", "") + rxmlstring = rxmlstring.replace('iq xmlns="com:ctl"', "iq") + rxmlstring = rxmlstring.replace(' -1: - # ack jabbr:client - # no STARTTLS - self.send( - ''.format( - XMPPServer.server_id + if xml == None: + # Client first connecting, send our features + if data.decode("utf-8").find("jabber:client") > -1: + sc = data.decode("utf-8").find("to=") + ec = data.decode("utf-8").find(".ecorobot.net") + if ec > -1: + self.devclass = data.decode("utf-8")[sc+4:ec] + # ack jabbr:client + # no STARTTLS + self.send( + ''.format( + XMPPServer.server_id + ) ) - ) - # with STARTTLS - # self.send(''.format(XMPPServer.server_id)) - time.sleep(0.25) - # send authentication support for iq-auth (fallback) and SASL - self.send( - 'PLAIN' - ) - # self.send('') + # with STARTTLS + # self.send(''.format(XMPPServer.server_id)) + time.sleep(0.25) + # send authentication support for iq-auth (fallback) and SASL + self.send( + 'PLAIN' + ) + # self.send('') - elif data.decode("utf-8").find("jabber:iq:auth") > -1: # Handle iq-auth - self._handle_iq_auth(data) - - elif ( - data.decode("utf-8").find("urn:ietf:params:xml:ns:xmpp-sasl") > -1 - ): # Handle SASL auth - self._handle_sasl_auth(data) + else: + self.send("") + + else: + if "jabber:iq:auth" in xml.tag: # Handle iq-auth + self._handle_iq_auth(xml) + elif "urn:ietf:params:xml:ns:xmpp-sasl" in xml.tag: #Handle SASL Auth + self._handle_sasl_auth(xml) + else: + xmppserverlog.error("Couldn't handle: {}".format(xml)) elif self.state == self.INIT: - # Client getting session after authentication - if data.decode("utf-8").find("jabber:client") > -1: - # ack jabbr:client - self.send( - ''.format( - XMPPServer.server_id + if xml == None: + # Client getting session after authentication + if data.decode("utf-8").find("jabber:client") > -1: + # ack jabbr:client + self.send( + ''.format( + XMPPServer.server_id + ) + ) + time.sleep(0.25) + # session + self.send( + '' ) - ) - time.sleep(0.25) - # session - self.send( - '' - ) - else: # Handle init bind - xml = ET.fromstring(data.decode("utf-8")) + else: # Handle init bind if len(xml): - child = self._tag_strip_uri(xml[0].tag) + child = self._tag_strip_uri(xml[0].tag) else: child = None if xml.tag == "iq": if child == "bind": - self._handle_bind(xml) + self._handle_bind(xml) + else: + xmppserverlog.error("Couldn't handle: {}".format(xml)) + + except Exception as e: xmppserverlog.exception("{}".format(e)) @@ -504,7 +582,7 @@ class Client(threading.Thread): except ET.ParseError as e: if "no element found" in e.msg: xmppserverlog.debug( - "xml parse error - {} - {} - this is common with ecovac protocol".format( + "xml parse error - {} - {}".format( data.decode("utf-8"), e ) ) @@ -520,20 +598,27 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception("{}".format(e)) - def _handle_sasl_auth(self, data): - try: - xml = ET.fromstring(data.decode("utf-8")) - saslauth = base64.b64decode(xml.text).decode("utf-8").split("/") + def _handle_sasl_auth(self, xml): + try: + + saslauth = base64.b64decode(xml.text).decode("utf-8").split("/") username = saslauth[0] username = saslauth[0].split("\x00")[1] self.uid = username - resource = saslauth[1] - self.clientresource = resource - authcode = saslauth[2] + if len(saslauth) > 1: + resource = saslauth[1] + self.clientresource = resource + elif len(saslauth[0].split("\x00")) > 2: + resource = saslauth[0].split("\x00")[2] + self.clientresource = resource + + if len(saslauth) > 2: + authcode = saslauth[2] if not self.uid.startswith("fuid"): # Need sample data to see details here - bumper.add_bot("", self.uid, "", resource) + bumper.add_bot(self.uid, self.uid, self.devclass, "atom","eco-legacy") + self.type = self.BOT xmppserverlog.info("bot authenticated {}".format(self.uid)) # Send response self.send( @@ -551,6 +636,7 @@ class Client(threading.Thread): auth = True if auth: + self.type = self.CONTROLLER bumper.add_client(self.uid, "bumper", self.clientresource) xmppserverlog.debug("client authenticated {}".format(self.uid)) @@ -568,22 +654,6 @@ class Client(threading.Thread): '' ) # Fail - except ET.ParseError as e: - if "no element found" in e.msg: - xmppserverlog.debug( - "xml parse error - {} - {} - this is common with ecovac protocol".format( - data.decode("utf-8"), e - ) - ) - elif "not well-formed (invalid token)" in e.msg: - xmppserverlog.debug( - "xml parse error - {} - {}".format(data.decode("utf-8"), e) - ) - else: - xmppserverlog.debug( - "xml parse error - {} - {}".format(data.decode("utf-8"), e) - ) - except Exception as e: xmppserverlog.exception("{}".format(e)) @@ -606,21 +676,31 @@ class Client(threading.Thread): clientbindxml = xml.getchildren() clientresourcexml = clientbindxml[0].getchildren() - if len(clientresourcexml) > 0: + if self.devclass: #its a bot + self.name = "XMPP_Client_{}_{}".format(self.uid,self.devclass) + self.bumper_jid = "{}@{}.ecorobot.net/atom".format(self.uid, self.devclass) + xmppserverlog.debug("new bot {}".format(self.uid)) + res = '{}'.format( + xml.get("id"), self.bumper_jid + ) + elif len(clientresourcexml) > 0: self.clientresource = clientresourcexml[0].text self.name = "XMPP_Client_{}".format(self.clientresource) + self.bumper_jid = "{}@{}/{}".format(self.uid, XMPPServer.server_id, self.clientresource) xmppserverlog.debug( "new client {} using resource {}".format( - self.address, self.clientresource + self.uid, self.clientresource ) ) - res = '{}@{}/{}'.format( - xml.get("id"), self.uid, XMPPServer.bot_id, self.clientresource + res = '{}'.format( + xml.get("id"), self.bumper_jid ) - else: - xmppserverlog.debug("new client {}".format(self.address)) - res = '{}@{}'.format( - xml.get("id"), self.uid, XMPPServer.bot_id + else: + self.name = "XMPP_Client_{}_{}".format(self.uid,self.address) + self.bumper_jid = "{}@{}".format(self.uid, XMPPServer.server_id) + xmppserverlog.debug("new client {}".format(self.uid)) + res = '{}'.format( + xml.get("id"), self.bumper_jid ) self._set_state("BIND") @@ -640,93 +720,148 @@ class Client(threading.Thread): def _handle_presence(self, xml): try: + if len(xml) and xml[0].tag == "status": - # bot announcing arrival - self.type = self.BOT xmppserverlog.debug( - "{} type set to BOT (based on presence tag)".format(self.address) + "bot presence {} ".format(ET.tostring(xml, encoding="utf-8")) ) - # send a command from an unknown user - the response will contain the correct admin username - + #Most likely a bot, possibly hello world in text + + #Send dummy return self.send( - ''.format( - uuid.uuid4(), "unknown@ecouser.net", XMPPServer.bot_id + ' dummy '.format( + self.bumper_jid ) ) - else: - self.type = self.CONTROLLER + #If it is a BOT, send extras + if self.type == self.BOT: + #get device info + self.send( + ''.format( + self.bumper_jid, XMPPServer.server_id + ) + ) + + else: xmppserverlog.debug( - "{} type set to CONTROLLER (based on presence tag)".format( - self.address + "client presence - {} ".format(ET.tostring(xml, encoding="utf-8")) + ) + + if xml.get("type") == "available": + xmppserverlog.debug( + "client presence available - {} ".format(ET.tostring(xml, encoding="utf-8")) ) - ) - self.send( - ' dummy '.format( - self.uid, XMPPServer.bot_id, self.clientresource + #Send dummy return + self.send( + ' dummy '.format( + self.bumper_jid + ) ) - ) + elif xml.get("type") == "unavailable": + xmppserverlog.debug( + "client presence unavailable (DISCONNECT) - {} ".format(ET.tostring(xml, encoding="utf-8")) + ) + + self._set_state("DISCONNECT") + else: + #Sometimes the android app sends these + xmppserverlog.debug( + "client presence (UNKNOWN) - {} ".format(ET.tostring(xml, encoding="utf-8")) + ) + #Send dummy return + self.send( + ' dummy '.format( + self.bumper_jid + ) + ) + except Exception as e: xmppserverlog.exception("{}".format(e)) - def _parse_data(self, data): - if self.log_incoming_data: - xmppserverlog.debug( - "from {} - {}".format(self.address, data.decode("utf-8")) - ) + def _parse_data(self, data): - try: - xml = ET.fromstring(data.decode("utf-8")) - self._handle_xml(xml, data) + + if data.decode("utf-8").startswith("]+\?>)", r"",data.decode("utf-8")) + "" + + else: + newdata = "{}".format(data.decode("utf-8")) #Add artificial root + + try: + root = ET.fromstring(newdata) + for item in root.iter(): + if item.tag != "root": + if item.tag == "iq": + if self.log_incoming_data: + xmppserverlog.debug( + "from {} - {}".format(self.address, str(ET.tostring(item, encoding="utf-8").decode("utf-8")).replace("ns0:","")) + ) + self._handle_iq(item, newdata) + item.clear() + elif "auth" in item.tag: + if "urn:ietf:params:xml:ns:xmpp-sasl" in item.tag: #SASL Auth + self._handle_sasl_auth(item) + item.clear() + + elif "presence" in item.tag: + self._handle_presence(item) + item.clear() + + else: + if self.log_incoming_data: + xmppserverlog.debug( + "Unparsed Item - {}".format(str(ET.tostring(item, encoding="utf-8").decode("utf-8")).replace("ns0:","")) + ) + print("e") + except ET.ParseError as e: if ( "no element found" in e.msg ): # Element not closed or not all bytes received # Happens wth connect stream often - if " - client is signalling end of session/disconnect - if not "" in data.decode("utf-8"): + if not "" in newdata: xmppserverlog.error( - "xml parse error - {} - {}".format(data.decode("utf-8"), e) + "xml parse error - {} - {}".format(newdata, e) ) else: self.send("") # Close stream - - elif ( - "junk after document element" in e.msg - ): # More than one xml doc in data - # try to split it - data0 = data.decode("utf-8") - data1 = data0[e.position[1] :] - data0 = data0[: e.position[1]] - # xmppserverlog.debug('xml parse error - {} - {} - split0: {} - split1: {}'.format(data.decode('utf-8'), e, data0, data1)) - self._parse_data(data0.encode("utf-8")) - self._parse_data(data1.encode("utf-8")) - + else: - xmppserverlog.debug( - "xml parse error - {} - {}".format(data.decode("utf-8"), e) - ) + if "" in newdata: + xmppserverlog.error( + "xml parse error - {} - {}".format(newdata, e) + ) + else: + self.send("") # Close stream + self._set_state("DISCONNECT") + except Exception as e: xmppserverlog.exception("{}".format(e)) - def _handle_xml(self, xml, data): + def _handle_iq(self, xml, data): try: - if self.state == self.CONNECT or self.state == self.INIT: - self._handle_connect(data) - if len(xml): child = self._tag_strip_uri(xml[0].tag) else: @@ -737,14 +872,23 @@ class Client(threading.Thread): self._handle_bind(xml) elif child == "session": self._handle_session(xml) - elif child == "query": - self._handle_ctl(xml, data) elif child == "ping": self._handle_ping(xml, data) + elif child == "query": + if self.type == self.BOT: + self._handle_result(xml, data) + else: + self._handle_ctl(xml, data) elif xml.get("type") == "result": - self._handle_result(data) - elif xml.tag == "presence": - self._handle_presence(xml) + if self.type == self.BOT: + self._handle_result(xml, data) + else: + self._handle_result(xml, data) + elif xml.get("type") == "set": + if self.type == self.BOT: + self._handle_result(xml, data) + else: + self._handle_result(xml, data) except Exception as e: xmppserverlog.exception("{}".format(e)) @@ -752,15 +896,14 @@ class Client(threading.Thread): def run(self): # xmppserverlog.info('client connected - {}'.format(self.address)) self._set_state("CONNECT") - while not self.state == self.DISCONNECT and not self.connection._closed: data = b"" - - time.sleep(0.2) + time.sleep(0.1) if not self.connection._closed: try: - data = self.connection.recv(4096) - + data = self.connection.recv(4096) + if data != b"": + self._parse_data(data) except ConnectionResetError as e: xmppserverlog.error("{}".format(e)) except OSError as e: @@ -768,6 +911,5 @@ class Client(threading.Thread): except Exception as e: xmppserverlog.exception("{}".format(e)) - if data != b"": - self._parse_data(data) - + +