wpietri / sucks

Simple command-line script for the Ecovacs series of robot vacuums
GNU General Public License v3.0
282 stars 104 forks source link

Add map support #78

Open lukakama opened 4 years ago

lukakama commented 4 years ago

I'm trying to add the map image pull support for vacuum having mapping capability such as 900 serie.

Probably the request is more related to bmartin5692 and his fork / pull request #63 .

By far I was able to understand how the map is managed and how it is retrieved.

For management, the full map is splitted in a grid x * y, and the app pull the image for each grid piece at a time.

The protocol used to retrieve it is the following:

  1. The app send the "GetMapM" command.
  2. The command return a list of map metadata such:
    • map id
    • map grid resolution (x * y)
    • list of checksum for each grid piece (crc32 of each grid piece image, probably used to validate images locally cached or currently shown by the app)
    • pixel resolution (of the full map or, more probably, of each grid piece)
  3. The app send a "PullMP" command for each map grid piece, passing the grid location (base 0, still need to understand to which coordinates it refers) on a parameter named "pid", returning the grid piece image as a lzma compressed data and encoded as text attribute with base64.

These are what I was able to get with sucks on my Ozmo 900:

However, this is where I'm currently stuck: the image data ("p" attribute) retrieved with sucks does not seem to be a valid base64 text, as it contains unhallowed "_" characters. Also, it does not contain any uppercase character, which is strange for a text base64 encoded... Moreover, by performing some tests I was able to produce a partial valid lzma data, by converting each character before an underscore to uppercase and removing the underscores, which produce a valid lzma header (first 5 bytes) once decoded into a byte array (respecting also some check performed by the Android app on hard coded values on such first 5 bytes), but the remaining portion of the lzma compressed data results to be corrupted and cannot be decompressed.

I'm worrying if there is something wrong with the retrieval of the command result message, maybe due some configuration to be fine tuned at lower lever, or for the api used to send the command and/or the channel used to retrieve the result message (MQTT vs XMPP), considering that the Android app does not seems to apply any transformation on the base64 text of the result message, which is simply decoded using the standard android Base64 utility class, which I also tested that does not decode correctly the base64 text that I'm able to retrieve using sucks.

Unfortunately, I was not able to setup any tool to trace the data sent and received from the Android app and the vacuum, as latest versions of Android do not allow anymore the usage of a proxy to decrypt https traffic, and the vacuum instead does not seem to use xmpp (or I was unable to setup the xmpp proxy correctly).

Do anyone have some clue or have some hint on how or what to try in order to be able to retrieve a well-formed base64 text for the lzma compressed image data ? What could I check to see what protocol is used by sucks? Is there a way to force the use of MQTT or XMPP by code for specific commands?

Thanks

Ligio commented 4 years ago

Hi Luca! I'm also trying to find a way to get the live clean map for my Ozmo 900. I'm stuck at your same point, but I also discovered an endpoint that can be useful (I hope) to get maps for history with imageUrl property. Yes, this is the image url with the full map! You can call it without any authentication to get a detailed png!

Please, take a look at this "GetCleanLogs" command: https://github.com/Ligio/ozmo/blob/master/ozmo/__init__.py#L1193

The result will be like:

{ 
   "ret":"ok",
   "logs":[ 
      { 
         "ts":1567243978,
         "last":45,
         "area":0,
         "id":"<did>@450854172@<resource>",
         "imageUrl":"https://portal-eu.ecouser.net/api/lg/image/<id>",
         "type":"CustomArea",
         "aiavoid":0,
         "aitypes":[ 

         ]
      },      
      { 
         "ts":1567243793,
         "last":8,
         "area":0,
         "id":"<did>@1742805146@<resource>",
         "imageUrl":"https://portal-eu.ecouser.net/api/lg/image/<id>",
         "type":"CustomArea",
         "aiavoid":0,
         "aitypes":[ 

         ]
      }
}

When I run the Clean command I can see that the deebot is getting some full png image, so I hope the imageUrl can be called also for live maps. Unfortunately, I can't understand how to build the ID since it has 3 parameters:

Hope this help!

P.S: I have an old Android 5 device and I can get some info from HTTPS traffic, but I didn't find anything more at the moment... I'll let you know otherwise.

lukakama commented 4 years ago

Hi, thank you @Ligio , meanwhile I found that the "PullMP" command result, the one used to retrieve the map piece image, is returned directly by the REST API "devmanager.do" and is not received using MQTT or XMPP protocols.

Trying to invoke the REST API manually using Postman, I found that the raw json body contains a well-formed base64 text.

This is the result returned by the rest API for an empty map piece (index 0)

{"ret":"ok","resp":"<ctl ret='ok' i='1839263381' p='XQAABAAQJwAAAABv/f//o7f/Rz5IFXI5YVG4kijmo4YH+e7kHoLTL8U6PAFLsX7Jhrz0KgA='/>","id":"kR0l"}

Using the right base64 text, which in turn produces a well-formed lzma compressed stream, I understood that the map piece binary data is a list of pixels encoded as following:

The resolution of the piece is the one returned in the main map info metadata (n my case 100x100 = 10000 pixels). Also, the map piece grid index seems to be bottom-left based, and the same is for the order of pixels in the map piece data.

So, it seems that the issue about the base64 text is in the sucks' or python's logic when parsing the raw/json REST response. I will update the ticket as soon as I found where it is... really can't understand who it is and why it should alters the text...

lukakama commented 4 years ago

I found the culprit of the malformed base64 text:

The same is done for MQTT and REST communications implementation of the pull request #63:

So, what happens is that sucks convert all non integer properties of a command response to snakecase (ref: https://github.com/okunishinishi/python-stringcase ) and, for unknown reason, the first uppercase character is not prefixed with an underscore.

IMHO it is not so much correct that every text is converted to a snakecase... if it is done to map return message event codes to event handler functions, it should be responsibility of the main handler function to convert just the event code to a snakecase, or if it is done to handle other attributes in other handling functions, it should be responsibility of them to properly sanitize (converting to snakecase or other) needed attributes.

Anyway, I was able to recover the original base64 text decompressing it using the following code:

    def unsnakecase(self, m):
        return m.groups()[0][1].upper()

    def decompress7zBase64Data(self, data):
        # Sucks convert texts in snakecase, without prefixing with "_" the eventually converted first character...
        # So, we need to convert back to uppercase characters prefixed with "_" and, additionally, we also need to 
        # convert to uppercase the first character, in order to get the correct lzma header with first 5 bytes 
        # that, for ecovacs, must be 0x5d, 0x00, 0x00, 0x04, 0x00.
        b64 = re.sub('(_[a-z])', self.unsnakecase, data)
        b64 = b64[0].upper() + b64[1:]

        # Decode Base64
        data = base64.b64decode(b64)

        # Get lzma output size (as done by the Android app)
        len_array = data[5:5+4]
        len_value = ((len_array[0] & 0xFF) | (len_array[1] << 8 & 0xFF00) | (len_array[2] << 16 & 0xFF0000) | (len_array[3] << 24 & 0xFF000000)) 

        # Init the LZMA decompressor using the lzma header
        dec = lzma.LZMADecompressor(lzma.FORMAT_RAW, None, [lzma._decode_filter_properties(lzma.FILTER_LZMA1, data[0:5])])

        # Decompress the lzma stream to get raw data
        return dec.decompress(data[9:], len_value)
bmartin5692 commented 4 years ago

@lukakama - Thanks for this.

I haven't reviewed the code base to see why it was done this way originally, but for my PR and changes I tried to mirror the existing code - hence my fork/PR showing the same issues.

If you have any ideas to address this in the code I am happy to accept a PR on my fork, otherwise when I get some spare cycles I may take a look at this myself.