Initial commit

This commit is contained in:
Torbjörn Axelsson 2017-12-15 15:14:33 -08:00
commit d9ceeea556
10 changed files with 462 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__
.DS_Store
Pipfile.lock

13
Pipfile Normal file
View file

@ -0,0 +1,13 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]

109
README.md Normal file
View file

@ -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!

16
bumper.py Normal file
View file

@ -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')

4
bumper/__init__.py Normal file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env python3
from .confserver import ConfServer
from .xmppserver import XMPPServer

43
bumper/confserver.py Normal file
View file

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

168
bumper/xmppserver.py Normal file
View file

@ -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('<stream:stream xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client" version="1.0" id="1" from="{}">'.format(XMPPServer.server_id))
time.sleep(0.5)
# session
self.send('<stream:features><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/><session xmlns="urn:ietf:params:xml:ns:xmpp-session"/></stream:features>')
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 = '<iq type="result" id="{}"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>{}</jid></bind></iq>'.format(last_id, XMPPServer.bot_id)
self._set_state('BIND')
elif child == 'session':
res = '<iq type="result" id="{}" />'.format(last_id)
self._set_state('READY')
elif child == 'query':
self._handle_ctl(xml, data)
elif child == 'ping':
# respond to ping request
res = '<iq type="result" id="{}" from="{}" />'.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('<iq type="set" id="{}" from="{}" to="{}"><query xmlns="com:ctl"><ctl td="GetCleanState" /></query></iq>'.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()

33
certs/cert.pem Normal file
View file

@ -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-----

51
certs/key.pem Normal file
View file

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJJwIBAAKCAgEAu1z0Iw7KLJZD8CBrZWm9Jp7eqIAqdoTn3GWmNmzgIDnzmxZb
70War74d7aWDVtEVtOZQ8ShW15zRanr6hQkHdM5Li1KTMYW3P7TOmzJZkESNEZvi
OyvlfRPGoLLbPIp+A7W8Zz3ST0cDzyy6N7LRAA0OywRgPhrxrN637Fx1+SFwqKQg
0IUOuX3vveZ1oAZSBUDzpDPUaak7jXugB11wt6XTEekwOcrH7WEDoFH87thtJyCM
7IidW0bMrkqxeiM18qMzn8cGqnm2nY+VHgK0t0/QZ/cftPVc2IHZLr/+eTGaJmPv
7gL+7v/l7fgk3RMTn0GrdTO+Vvnu1qNK7r+//KMgGOo6zwEQAI9Ml6y1LRuEXrym
gsYY9qCEZtBV0FoZaVYAuGG4cvCDLLPgM2OivIcWgG+77MqMH/RkWg+BrUesn7IS
EpF+7Q7+jc6pFYr4y5fc2jJ9/c5Mxu6sv0Tc8N/c9WpcXsJ6Y1JrBbDcdGSwmTZ1
JVyiog9O6bU/16QTuU3w2oH9qjnc907tZuB66P53WJ2iHPvB8yF5V93FvtbPYsHp
O7noURSILqvKoNy31ndv4kao2AIzrJuhBBkSorBEcoFerRRBJMHa85R5Tb6iuGg6
RFv0BB8Kgv6mG/7oqmR8QDnRRd54U2pEsIkYmgoIU4/8L91r9Vd1eiEDEnsCAwEA
AQKCAgBmUpJl3vrctMeviRT90mxhfhvda/lEtrqla8IFMRqJnvyCMwjMOHgNCEfx
9BUxQYiYPbvwp/YdVGAtxbGxG8g9qzgog1Utk3gLS6QVr76oAjgEaNx5MjTnRPiR
5HvEqWG+hu64d1O2xfK3nxanunlXjMoKZ35kDHT10sAhTjGDeBa9krz1A+WRucxH
KmgMl8cNZXZps4CKn8iNMPmpbAdV1DLcMjyLI0piQjwnvv7iNcIOv0EXIFjHc3fu
q44XRMl+lFBqLtheG5B2peZq+FFXVzhavuRLAEDPYMKKPIIuD7++vY9Xrb0qKwiY
GI+RSIzLyaJopHSLrX0ZLR6MGp4u3D47EwdN6Epir94lLW7lqeyLzXyQN9YWCCiD
f6+aa3CfO6892ToPFstliD1+3o2ghaTG2ypbfK+OaVWwaUzI2TXgFM08b3SPpQaS
xgsanJhVHBSVZgyTU++hSZJcmIFPm0creEdB2HFm/ef0nABoYqRMADOToZSOaIUU
eKy0bLGTkBo5F1lV9s8Bz9GsDihORiwMucJ2Q0k8i6kE26oudMm4dOD8m+Q9Q+i1
cuC2oujD6qGbQx0uagAiNE8OHsvTZ1N7LpB7Z8ipz6k5D5xELsieFzGkj+ZBUh7B
ilRpBO0DLie7e90HICpAuOckpcgU+2Ek/WgFzNl8eEtAl44VOQKCAQEA5HNF8Pni
rjadcOcYXzdVRQp44sjErbUjTiqzsoZrcDYExZQPKPjOIzACjiUzJT6uiuTJcaye
2Y4j4S11M9kKY4E41bmXsIBfioNLd5GfSCYm3qdMTy5hOSOUCLxSA2rQ+QGYMGGc
1EJwGnx5dxS8G1K9tyIC1aSiYJivvGstNEWy+2SIWWfHIg9s6edEKhLo+lMVb0aZ
JLrtE0XWKS9u5meZdKpib20WfNI2XCKH7bs8wKagHEvOv1xky1R0+5kJ61bUhuto
3kdUtd6RmcXRlAhxRUGta9PszKVBaUgMsKIaWG9ArgrNs3pwg6mAy+eo33DdREN6
AcFODJG+TVEGZwKCAQEA0fU8MbiQ1HtAYju36WPQYP4b8GT1RmaSsiRJRwexHD/q
HbFZ43CVWni03G4YU4fNjzUUg3gfJgV+ho6fdMJvTneCy0jJEQnjlo/RN4N9nbgL
fyrxhtsvfKus9NyxA3YzaPNc8b87f7c4kXgKCKJfiUyWGye0jBkK/1U1Se0R4eko
816ZktIPWjrJGItlsrv/XnhQVDjhvAIjvVnVfIvEtpbie1zOxTFtRm+DkZwkRBmn
2+0SjWjy3Wmi5GVne8LslS9nRk5uOw2//nXB/1rqWGNaiwW5PPeprv416OVxQGY4
MKbEp3H2CeTPW9MWaX4zCfI0d1TANXdGGNDjJ0w+zQKCAQAWRyN/725qnMrXssGO
r3+yPuYw/3Emm2549fMWKsOZarsmZVzhGvpHNixZnkkRjb5Jryvx2OqYvqLDF8LB
Lp4zu+tF9FZVkP9AK7OVUm8dAxNxTRFE+3nhr5WFEJBS4vzD+6LSvQIlNOFnw9G4
ih4Z8ASuMjPij66CCwHANxdubImauGAPj+48ZRKy8KmWDMgJeUay1kii61OpOHs9
AvImp92wIdl9kj7KJ1nDvF5TEZpIEzez6rSbtq/hoDfUPUVZXNtW2OgeHQimv7Cl
NURN0lDxdwkpZb5G6qqpUKxkITq+ewLstgL/SKZmQIZd3BZ8y63YNVhViXtKAIB1
W7xrAoIBACZi3cSBaAlRF25eWLuVAi4Zh2YmLynV6xAizjrsTxdJIDaZjEOm+8d0
tixu/IeZFp4OKFf5CEjdTEqxnLmuKsd/1ivwfMJeunt2g9vQcJD7ay6u3UW4oS/7
+z0xdDOb0r5ncCDKw8gP6Ym/FqaJeUoS9Ia2da3/jiEspPeFHnXdOR11uEI9oYzv
noCcS9WnxYKyII2lcK/8/CfMWtnQfACeCX5gc3VeGgp8rONcVs0Um7n72B5+h5iv
jZaQa6EW/EYySoFyKXkmhGmzgep75siOMamlPD7HUQCrfZO9Dz2k8rUCaIXssf0R
FlBFgZ178zzoSWtf1VxxGCHFXyJ6MCkCggEAPbaIna3rDPMQbw34Zhd1LrUvtiRG
G1kARdR/LTA+j8wXSI4ONlhHT50g3sxyIW1UcPTr4tlJRECQ97XaY5HPP+FovWVV
joOnFgVn9hklo8EdUbc2jFFtPZOwqikfocxf8H4XfR58Ak7/lKzwegwM7MciF5r7
19WVi/QQZa2gRTFrn78eElUnAu33AlW2SHELwg4Lh2zbJK2wYIfT3c7GB4qhFLch
+MF7GOzLmkBYb23IE+S7ddciH0OnAOLMHxxFUx+DdQ2hPobj/Y0oqUdEl5wqLvZ+
4TUdzCay4b1hBoXKD1dVv1VUtt1XGB8AZYOdVkbInDDWJuuJLgY93Qaeqg==
-----END RSA PRIVATE KEY-----

22
examples/sucks.py Normal file
View file

@ -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()