Hopefully adding support for other countries.
This commit is contained in:
@@ -21,4 +21,5 @@ requests-mock = ">=1.3"
|
||||
sleekxmpp = ">=1.3"
|
||||
click = ">=6"
|
||||
requests = ">=2.18"
|
||||
pycryptodome = ">=3.4"
|
||||
pycryptodome = ">=3.4"
|
||||
"pycountry-convert" = ">=0.5"
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# Developer Attacks Robot! (adventures in reverse engineering)
|
||||
|
||||
## outline
|
||||
|
||||
- Introduction -
|
||||
- I'm writing this up because these skills aren't just useful for dealing with robots; they're a good way to figure out existing systems debugging legacy code.
|
||||
- I'm doing this all with Linux, but this all can be done
|
||||
- snoop the traffic - let's set up as an access point and just use Wireshark to see what it's doing.
|
||||
- close everything, put it in battery-saving mode
|
||||
- DNS! Fine!
|
||||
- HTTPS! Urgh.
|
||||
- And what's this stuff?
|
||||
- XMPP
|
||||
- what's this protocol
|
||||
- it's controlling the vacuum, so let's dig in
|
||||
- what can I use? aha, mitmproxy
|
||||
- wow, that's a bunch of junk!
|
||||
- boil it down some
|
||||
- boil it down some more
|
||||
- start hacking
|
||||
- simple XMPP script
|
||||
- what's this auth stuff
|
||||
- an RFC side trip
|
||||
- send some commands
|
||||
- oh my god, it works!
|
||||
- auth troubles
|
||||
- now it doesn't work, what happened?
|
||||
- the android app is breaking things
|
||||
- now we have to look at the HTTPS stuff
|
||||
- mitmproxy to the rescue
|
||||
- HTTPS
|
||||
- 6 calls in 2 sets but hey, there in #4 is a token that works
|
||||
- Let's make our hack script work for the second set
|
||||
- it works! But what about this garbage?
|
||||
- We can't figure it out from the protocol alone. To the code!
|
||||
- APK decompliation
|
||||
- Let's search for key strings
|
||||
- Well, that's some strings. Where are the rest?
|
||||
- Ok, more strings. Let's try implementing something.
|
||||
- Can we send it with no signature? Nope.
|
||||
- Can we send it with a fake signature? Nope.
|
||||
- Gotta do it for real. Let's bring it!
|
||||
- Hack, fail, hack, fail. (Scary! Low feedback!) Hack, succeed!
|
||||
- Wrapping up
|
||||
- Let's make a tidy final script (and share it!)
|
||||
- What did we learn?
|
||||
|
||||
## tl;dr
|
||||
|
||||
I bought a robot vacuum. The control app didn't do quite what I wanted. Here I
|
||||
explain in too much detail how I figured out how the app was talking to the robot
|
||||
so that I could control it from my own code. Tools used include Wireshark,
|
||||
mitmproxy, xmppproxy, adb, and a Java decompiler.
|
||||
|
||||
## introduction
|
||||
|
||||
After many years as a sysadmin and developer, after thousands of hours spent
|
||||
running down problems, this is basically how I feel about other people's code:
|
||||
|
||||
> I don't know who you are. I don't know what you want. If you are looking for
|
||||
> ransom I can tell you I don't have money, but what I do have are a very
|
||||
> particular set of skills. Skills I have acquired over a very long career.
|
||||
> Skills that make me a nightmare for people like you.
|
||||
> -- [Liam Neeson, Taken](http://www.esquire.com/entertainment/movies/a31775/taken-speech/)
|
||||
|
||||
<!-- TODO: insert Taken photo -->
|
||||
|
||||
Recently, I bought a robot vacuum, the [Wirecutter-recommended](https://thewirecutter.com/reviews/best-robot-vacuum/)
|
||||
[EcoVacs Deebot N79](https://www.ecovacs.com/us/deebot-robotic-vacuum-cleaner/DEEBOT-N79/).
|
||||
It's honestly a pretty swell piece of hardware. Watching it go, it's clear that
|
||||
it's the product not just of smarts, but of a lot of thoughtful experimentation.
|
||||
Compared with the early Roomba I had, it feels *evolved*.
|
||||
|
||||
But the Android app is... not as good. In particular, you can have it start
|
||||
cleaning at a certain time. However, it only stops cleaning when it runs out
|
||||
of battery, or circa 90 minutes. My place is small; I need maybe 10 minutes
|
||||
of daily cleaning. That bothered me. It's a mechanical device, and its brushes
|
||||
and filters are consumables. It's not expensive, but having it waste 8/9ths
|
||||
of its lifespan cleaning already-clean floors bugged me. I also didn't need
|
||||
the extra noise and hassle.
|
||||
|
||||
So first, to Google! Did anybody already fix this? Nope. Is there a public API?
|
||||
Nope. Have other people looked into this? Turns out yes! There was a forum
|
||||
post with a little discussion and some sample code. Which was interesting,
|
||||
but not enough to build from. How to I find out how to control my property?
|
||||
|
||||
Here follows my answer to that. It's a cleaned-up version of my explorations,
|
||||
written to share some of my tricks with others, and help people get more
|
||||
comfortable figuring out what the hell is going on with their tech. My
|
||||
target audience is people who code at least enough to be dangerous. By the
|
||||
end, I hope you'll be more dangerous.
|
||||
|
||||
|
||||
## you say you wanna snoop
|
||||
|
||||
The first quesiton to answer: what the hell is that app doing?: Modern phones are,
|
||||
for good reason, basically sealed boxes. I could root my phone so that I could do
|
||||
more effective debugging from there. But since I mainly care about what it's doing
|
||||
over the network, I'm going to start with snooping the network traffic.
|
||||
|
||||
### Wireshark to the rescue!
|
||||
|
||||
An easy way to capture all that traffic is to make your laptop a wifi router.
|
||||
I"m using an Ubuntu laptop, so I needed to plug into an Ethernet network and then
|
||||
make a few [simple networking changes](https://askubuntu.com/questions/180733/how-to-setup-an-access-point-mode-wi-fi-hotspot).
|
||||
(Other OSes have similar functionality, so you should be able to play along.)
|
||||
Once I got that working, I connected my phone to the new wifi access point.
|
||||
|
||||
The next step was to make things clean and simple. Complexity is the enemy of
|
||||
any good debugging effort. I put my phone in Battery Saver mode, which
|
||||
minimizes background activity. I force-stopped the EcoVacs app and cleared
|
||||
out its data, so we were starting fresh. Then I rebooted my phone, turned
|
||||
Battery Saver back on, and let it sit for a couple minutes so its startup
|
||||
activity was hopefully complete.
|
||||
|
||||
<!--TODO: Screnshots of battery saver, etc-->
|
||||
|
||||
I also checked out the network settings to see what my phone's
|
||||
IP was. That will become important once we have some packets captured.
|
||||
|
||||
That ready, I started up one of my favorite tools, Wireshark. This is basicially
|
||||
x-ray vision for networks, and it is fucking magic. It has a zillion options
|
||||
and tools and protocol decoders, most of which we will studiously ignore. I
|
||||
told it to start capturing my wireless interface. It shows you what's happening
|
||||
in real time, so I saw various traffic ticking by, including a bunch of Google
|
||||
stuff. That seemed good, and it was my phone's IP address, so I was capturing
|
||||
the right activity.
|
||||
|
||||
Now to start up the app! I jotted down the exact time in my notes and started
|
||||
up the EcoVacs app. I noted the time I hit the login button, and watched it
|
||||
churn for a bit as it found my vacuum and got connected. I noted the time again
|
||||
when I told the vaccum to start cleaning. After a bit, I told it to go back to
|
||||
its charger. When all was quiet again, I stopped the capture.
|
||||
|
||||
[SIDEBAR: In this article, I'm making it sound like I did things once. The
|
||||
truth is that I did most of these steps many times, separated by periods of
|
||||
confusion and swearing. You're getting the edited version here, because the
|
||||
truth would be deadly dull. But as you go through this, don't be discouraged
|
||||
if you have to try something again and again because the results were messy
|
||||
or puzzling.]
|
||||
|
||||
### what's all this garbage?
|
||||
|
||||
If you're playing along, you now have a screen with a lot of technical-looking
|
||||
stuff, done up in the [angry fruit salad](http://catb.org/jargon/html/A/angry-fruit-salad.html)
|
||||
school of user interface design. Scrolling, there are thousands of entries,
|
||||
each more arcane than the last. What is it?
|
||||
|
||||
Well, honestly, for the moment I don't care , and you shouldn't either. There's 30 years of
|
||||
layered technical history that is undeniably cool and interesting. Your
|
||||
phone does a lot! Other things on your network are also doing a lot! Later
|
||||
on you can come back, pick a random packet, and try to figure out what it's
|
||||
doing. But for now, you should treat this like you're an easily distractable
|
||||
person shopping at a megastore. Don't look at everything; just find what you
|
||||
need and get the hell out.
|
||||
|
||||
A great place to start is DNS. That is the protocol where domain names get
|
||||
turned into internet addresses, a necessary step before any connection is made
|
||||
Since DNS mostly runs over UDP port 53, I type in a filter at the top of the
|
||||
window: "udp.port==53". (How did I know it was 53? Experience. But you can
|
||||
check out a [short list](http://www.pearsonitcertification.com/articles/article.aspx?p=1868080)
|
||||
of common port numbers or Wikipedia's [more comprehensive list](https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers).)
|
||||
Now I have a fair bit less garbage. I see queries
|
||||
about things I know are on my phone: Wikipedia, Lastpass, Mint, and of course
|
||||
Google.
|
||||
|
||||
As I get down to the timestamp for when I started the app, I see a familiar
|
||||
name: ecovacs.com. <!-- TODO: insert screnshot 500 -->
|
||||
That's a good sign! Now I'm in the right place. I set the this a a timestamp
|
||||
reference. <!-- TODO: insert screnshot 502 --> Now the times are relative
|
||||
to when the app got going, making it easier for me to keep track of things.
|
||||
<!-- TODO: insert screnshot 503 --> Scrolling down, I see it look up some
|
||||
related domain names: eco-us-api.ecovacs.com, users-na.ecouser.net,
|
||||
ne-na.ecouser.net, msg-na.ecouser.net, and a few others. I keep those in
|
||||
mind for future investigation.
|
||||
|
||||
Now that I'm in the right ballpark, I clear the filter and start looking
|
||||
at the raw traffic. What does it do right after it looks up the eco-us-api
|
||||
name? It connects to it! Yay! There are a whole bunch of packets going back
|
||||
and forth between my phone and that server. If I clieck on the first, Wireshark
|
||||
even shows in the left edge that all those packets are related. <!-- TODO: insert screnshot 505 -->
|
||||
|
||||
However, the fact that it's connecting to port 443 is a problem. That's the
|
||||
port for HTTPS. The S meaning Secure, meaning encrypted, meaning naughty
|
||||
snoopers like us can't see anything useful. There's a solution for that, but
|
||||
let's keep looking and see what else we have.
|
||||
|
||||
Further down, there's a connection on port 5223. <!-- TODO: insert screnshot 508 -->
|
||||
That's unusual, and Wireshark
|
||||
just calls the protocol TCP, meaning it's not sure what to make of it. What
|
||||
is it? To find out, I bring up the menu for one of the packets, pick "Follow",
|
||||
and then "TCP Stream". This pulls all the packets together for connection,
|
||||
extracts the payload as texts, and then displays the client as red and the
|
||||
server as blue.
|
||||
|
||||
<!-- TODO: insert screnshot 509 -->
|
||||
|
||||
This is one of my favorite Wireshark tools. Expecially for plain HTTP and other
|
||||
non-encrypted protocols, it's great for seeing what's really going on. Here,
|
||||
we see some XML at the top and then it decends into mostly garbage. Let's look
|
||||
at the XML.
|
||||
|
||||
<!-- TODO: format and colorize -->
|
||||
|
||||
```
|
||||
<stream:stream to="ecouser.net" xmlns="jabber:client" xmlns:stream="http://etherx.jabber.org/streams" version="1.0">
|
||||
|
||||
<stream:stream xmlns:stream="http://etherx.jabber.org/streams" xmlns="jabber:client" version="1.0" id="a85619c3ea44f395f651faf1a5996ee6" from="ecouser.net">
|
||||
|
||||
<stream:features>
|
||||
<auth xmlns="http://jabber.org/features/iq-auth"/>
|
||||
<starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls">
|
||||
<required/>
|
||||
</starttls>
|
||||
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
|
||||
<mechanism>PLAIN</mechanism>
|
||||
</mechanisms>
|
||||
</stream:features>
|
||||
|
||||
<starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"/>
|
||||
|
||||
<proceed xmlns="urn:ietf:params:xml:ns:xmpp-tls"/>
|
||||
```
|
||||
|
||||
I don't know much about this, but there's an obvious hint. So I look at
|
||||
jabber.org, and it mentions XMPP. I hazily recall Jabber as an instant
|
||||
messaging tool, and XMPP is the protocol. That seems weird, but the
|
||||
[XMPP website](https://xmpp.org/) mentions that a common use case is
|
||||
Internet of Things. That makes some sense. If you have a lot of things,
|
||||
like my phone, wanting to talk occasionally to other things, like my robot vacuum,
|
||||
IM is a well-tested approach.
|
||||
|
||||
The TLS thing, though, is a bit of a problem. When it says starttls, it's
|
||||
upgrading from the insecure connection, which we can read, to a secure
|
||||
connection, which we can't. (For more, see Wikipedia's [Opportunistic TLS](https://en.wikipedia.org/wiki/Opportunistic_TLS)
|
||||
article.) But there's some good news. As I scroll down in the Wireshark
|
||||
extract, I see this bit:
|
||||
|
||||
```
|
||||
AU1.0...U...
|
||||
Some-State1!0...U.
|
||||
..Internet Widgits Pty Ltd
|
||||
```
|
||||
|
||||
AU? Some State? Interenet Widgits? I recognize those as default values
|
||||
for a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate).
|
||||
That means the app is maybe not so serious about security here. So this seems
|
||||
like a good place to break in.
|
||||
|
||||
## robot jabber
|
||||
|
||||
I do a little more reading on [XMPP](https://en.wikipedia.org/wiki/XMPP).
|
||||
It's a proper internet standard and everything, so I flip through [RFC 6120](https://tools.ietf.org/html/rfc6120),
|
||||
[RFC 6121](https://tools.ietf.org/html/rfc6121), and [RFC 6122](https://tools.ietf.org/html/rfc6122),
|
||||
the standards documents that describe it. It feels like (and is) something
|
||||
from the late 90s, when XML was all the rage.
|
||||
|
||||
I rummage around a bit, an a search for "xmpp mitm" (that being Man In The Middle),
|
||||
I find [xmpppeek](https://www.beneaththewaves.net/Software/XMPPPeek.html), an
|
||||
XMPP proxy. I love this kind of thing: somebody made it for their own needs,
|
||||
found it useful, and put it up on the Internet just in case.
|
||||
|
||||
### looking inside the XMPP
|
||||
|
||||
I install xmpppeek. But how to get the app to talk to my server instead of
|
||||
the real one? Lies. In particular, my router lets me set up hostnames. That's
|
||||
probably so I can easily set up a home server, but here, I'll abuse it. I tell
|
||||
it to lie about the address for [TODO: server name] and return my laptop address
|
||||
instead. Then I start up xmpppeek and tell it to forward to the real EcoVacs
|
||||
server. It complains, saying it needs its own encryption certificate. I can
|
||||
never remember the incantation for that, but the Internet does, so shortly it's
|
||||
up and running. Will it work?
|
||||
|
||||
I start up the Ecovacs app and shit starts scrolling on my laptop screen:
|
||||
|
||||
<!-- Insert screenshot 510 -->
|
||||
|
||||
I don't know what half of this means, but we can definitely see farther than
|
||||
before into the XMPP transaction, so this looks like a step forward. And this
|
||||
bit is particularly interesting:
|
||||
|
||||
```
|
||||
[2017-11-15 14:49:48.016593-0MjAxNzEwMjU1OWYwZWU2M2M1ODhkADIwMTcxMDI1NTlmMGVlNjNjNTg4ZAAwLzEzYjQxMjk5L2V5aDlXZ0NOOVFkWGxEZkNDY1I1cVBBMUtLSnZ4eWts7:00] [(S2C) 47.88.66.164:5223 -> 192.168.1.227:48607] }}}<stream:features><auth xmlns="http://jabber.org/features/iq-auth"/><mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl"><mechanism>PLAIN</mechanism></mechanisms></stream:features>{{{
|
||||
[2017-11-15 14:49:48.255088-07:00] [(C2S) 192.168.1.227:48607 -> 47.88.66.164:5223] }}}<auth mechanism="PLAIN" xmlns="urn:ietf:params:xml:ns:xmpp-sasl">MjAxNzEwMjU1OWYwZWU2M2M1ODhkADIwMTcxMDI1NTlmMGVlNjNjNTg4ZAAwL2VmY2Y5M2U2L2dyOWhDZkc0Q3NmeVU3QlRQeVRDNkJaWVgxQjFVazhE</auth>{{{
|
||||
[2017-11-15 14:49:48.444622-07:00] [(S2C) 47.88.66.164:5223 -> 192.168.1.227:48607] }}}<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl"/>{{{
|
||||
```
|
||||
|
||||
The server says, "for authenticating, I like the PLAIN method". The client says,
|
||||
"cool, here's a lump of PLAIN auth". The server says, "success". And then we say,
|
||||
in our best hacker voices, "I'm in!" If we can authenticate with something
|
||||
straightforward, maybe we're good to go.
|
||||
|
||||
Fishing further, I see something that looks like a username "2017102559f0ee63c588d@ecouser.net". Maybe that will come
|
||||
in handy.There are also
|
||||
nice bits of XML like `<ctl td="Clean"><clean type="auto" speed="standard"/></ctl>`
|
||||
when I hit the auto-clean button in the app, and `<ctl td="Clean"><clean type="stop" speed="standard"/></ctl>`
|
||||
when I have it stop. Once we're connected, bossing the robot around should be easy.
|
||||
So how do we get connected?
|
||||
|
||||
## ruby slippers
|
||||
|
||||
For things like this, I like Ruby and Python. The languages are easy to get
|
||||
started with, forgiving, and they have a ton of libraries. For this, let's try
|
||||
Ruby. I quickly find an [XMPP client library](http://xmpp4r.github.io/) that
|
||||
isn't too stale and even a
|
||||
[simple how-to article](https://www.sitepoint.com/looking-xmpp-xmpp4r-gem/).
|
||||
So let's see if we can build something that at least talks to the server.
|
||||
|
||||
I use bundler to install the xmpp4r gem and then write the dumbest possible
|
||||
program:
|
||||
|
||||
```
|
||||
require 'xmpp4r'
|
||||
require 'xmpp4r/client'
|
||||
|
||||
include Jabber
|
||||
|
||||
|
||||
Jabber.debug = true
|
||||
|
||||
jid = JID.new('2017102559f0ee63c588d@ecouser.net')
|
||||
client = Client.new(jid)
|
||||
client.connect
|
||||
```
|
||||
|
||||
I run that and it works! It connects. It doesn't do anything, but I'm on
|
||||
the right trail. Can we log in just using the magic string from the XML
|
||||
above? I add this:
|
||||
|
||||
```
|
||||
client.auth('MjAxNzEwMjU1OWYwZWU2M2M1ODhkADIwMTcxMDI1NTlmMGVlNjNjNTg4ZAAwL2VmY2Y5M2U2L2dyOWhDZkc0Q3NmeVU3QlRQeVRDNkJaWVgxQjFVazhE')
|
||||
|
||||
```
|
||||
|
||||
Does it work? No. In the debugging output, it sends a different, longer
|
||||
string than the one we gave it. Now, alas, we have to actually learn
|
||||
something about how this works. Luckily, XML is an implausibly verbose
|
||||
protocol, and looking at the auth exchange there are valuable clues like
|
||||
SASL and PLAIN. Going back to the [XMPP spec](https://tools.ietf.org/html/rfc6120#section-6)
|
||||
there is a whole section on SASL, and SASL even has an [RFC of its own](https://tools.ietf.org/html/rfc4422).
|
||||
The XMPP RFC tells us that data gets encoded as base64. So let's try
|
||||
decoding that.
|
||||
|
||||
```
|
||||
$ base64 -d
|
||||
MjAxNzEwMjU1OWYwZWU2M2M1ODhkADIwMTcxMDI1NTlmMGVlNjNjNTg4ZAAwL2VmY2Y5M2U2L2dyOWhDZkc0Q3NmeVU3QlRQeVRDNkJaWVgxQjFVazhE
|
||||
2017102559f0ee63c588d2017102559f0ee63c588d0/efcf93e6/gr9hCfG4CsfyU7BTPyTC6BZYX1B1Uk8D
|
||||
```
|
||||
I give it the first line; it gives me the second. The first part of that
|
||||
looks like the username above, but then it trails off into nonsense.
|
||||
What's the rest? More rummaging leads me to [RFC 4616](https://tools.ietf.org/html/rfc4616),
|
||||
which is specifically about SASL PLAIN. There it explains the format:
|
||||
|
||||
```
|
||||
message = [authzid] UTF8NUL authcid UTF8NUL passwd
|
||||
authcid = 1*SAFE ; MUST accept up to 255 octets
|
||||
authzid = 1*SAFE ; MUST accept up to 255 octets
|
||||
passwd = 1*SAFE ; MUST accept up to 255 octets
|
||||
UTF8NUL = %x00 ; UTF-8 encoded NUL character
|
||||
```
|
||||
|
||||
That's somewhat helpful. A password is somethign I understand. The docs say
|
||||
that the two ids are the *authentication id* and the *authorization id*. I
|
||||
don't know what the difference is, but I have learned that there is probably
|
||||
a NUL character, which doesn't show up on my screen.
|
||||
|
||||
To see it, I whip out one of my favorite tools: od. This is an old-school
|
||||
Unix utility, short for "octal dump", because octal numbers were used heavily
|
||||
back in the day. (Now generally we use hex.) But it does a ton more than that.
|
||||
In particular, its `-c` option will dump things as characters when it can, and
|
||||
then show us the hidden things. So now I try again:
|
||||
|
||||
```
|
||||
$ base64 -d | od -c
|
||||
MjAxNzEwMjU1OWYwZWU2M2M1ODhkADIwMTcxMDI1NTlmMGVlNjNjNTg4ZAAwL2VmY2Y5M2U2L2dyOWhDZkc0Q3NmeVU3QlRQeVRDNkJaWVgxQjFVazhE
|
||||
0000000 2 0 1 7 1 0 2 5 5 9 f 0 e e 6 3
|
||||
0000020 c 5 8 8 d \0 2 0 1 7 1 0 2 5 5 9
|
||||
0000040 f 0 e e 6 3 c 5 8 8 d \0 0 / e f
|
||||
0000060 c f 9 3 e 6 / g r 9 h C f G 4 C
|
||||
0000100 s f y U 7 B T P y T C 6 B Z Y X
|
||||
0000120 1 B 1 U k 8 D
|
||||
0000127
|
||||
```
|
||||
|
||||
This is much better. The NUL character shows up as \0 here. So we can see
|
||||
that the `authcid` is our username. And the `authzid` is too, so I guess we
|
||||
don't have to understand the difference. And the rest must be the password.
|
||||
Let's try authenticating with that.
|
||||
|
||||
And no! It doesn't work. But why? Looking at the debugging output from the
|
||||
script, I try feeding that into od -c as well. And I discover that it is
|
||||
sending the @ecouser.net as part of the username. That's no good! Maybe
|
||||
the username's just the first part.
|
||||
|
||||
|
||||
SOMETHING ELSE IS FUCKED UP ABOUT THIS LIBRARY. I taught it to do EcoVacs
|
||||
style auth and it is definitely not working. So there's something else in the
|
||||
flow that isn't right. But what?
|
||||
@@ -12,6 +12,7 @@ from threading import Event
|
||||
|
||||
import click
|
||||
import requests
|
||||
from pycountry_convert import country_alpha2_to_continent_code
|
||||
from sleekxmpp import ClientXMPP, Callback, MatchXPath
|
||||
from sleekxmpp.xmlstream import ET
|
||||
|
||||
@@ -21,12 +22,12 @@ class EcoVacsAPI:
|
||||
SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC"
|
||||
PUBLIC_KEY = 'MIIB/TCCAWYCCQDJ7TMYJFzqYDANBgkqhkiG9w0BAQUFADBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMCAXDTE3MDUwOTA1MTkxMFoYDzIxMTcwNDE1MDUxOTEwWjBCMQswCQYDVQQGEwJjbjEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDb8V0OYUGP3Fs63E1gJzJh+7iqeymjFUKJUqSD60nhWReZ+Fg3tZvKKqgNcgl7EGXp1yNifJKUNC/SedFG1IJRh5hBeDMGq0m0RQYDpf9l0umqYURpJ5fmfvH/gjfHe3Eg/NTLm7QEa0a0Il2t3Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBANhIMT0+IyJa9SU8AEyaWZZmT2KEYrjakuadOvlkn3vFdhpvNpnnXiL+cyWy2oU1Q9MAdCTiOPfXmAQt8zIvP2JC8j6yRTcxJCvBwORDyv/uBtXFxBPEC6MDfzU2gKAaHeeJUWrzRv34qFSaYkYta8canK+PSInylQTjJK9VqmjQ'
|
||||
MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}'
|
||||
USER_URL = 'https://users-na.ecouser.net:8000/user.do'
|
||||
USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do'
|
||||
REALM = 'ecouser.net'
|
||||
|
||||
def __init__(self, device_id, account_id, password_hash):
|
||||
def __init__(self, device_id, account_id, password_hash, country, continent):
|
||||
self.meta = {
|
||||
'country': 'us',
|
||||
'country': country,
|
||||
'lang': 'en',
|
||||
'deviceId': device_id,
|
||||
'appCode': 'i_eco_e',
|
||||
@@ -36,6 +37,8 @@ class EcoVacsAPI:
|
||||
}
|
||||
logging.debug("Setting up EcoVacsAPI")
|
||||
self.resource = device_id[0:8]
|
||||
self.country = country
|
||||
self.continent = continent
|
||||
login_info = self.__call_main_api('user/login',
|
||||
('account', self.encrypt(account_id)),
|
||||
('password', self.encrypt(password_hash)))
|
||||
@@ -83,7 +86,7 @@ class EcoVacsAPI:
|
||||
logging.debug("calling user api {} with {}".format(function, args))
|
||||
params = {'todo': function}
|
||||
params.update(args)
|
||||
response = requests.post(EcoVacsAPI.USER_URL, json=params)
|
||||
response = requests.post(EcoVacsAPI.USER_URL_FORMAT.format(continent=self.continent), json=params)
|
||||
json = response.json()
|
||||
logging.debug("got {}".format(json))
|
||||
if json['result'] == 'ok':
|
||||
@@ -130,13 +133,14 @@ class EcoVacsAPI:
|
||||
|
||||
|
||||
class VacBot(ClientXMPP):
|
||||
def __init__(self, user, domain, resource, secret, vacuum):
|
||||
def __init__(self, user, domain, resource, secret, vacuum, continent):
|
||||
ClientXMPP.__init__(self, user + '@' + domain, '0/' + resource + '/' + secret)
|
||||
|
||||
self.user = user
|
||||
self.domain = domain
|
||||
self.resource = resource
|
||||
self.vacuum = vacuum
|
||||
self.continent = continent
|
||||
self.credentials['authzid'] = user
|
||||
self.add_event_handler("session_start", self.session_start)
|
||||
|
||||
@@ -190,7 +194,8 @@ class VacBot(ClientXMPP):
|
||||
c.send()
|
||||
|
||||
def wrap_command(self, ctl):
|
||||
q = self.make_iq_query(xmlns=u'com:ctl', ito=self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom',
|
||||
q = self.make_iq_query(xmlns=u'com:ctl',
|
||||
ito=self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom',
|
||||
ifrom=self.user + '@' + self.domain + '/' + self.resource)
|
||||
q['type'] = 'set'
|
||||
for child in q.xml:
|
||||
@@ -199,7 +204,7 @@ class VacBot(ClientXMPP):
|
||||
return q
|
||||
|
||||
def connect_and_wait_until_ready(self):
|
||||
self.connect(('47.88.66.164', '5223')) # TODO: change to domain name
|
||||
self.connect(('msg-{}.ecouser.net'.format(self.continent), '5223'))
|
||||
self.process()
|
||||
self.wait_until_ready()
|
||||
|
||||
@@ -317,6 +322,17 @@ def write_config(config):
|
||||
fp.write(key + '=' + config[key] + "\n")
|
||||
|
||||
|
||||
def current_country():
|
||||
try:
|
||||
return requests.get('http://ipinfo.io/json').json()['country'].lower()
|
||||
except:
|
||||
return 'us'
|
||||
|
||||
|
||||
def continent_for_country(country_code):
|
||||
return country_alpha2_to_continent_code(country_code.upper()).lower()
|
||||
|
||||
|
||||
def should_run(frequency):
|
||||
if frequency is None:
|
||||
return True
|
||||
@@ -337,7 +353,10 @@ def cli(charge, debug):
|
||||
@cli.command(help='logs in with specified email; run this first')
|
||||
@click.option('--email', prompt='Ecovacs app email')
|
||||
@click.option('--password', prompt='Ecovacs app password', hide_input=True)
|
||||
def login(email, password):
|
||||
@click.option('--country-code', prompt='your two-letter country code', default=lambda: current_country())
|
||||
@click.option('--continent-code', prompt='your two-letter continent code',
|
||||
default=lambda: continent_for_country(click.get_current_context().params['country_code']))
|
||||
def login(email, password, country_code, continent_code):
|
||||
if config_file_exists() and not click.confirm('overwrite existing config?'):
|
||||
click.echo("Skipping login.")
|
||||
exit(0)
|
||||
@@ -352,6 +371,8 @@ def login(email, password):
|
||||
config['email'] = email
|
||||
config['password_hash'] = password_hash
|
||||
config['device_id'] = device_id
|
||||
config['country'] = country_code.lower()
|
||||
config['continent'] = continent_code.lower()
|
||||
write_config(config)
|
||||
click.echo("Config saved.")
|
||||
exit(0)
|
||||
@@ -395,9 +416,10 @@ def run(actions, charge, debug):
|
||||
|
||||
if actions:
|
||||
config = read_config()
|
||||
api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'])
|
||||
api = EcoVacsAPI(config['device_id'], config['email'], config['password_hash'],
|
||||
config['country'], config['continent'])
|
||||
vacuum = api.devices()[0]
|
||||
vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum)
|
||||
vacbot = VacBot(api.uid, api.REALM, api.resource, api.user_access_token, vacuum, config['continent'])
|
||||
vacbot.connect_and_wait_until_ready()
|
||||
|
||||
for action in actions:
|
||||
|
||||
+9
-6
@@ -6,7 +6,7 @@ from nose.tools import *
|
||||
from sucks import *
|
||||
|
||||
|
||||
# There are no tests for the XMPP stuff here because a) it's relatively complicated to test given
|
||||
# There are few tests for the XMPP stuff here because a) it's relatively complicated to test given
|
||||
# the library's design and its multithreaded nature, and b) I'm manually testing every change anyhow,
|
||||
# as it's not clear how the robot really behaves.
|
||||
|
||||
@@ -74,10 +74,13 @@ def test_should_run():
|
||||
count += 1
|
||||
assert_almost_equal(count, 9000, delta=200)
|
||||
|
||||
def test_continent_for_country():
|
||||
assert_equal(continent_for_country('us'), 'na')
|
||||
assert_equal(continent_for_country('fr'), 'eu')
|
||||
|
||||
def test_wrap_command():
|
||||
v = VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12',
|
||||
{"did": "E0000000001234567890", "class": "126", "nick": "bob"})
|
||||
{"did": "E0000000001234567890", "class": "126", "nick": "bob"}, 'na')
|
||||
c = str(v.wrap_command(Clean(1).to_xml()))
|
||||
assert_true(re.search(r'from="20170101abcdefabcdefa@ecouser.net/abcdef12"', c))
|
||||
assert_true(re.search(r'to="E0000000001234567890@126.ecorobot.net/atom"', c))
|
||||
@@ -85,10 +88,10 @@ def test_wrap_command():
|
||||
|
||||
def test_model_variation():
|
||||
v = VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12',
|
||||
{"did": "E0000000001234567890", "class": "141", "nick": "bob"})
|
||||
{"did": "E0000000001234567890", "class": "141", "nick": "bob"}, 'na')
|
||||
c = str(v.wrap_command(Clean(1).to_xml()))
|
||||
assert_true(re.search(r'to="E0000000001234567890@141.ecorobot.net/atom"', c))
|
||||
b
|
||||
|
||||
|
||||
def test_main_api_called():
|
||||
with requests_mock.mock() as m:
|
||||
@@ -98,7 +101,7 @@ def test_main_api_called():
|
||||
text='{"time": 1511200804607, "data": {"authCode": "5c28dac1ff580210e11292df57e87bef"}, "code": "0000", "msg": "X"}')
|
||||
r3 = m.post(re.compile('user.do'),
|
||||
text='{"todo": "result", "token": "jt5O7oDR3gPHdVKCeb8Czx8xw8mDXM6s", "result": "ok", "userId": "2017102559f0ee63c588d", "resource": "f8d99c4d"}')
|
||||
api = EcoVacsAPI("long_device_id", "account_id", "password_hash")
|
||||
api = EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na')
|
||||
assert_equals(r1.call_count, 1)
|
||||
assert_equals(r2.call_count, 1)
|
||||
assert_equals(r3.call_count, 1)
|
||||
@@ -127,4 +130,4 @@ def make_api():
|
||||
text='{"time": 1511200804607, "data": {"authCode": "abcdef01234567890abcdef012345678"}, "code": "0000", "msg": "X"}')
|
||||
m.post(re.compile('user.do'),
|
||||
text='{"todo": "result", "token": "base64base64base64base64base64ba", "result": "ok", "userId": "20170101abcdefabcdefa", "resource": "abcdef12"}')
|
||||
return EcoVacsAPI("long_device_id", "account_id", "password_hash")
|
||||
return EcoVacsAPI("long_device_id", "account_id", "password_hash", 'us', 'na')
|
||||
|
||||
Reference in New Issue
Block a user