commit d9ceeea556f67d58cea10169befe40dbddb8b45f Author: Torbjörn Axelsson Date: Fri Dec 15 15:14:33 2017 -0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3174b14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +.DS_Store +Pipfile.lock diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..def16e1 --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[packages] + + + +[dev-packages] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8e0426 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +Bumper +===== + +A standalone implementation of the central server used by Ecovacs +Deebot cleaning robots to relay data between the robot and client. + +Tested on Ecovacs Deebot M81 Pro but should be compatible with +most wifi-enabled robots that use the Ecovacs Android app. + +*Please note: this software is experimental and not ready +for production use. Use at your own risk.* + + +## Why? + +For fun, mostly :) + +But seriously, there are a serveral reasons for +elminating the central server: + +1. Convenience: It works without an internet connection or if +Ecovacs servers are down +2. Performance: No need for messages to travel to Ecovacs server +and back. +3. Security: We can completely isolate the robot from the public +Internet. + + +## Requirements + +- An Ecovacs wifi-enabled robot +- A computer on your local network to run the Bumper server +- Python 3 and pipenv +- A network router that has functionality for overriding DNS queries +- A client that can connect to Bumper and talk to the robot over the +Ecovacs protocol. +I recomend [Sucks](https://github.com/wpietri/sucks). +It can run on the same computer and requires only minimal modification +to work with Bumper. + + +## Usage + +- Run `pipenv install` to install dependencies + +- Configure your Ecovacs vacuum using the official mobile app +(if you haven't done this already) + +- Configure your router DNS to point the domain lbo.ecovacs.net to +the machine that will run the Bumper server + +- Start Bumper with `pipenv run python bumper.py` + +- Reboot your robot (remove and re-insert the battery pack, +then power it on) + +- If your configuration is correct, the robot will connect to Bumper +within about 30 seconds. Bumper will output informaiton about the +connection status. + +- Configure your client - see below. + + +## Using with Sucks + +Instructions (verified to work with Sucks 0.8.3) + +- Download and install [Sucks](https://github.com/wpietri/sucks) +- See the [example script](examples/sucks.py) for how to connect + + +## Using with the official Android/iOS App + +Bumper *can* be used with the official app, but with limitations. Your +phone needs to use your DNS server with custom settings, and the app +authenticates via Ecovacs central servers every time you start it. + +- Configure your DNS server to point the domains msg-na.ecouser.net and +msg-ww.ecouser.net to the machine running Bumpy. Note: Depending on +country, your phone may be using a different domain. + +- Login to the app. It will authenticate and ask for a list of robots +from Ecovacs central servers. + +- The app will now connect to Bumper and try to ping the robot. Bumper +responds to this ping to tell the app that the robot is online. + +- You should now be able to control the robot from the app. + + +## How does it work? + +Ecovacs robots communicate over the XMPP (jabber) protocol. Messages +are relayed by a central XMPP server. + +Bumper exposes a simulated XMPP server that implements the nessecary +functions for relaying messages between a robot and a client. + +When the robot boots up it sends a HTTP request to lbo.ecovacs.net:8007 +asking for the IP address and port of the XMPP server. Because of our +DNS override, this request will be received by Bumper. We tell the robot +to connect over XMPP to our local machine. Voilá! + + +## Thanks + +Bumper woulden't exist without [Sucks](https://github.com/wpietri/sucks), +an open source client for Ecovacs robots. Big thanks to @wpietri and +contributors! \ No newline at end of file diff --git a/bumper.py b/bumper.py new file mode 100644 index 0000000..cdbd414 --- /dev/null +++ b/bumper.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +import logging +import bumper + +logging.basicConfig(level=logging.INFO, + format='%(levelname)-8s %(message)s') + +bumper.ConfServer() +bumper.XMPPServer() + +try: + while True: + pass +except KeyboardInterrupt: + logging.info('keyboard interrupt') diff --git a/bumper/__init__.py b/bumper/__init__.py new file mode 100644 index 0000000..6ff2fd5 --- /dev/null +++ b/bumper/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + +from .confserver import ConfServer +from .xmppserver import XMPPServer diff --git a/bumper/confserver.py b/bumper/confserver.py new file mode 100644 index 0000000..0f6bebc --- /dev/null +++ b/bumper/confserver.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +from http.server import HTTPServer +from http.server import BaseHTTPRequestHandler +from http import HTTPStatus +import socket, logging, _thread, json + + +class RequestHandler(BaseHTTPRequestHandler): + def do_POST(self): + try: + self.protocol_version = 'HTTP/1.1' + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + logging.debug("Headers: " + str(self.headers)) + request_body = post_data.decode('utf-8') + logging.debug("Request: " + request_body) + if request_body.find('EcoMsgNew') > -1: + body = '{{"result":"ok","ip":"{}","port":5223}}'.format(socket.gethostbyname(socket.gethostname())) + else: + body = '{"result":"ok","ip":"47.88.66.164","port":8005}' + logging.debug("Response: " + body) + body = body.encode() + self.send_response(HTTPStatus.OK) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Connection', 'keep-alive') + self.send_header('Content-Length', len(body)) + self.end_headers() + self.wfile.write(body) + return + except Exception as e: + logging.error(e) + + +class ConfServer(): + def __init__(self): + try: + server_address = (socket.gethostbyname(socket.gethostname()), 8007) + httpd = HTTPServer(server_address, RequestHandler) + logging.info("ConfServer: running on http://{}:{}".format(server_address[0], server_address[1])) + _thread.start_new_thread(httpd.serve_forever, ()) + except Exception as e: + logging.error(e) diff --git a/bumper/xmppserver.py b/bumper/xmppserver.py new file mode 100644 index 0000000..3ce3dc4 --- /dev/null +++ b/bumper/xmppserver.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 + +import sys, socket, _thread, re, time, logging, uuid +import xml.etree.ElementTree as ET + + +class XMPPServer(): + server_id = 'bumper' + bot_id = 'bumpy' + client_id = None + clients = [] + + def __init__(self): + try: + # Initialize bot server + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_address = (socket.gethostbyname(socket.gethostname()), 5223) + server.bind(server_address) + server.listen(1) + logging.info('XMPPServer: listening on {}:{}'.format(server_address[0], server_address[1])) + while True: + logging.info('XMPPServer: awaiting connection') + connection, client_address = server.accept() + # disconnect any clients with this ip + for client in self.clients: + if client.address == client_address[0]: + client.disconnect() + _thread.start_new_thread(Client,(connection, client_address)) + except KeyboardInterrupt: + logging.info('keyboard interrupt') + server.shutdown(2) + server.close() + except Exception as e: + server.shutdown(2) + server.close() + logging.error(e) + + +class Client(): + IDLE = 0 + CONNECT = 1 + INIT = 2 + BIND = 3 + READY = 4 + DISCONNECT = 5 + UNKNOWN = 0 + BOT = 1 + CONTROLLER = 2 + + def __init__(self, connection, client_address): + self.id = uuid.uuid4() + self.type = self.UNKNOWN + self.state = self.IDLE + self.connection = connection + self.address = client_address[0] + XMPPServer.clients.append(self) + self._main() + + def send(self, command): + logging.debug('to {}: {}'.format(self.address, command)) + self.connection.send(command.encode()) + + def disconnect(self): + logging.info('{} disconnecting'.format(self.address)) + self.connection.close() + self._set_state('DISCONNECT') + + def _tag_strip_uri(self, tag): + if tag[0] == "{": + uri, ignore, tag = tag[1:].partition("}") + return tag + + def _set_state(self, state): + new_state = getattr(Client, state) + if self.state > new_state: + raise Exception("{} illegal state change {}->{}".format(self.address, self.state, new_state)) + logging.info('{} state: {}'.format(self.address, state)) + self.state = new_state + + def _handle_ctl(self, xml, data): + ctl = xml[0][0] + if ctl.get('admin') and self.type == self.BOT: + logging.info('admin username received from bot: {}'.format(ctl.get('admin'))) + XMPPServer.client_id = ctl.get('admin') + return + # forward + for client in XMPPServer.clients: + if client.address != self.address and client.state == client.READY: + if client.type == self.BOT: + data = data.decode('utf-8') + id_index = data.find('id') + if id_index > -1: + data = data[:id_index] + 'from="' + XMPPServer.client_id + '" ' + data[id_index:] + data = data.encode() + client.send(data.decode('utf-8')) + + def _handle_result(self, data): + # forward + for client in XMPPServer.clients: + if client.address != self.address and client.state == client.READY: + logging.debug('sending result: ' + data.decode('utf-8')) + client.send(data.decode('utf-8')) + + def _main(self): + try: + logging.info('client connected: {}'.format(self.address)) + self._set_state('CONNECT') + while True: + data = self.connection.recv(4096) + if data: + logging.debug('from {}: {}'.format(self.address, data.decode('utf-8'))) + try: + if self.state == self.CONNECT: + if data.decode('utf-8').find('jabber:client') > -1: + self._set_state('INIT') + # ack jabbr:client + self.send(''.format(XMPPServer.server_id)) + time.sleep(0.5) + # session + self.send('') + continue + xml = ET.fromstring(data) + if len(xml): + child = self._tag_strip_uri(xml[0].tag) + else: + child = None + if xml.get('id'): + last_id = xml.get('id') + else: + last_id = '0' + if xml.tag == 'iq': + res = None + if child == 'bind': + res = '{}'.format(last_id, XMPPServer.bot_id) + self._set_state('BIND') + elif child == 'session': + res = ''.format(last_id) + self._set_state('READY') + elif child == 'query': + self._handle_ctl(xml, data) + elif child == 'ping': + # respond to ping request + res = ''.format(last_id, xml.get('to')) + elif xml.get('type') == 'result': + self._handle_result(data) + if res: + self.send(res) + elif xml.tag == 'presence': + if len(xml) and xml[0].tag == 'status': + # bot announcing arrival + self.type = self.BOT + logging.info('{} type set to BOT (based on presence tag)'.format(self.address)) + # send a command from an unknown user - the response will contain the correct admin username + self.send(''.format(uuid.uuid4(), 'unknown@ecouser.net', XMPPServer.bot_id)) + elif xml.get('type') == 'available': + self.type = self.CONTROLLER + logging.info('{} type set to CONTROLLER (based on presence tag)'.format(self.address)) + except ET.ParseError as e: + logging.debug("error: {}".format(e)) + except Exception as e: + logging.error(e) + self._set_state('DISCONNECT') + except Exception as e: + logging.error(e) + self._set_state('DISCONNECT') + finally: + self.disconnect() diff --git a/certs/cert.pem b/certs/cert.pem new file mode 100644 index 0000000..87925f8 --- /dev/null +++ b/certs/cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAKLG5udzkR+zMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcxMjA1MTkxNTQwWhcNMTgxMjA1MTkxNTQwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAu1z0Iw7KLJZD8CBrZWm9Jp7eqIAqdoTn3GWmNmzgIDnzmxZb70War74d +7aWDVtEVtOZQ8ShW15zRanr6hQkHdM5Li1KTMYW3P7TOmzJZkESNEZviOyvlfRPG +oLLbPIp+A7W8Zz3ST0cDzyy6N7LRAA0OywRgPhrxrN637Fx1+SFwqKQg0IUOuX3v +veZ1oAZSBUDzpDPUaak7jXugB11wt6XTEekwOcrH7WEDoFH87thtJyCM7IidW0bM +rkqxeiM18qMzn8cGqnm2nY+VHgK0t0/QZ/cftPVc2IHZLr/+eTGaJmPv7gL+7v/l +7fgk3RMTn0GrdTO+Vvnu1qNK7r+//KMgGOo6zwEQAI9Ml6y1LRuEXrymgsYY9qCE +ZtBV0FoZaVYAuGG4cvCDLLPgM2OivIcWgG+77MqMH/RkWg+BrUesn7ISEpF+7Q7+ +jc6pFYr4y5fc2jJ9/c5Mxu6sv0Tc8N/c9WpcXsJ6Y1JrBbDcdGSwmTZ1JVyiog9O +6bU/16QTuU3w2oH9qjnc907tZuB66P53WJ2iHPvB8yF5V93FvtbPYsHpO7noURSI +LqvKoNy31ndv4kao2AIzrJuhBBkSorBEcoFerRRBJMHa85R5Tb6iuGg6RFv0BB8K +gv6mG/7oqmR8QDnRRd54U2pEsIkYmgoIU4/8L91r9Vd1eiEDEnsCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUqA/GBsZAepFIcbgw6gK/HTUWolswdQYDVR0jBG4wbIAUqA/G +BsZAepFIcbgw6gK/HTUWoluhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCi +xubnc5EfszAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB7kHEHKNDQ +TcvwY9z3RscpHyWwjrGjUdjk8AkM9cTm/sT8G7JU/7+KZueu4elNOc4hir/+DMlO +/AN6IXRNrfPO8IVG6hKnK9K3wvceJzg6mT8F4peg4vMNdIsrZyQaPhWArUF7LG/Q +HF4Yk8jnw3NKIG5fnlagAGFDdDGSdDK/IQN+JA6sHJVQRrBLpksRUCWFY7QJGYVS +DmEAYr6Y6emftbflUjW6iC/E57lC5F7bQyoIEIBBHKjMaNTdM8TzOUZSOGLwAuVD +C1iAoZn7VWeGa4h/xwx0yio0yeI6gRh3fINBKoCNv6pLUm6sgrW8oqGMDJiH0T3H +sc8xC3bK2DPO4sIyisL3HUPcsSFB/hf0i8BOQrihrpZEn/fx4lTRyQYSKT5NVSl4 +v/9hmDjWIXh9EInGfP8DdWp4Og8GiXcV9rUqvRBo5aBwqK/aTNRCk6ah5N958yFu +wHlrbQmPnw1AqJSG1oG+4dcXnrcgd/xGMAarTbgzqsd8icafg8v2x2pG5ZexE3uN +0METK9hvCrjN8/Rvo2ViTDa8WF4CXMuVFG4FdbeRrYPZl1j1jBYDMtKkVJJCsVQd +chYbqKgNDmMcbfpksBtF3XpYTtVNAKA9g5mrAhEJS88uy63PStLoBQNXv9y8ksql +VVCTFNGy5JRyvOeSRYSSdXjKbfj5Vgh1HQ== +-----END CERTIFICATE----- diff --git a/certs/key.pem b/certs/key.pem new file mode 100644 index 0000000..852040d --- /dev/null +++ b/certs/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/examples/sucks.py b/examples/sucks.py new file mode 100644 index 0000000..9ab933f --- /dev/null +++ b/examples/sucks.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +from sucks import * + +class BumperVacBot(VacBot): + def __init__(self, server_address): + self.server_address = server_address + vacuum = { 'did':'none','class':'none' } + super().__init__('sucks', 'ecouser.net', '', '', vacuum, '') + def connect_and_wait_until_ready(self): + logging.info('connecting') + self.xmpp.connect(self.server_address) + self.xmpp.process() + self.xmpp.wait_until_ready() + +logging.basicConfig(level=logging.DEBUG, format='%(levelname)-8s %(message)s') + +# Change server address to the machine running Bumper +server_address = ('xxx.xxx.xxx.xxx', 5223) + +bumper_client = BumperVacBot(server_address) +bumper_client.connect_and_wait_until_ready()