Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor icon indicating copy to clipboard operation
Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor copied to clipboard

Request for Xiaomi S20+ ( xiaomi.vacuum.b108gl )

Open anton-knoc opened this issue 1 year ago • 26 comments
trafficstars

Checklist

  • [X] I have updated the integration to the latest version available
  • [X] I have checked if the vacuum/platform is already requested
  • [ ] I have sent raw map file to piotr.machowski.dev [at] gmail.com (Retrieving map; please provide your GitHub username in the email)

What vacuum model do you want to be supported?

xiaomi.vacuum.b108gl

What is its name?

Xiaomi S20+ (S20 plus)

Available APIs

  • [ ] xiaomi
  • [ ] viomi
  • [ ] roidmi
  • [ ] dreame

Errors shown in the HA logs (if applicable)

2024-10-29 18:41:27.400 ERROR (MainThread) [homeassistant.helpers.entity] Update for camera.xiaomi_cloud_map_extractor fails
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 942, in async_update_ha_state
    await self.async_device_update()
  File "/usr/src/homeassistant/homeassistant/helpers/entity.py", line 1302, in async_device_update
    await hass.async_add_executor_job(self.update)
  File "/usr/local/lib/python3.12/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/xiaomi_cloud_map_extractor/camera.py", line 278, in update
    self._handle_map_data(map_name)
  File "/config/custom_components/xiaomi_cloud_map_extractor/camera.py", line 335, in _handle_map_data
    map_data, map_stored = self._device.get_map(map_name, self._colors, self._drawables, self._texts,
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/xiaomi_cloud_map_extractor/common/vacuum.py", line 27, in get_map
    response = self.get_raw_map_data(map_name)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/xiaomi_cloud_map_extractor/common/vacuum.py", line 45, in get_raw_map_data
    map_url = self.get_map_url(map_name)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/xiaomi_cloud_map_extractor/common/vacuum_v2.py", line 18, in get_map_url
    if api_response is None or "result" not in api_response or "url" not in api_response["result"]:
                                                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: argument of type 'NoneType' is not iterable

Other info

image Please add this vacuum, if i can help - let me know please Thank you!

anton-knoc avatar Oct 29 '24 16:10 anton-knoc

Judging by the model number (xiaomi.vacuum.b108), I think it's the same integration as https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor/issues/460 (xiaomi.vacuum.b106)

Nicooow avatar Oct 29 '24 21:10 Nicooow

Adding my +1 Thank you

tam481 avatar Nov 10 '24 17:11 tam481

+1 waiting for this model

dankarization avatar Dec 02 '24 16:12 dankarization

+1 here too - just got this popular model and would be awesome to have this working, thanks

0rangutan avatar Dec 09 '24 09:12 0rangutan

Hi @PiotrMachowski , I have reverse engineered MI Home app plugin for S20+ and here is the python code that decrypts downloaded map. It should help with integration of S20+ to your map extractor.

from Crypto.Cipher import AES
from Crypto.Hash import MD5
from Crypto.Util.Padding import pad, unpad
import base64
import json
import zlib
import hashlib

def inflate(byte_array: bytes):
    # Inflate using zlib
    inflated_string = zlib.decompress(byte_array).decode('utf-8')    
    return inflated_string

def loadMapFromFile(file_path: str):
    with open(file_path, 'rb') as f:
        rawMapContent = f.read()
        jsoMapContent = json.loads(rawMapContent)
        return base64_decode(jsoMapContent["data"].encode('latin1'))
    

def encrypt(source: bytes, key: bytes, iv: bytes):
    """
    Encrypts a string using AES encryption in CBC mode.
    """
    cipher = AES.new(key, AES.MODE_CBC, iv)
    encrypted = cipher.encrypt(pad(source, AES.block_size))
    return encrypted.hex().upper()

def decrypt(encrypted_bytes: bytes, key: bytes, iv: bytes):
    """
    Decrypts a string using AES decryption in CBC mode.
    """
    try:
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decrypted = cipher.decrypt(encrypted_bytes)
        decryptedUnpadded = unpad(decrypted, AES.block_size, 'pkcs7')
        return decryptedUnpadded
    except Exception as e:
        return ""


def md5_hash(data: bytes):
    """
    Returns the MD5 hash of the given data.
    """
    return hashlib.md5(data).hexdigest()

def base64Encoding(input):
  dataBase64 = base64.b64encode(input)
  dataBase64P = dataBase64.decode("UTF-8")
  return dataBase64P

def base64_decode(input: bytes):
    """
    Decodes a Base64 string to hexadecimal.
    """
    decoded_bytes = base64.decodebytes(input)
    return decoded_bytes.hex()

def decryptMap(encryptedMapContent: bytes, modelKey: str, did: str):

    originalWork = modelKey + did

    iv = b"ABCDEF1234123412" # iv as a byte array

    encKey = encrypt(originalWork.encode('latin1'), modelKey.encode('latin1'), iv)
    encKey2 = bytes.fromhex(encKey)
    md5Key = md5_hash(encKey2)
    decryptKey = bytes.fromhex(md5Key)

    encryptedBytes = bytes.fromhex(encryptedMapContent)
    decryptedBase64Bytes = decrypt(encryptedBytes, decryptKey, iv)
    inflatedString = inflate(decryptedBase64Bytes)
    
    ## Write decrypted map to file
    #with open("0.decrypted.map.json", "w") as decryptedFile:
    #    # Writing data to a file
    #    decryptedFile.write(inflatedString)

    return inflatedString

def transformMapData(map_data):
    if map_data is None:
        return None

    map_data = json.loads(map_data)

    map_id = map_data.get("map_id")
    map_version = map_data.get("map_type")
    map_height = map_data.get("height")
    map_width = map_data.get("width")
    origin_x = map_data.get("origin_x")
    origin_y = map_data.get("origin_y")
    have_charge_pile = map_data.get("have_pile")
    charge_pile_x = map_data.get("pile_x")
    charge_pile_y = map_data.get("pile_y")
    charge_pile_yaw = map_data.get("pile_yaw")
    map_resolution = map_data.get("resolution")

    # Inflate the base64-encoded map data
    byte_array = zlib.decompress(base64.b64decode(map_data.get("map_data")))

    # Convert to a uint8 array (bytearray in Python)
    data = bytearray(byte_array)
    fb_walls = map_data.get("fb_walls")
    fb_area = map_data.get("fb_regions")
    zone = map_data.get("part_regions")
    rooms = map_data.get("room_attrs")
    room_colors = None

    if isinstance(map_data.get("map_room_info"), list):
        room_colors = {}
        for item in map_data["map_room_info"]:
            room_colors[item["grid_id"]] = item["color"]

    path = map_data.get("paths")

    # Charge details
    charge = {
        "haveChargePile": have_charge_pile,
        "chargePileX": charge_pile_x,
        "chargePileY": charge_pile_y,
        "chargePileYaw": charge_pile_yaw,
    }

    # Origin details
    origin = {
        "x": round(origin_x / 1000, 2) if origin_x is not None else None,
        "y": round(origin_y / 1000, 2) if origin_y is not None else None,
    }

    # Accuracy
    accuracy = round(map_resolution / 1000, 2) if map_resolution is not None else None

    # Header
    header = {
        "mapId": map_id,
        "mapVersion": map_version,
        "mapWidth": map_width,
        "mapHeight": map_height,
        "charge": charge,
        "origin": origin,
        "accuracy": accuracy,
        "roomColors": room_colors,
    }

    # Extra
    extra = {
        "fbWalls": fb_walls,
        "fbArea": fb_area,
        "zone": zone,
        "rooms": rooms,
        "path": path,
    }

    # Final map structure
    map_result = {
        "header": header,
        "data": data,
        "extra": extra,
    }

    return map_result


def main(): 
    modelKey = "mi.vacuum.b108gl"
    did = "1068470163"

    mapContent = loadMapFromFile("0.encrypted.map")
    decryptedMapContent = decryptMap(mapContent, modelKey, did)

    transformedMapData = transformMapData(decryptedMapContent)

    print ('Map content:', transformedMapData)

if __name__ == "__main__":
    main()
    
    ``` 

mdudek avatar Dec 11 '24 19:12 mdudek

@mdudek that is great, thank you for your work! <3 I will check it out when I will have some free time

PiotrMachowski avatar Dec 12 '24 01:12 PiotrMachowski

+1 and subscribed to updates

munaaf avatar Jan 01 '25 14:01 munaaf

+1

Sevii88 avatar Jan 05 '25 19:01 Sevii88

+1

alamakot avatar Jan 05 '25 20:01 alamakot

+1

uNiqu3MK avatar Jan 05 '25 22:01 uNiqu3MK

+1

karot9100 avatar Jan 08 '25 19:01 karot9100

+1

spascanu avatar Jan 12 '25 13:01 spascanu

+1

GoNzCiD avatar Jan 15 '25 12:01 GoNzCiD

+1

typ-turbo avatar Jan 16 '25 16:01 typ-turbo

Hi @PiotrMachowski , I have reverse engineered MI Home app plugin for S20+ and here is the python code that decrypts downloaded map. It should help with integration of S20+ to your map extractor.

from Crypto.Cipher import AES
from Crypto.Hash import MD5
from Crypto.Util.Padding import pad, unpad
import base64
import json
import zlib
import hashlib

def inflate(byte_array: bytes):
    # Inflate using zlib
    inflated_string = zlib.decompress(byte_array).decode('utf-8')    
    return inflated_string

def loadMapFromFile(file_path: str):
    with open(file_path, 'rb') as f:
        rawMapContent = f.read()
        jsoMapContent = json.loads(rawMapContent)
        return base64_decode(jsoMapContent["data"].encode('latin1'))
    

def encrypt(source: bytes, key: bytes, iv: bytes):
    """
    Encrypts a string using AES encryption in CBC mode.
    """
    cipher = AES.new(key, AES.MODE_CBC, iv)
    encrypted = cipher.encrypt(pad(source, AES.block_size))
    return encrypted.hex().upper()

def decrypt(encrypted_bytes: bytes, key: bytes, iv: bytes):
    """
    Decrypts a string using AES decryption in CBC mode.
    """
    try:
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decrypted = cipher.decrypt(encrypted_bytes)
        decryptedUnpadded = unpad(decrypted, AES.block_size, 'pkcs7')
        return decryptedUnpadded
    except Exception as e:
        return ""


def md5_hash(data: bytes):
    """
    Returns the MD5 hash of the given data.
    """
    return hashlib.md5(data).hexdigest()

def base64Encoding(input):
  dataBase64 = base64.b64encode(input)
  dataBase64P = dataBase64.decode("UTF-8")
  return dataBase64P

def base64_decode(input: bytes):
    """
    Decodes a Base64 string to hexadecimal.
    """
    decoded_bytes = base64.decodebytes(input)
    return decoded_bytes.hex()

def decryptMap(encryptedMapContent: bytes, modelKey: str, did: str):

    originalWork = modelKey + did

    iv = b"ABCDEF1234123412" # iv as a byte array

    encKey = encrypt(originalWork.encode('latin1'), modelKey.encode('latin1'), iv)
    encKey2 = bytes.fromhex(encKey)
    md5Key = md5_hash(encKey2)
    decryptKey = bytes.fromhex(md5Key)

    encryptedBytes = bytes.fromhex(encryptedMapContent)
    decryptedBase64Bytes = decrypt(encryptedBytes, decryptKey, iv)
    inflatedString = inflate(decryptedBase64Bytes)
    
    ## Write decrypted map to file
    #with open("0.decrypted.map.json", "w") as decryptedFile:
    #    # Writing data to a file
    #    decryptedFile.write(inflatedString)

    return inflatedString

def transformMapData(map_data):
    if map_data is None:
        return None

    map_data = json.loads(map_data)

    map_id = map_data.get("map_id")
    map_version = map_data.get("map_type")
    map_height = map_data.get("height")
    map_width = map_data.get("width")
    origin_x = map_data.get("origin_x")
    origin_y = map_data.get("origin_y")
    have_charge_pile = map_data.get("have_pile")
    charge_pile_x = map_data.get("pile_x")
    charge_pile_y = map_data.get("pile_y")
    charge_pile_yaw = map_data.get("pile_yaw")
    map_resolution = map_data.get("resolution")

    # Inflate the base64-encoded map data
    byte_array = zlib.decompress(base64.b64decode(map_data.get("map_data")))

    # Convert to a uint8 array (bytearray in Python)
    data = bytearray(byte_array)
    fb_walls = map_data.get("fb_walls")
    fb_area = map_data.get("fb_regions")
    zone = map_data.get("part_regions")
    rooms = map_data.get("room_attrs")
    room_colors = None

    if isinstance(map_data.get("map_room_info"), list):
        room_colors = {}
        for item in map_data["map_room_info"]:
            room_colors[item["grid_id"]] = item["color"]

    path = map_data.get("paths")

    # Charge details
    charge = {
        "haveChargePile": have_charge_pile,
        "chargePileX": charge_pile_x,
        "chargePileY": charge_pile_y,
        "chargePileYaw": charge_pile_yaw,
    }

    # Origin details
    origin = {
        "x": round(origin_x / 1000, 2) if origin_x is not None else None,
        "y": round(origin_y / 1000, 2) if origin_y is not None else None,
    }

    # Accuracy
    accuracy = round(map_resolution / 1000, 2) if map_resolution is not None else None

    # Header
    header = {
        "mapId": map_id,
        "mapVersion": map_version,
        "mapWidth": map_width,
        "mapHeight": map_height,
        "charge": charge,
        "origin": origin,
        "accuracy": accuracy,
        "roomColors": room_colors,
    }

    # Extra
    extra = {
        "fbWalls": fb_walls,
        "fbArea": fb_area,
        "zone": zone,
        "rooms": rooms,
        "path": path,
    }

    # Final map structure
    map_result = {
        "header": header,
        "data": data,
        "extra": extra,
    }

    return map_result


def main(): 
    modelKey = "mi.vacuum.b108gl"
    did = "1068470163"

    mapContent = loadMapFromFile("0.encrypted.map")
    decryptedMapContent = decryptMap(mapContent, modelKey, did)

    transformedMapData = transformMapData(decryptedMapContent)

    print ('Map content:', transformedMapData)

if __name__ == "__main__":
    main()
    
    ``` 

Hi, where do I need to add this code?

svpikalov avatar Jan 21 '25 11:01 svpikalov

Hi, where do I need to add this code?

It's not ready to be used

PiotrMachowski avatar Jan 21 '25 11:01 PiotrMachowski

Hi, where do I need to add this code?

It's not ready to be used

Oh, well, good luck with the refinement, I'll look forward to when it's ready. Good luck!

svpikalov avatar Jan 21 '25 12:01 svpikalov

Hi, where do I need to add this code?

It's not ready to be used

@PiotrMachowski - Might you be able to comment on how likely you are to be able to add support for the S20+ model at some point? It does seem to be a very popular request and it would be a big help to know whether support was, for example, imminent, months off or highly unlikely to ever happen. Generous support donation available! Thanks

0rangutan avatar Jan 21 '25 14:01 0rangutan

Hi, where do I need to add this code?

It's not ready to be used

@PiotrMachowski - Might you be able to comment on how likely you are to be able to add support for the S20+ model at some point? It does seem to be a very popular request and it would be a big help to know whether support was, for example, imminent, months off or highly unlikely to ever happen. Generous support donation available! Thanks

+1, including the donation part.

spascanu avatar Jan 21 '25 14:01 spascanu

@PiotrMachowski - Might you be able to comment on how likely you are to be able to add support for the S20+ model at some point? It does seem to be a very popular request and it would be a big help to know whether support was, for example, imminent, months off or highly unlikely to ever happen. Generous support donation available! Thanks

I did not have any free time recently, but now I slowly try to get back to this project. I have started rewriting this integration from the scratch, so it might take a while though. I also try to maintain my other repos (I have almost 50 of them...) and I also have to take care of my own HA instance - I have migrated to a new instance almost a year ago and I stil haven't created proper dashboards for it. In the meantime I also try to do some hardware projects (to not sit in front of a screen 24/7). Aaaand all of these things have to compete for my free time with other "normal" activities.

To sum up - I try my best to get back to this integration generally, not just specifically to this issue. I hope I will be able to release something usable in the next 2-3 months (but this is just a very rough guess).


By the way, "thumbs up" reaction to the original request is much better than commenting just "+1" under the issue. It sometimes gets irritating when you receive a lot of them from multiple threads/repos. When I release a new version of anything I evaluate all existing issues anyway.

PiotrMachowski avatar Jan 21 '25 17:01 PiotrMachowski

Can someone make a json file with scripts for s20+ similar to this file? For mi home vevs version scenaries https://github.com/user-attachments/files/18538684/dreame.vacuum.p2009.json so that it would be possible to specify the room number and type of cleaning in the script

https://mi.vevs.me/mihome/files/old/MiHome_10.0.706_79976_vevs.apk

Mykls1 avatar Jan 24 '25 16:01 Mykls1

you can already achieve this, see: https://community.home-assistant.io/t/support-for-xiaomi-vacuum-s20-to-xiaomi-miio-integration/770596/20

Can someone make a json file with scripts for s20+ similar to this file? For mi home vevs version scenaries https://github.com/user-attachments/files/18538684/dreame.vacuum.p2009.json so that it would be possible to specify the room number and type of cleaning in the script

https://mi.vevs.me/mihome/files/old/MiHome_10.0.706_79976_vevs.apk

suskozaver avatar Feb 24 '25 18:02 suskozaver

Hi, what does code avaiable tag means? Is there a commit I can test?

rafaeltcc avatar Mar 27 '25 01:03 rafaeltcc

Hi, what does code avaiable tag means? Is there a commit I can test?

@rafaeltcc that's a note for me that there is a code posted in comments

PiotrMachowski avatar Mar 27 '25 01:03 PiotrMachowski

Hello,

Any updates on here?

SoNyz85 avatar Mar 30 '25 07:03 SoNyz85

adding a +1 to this so I get alerts once added. Appreciate @PiotrMachowski is super busy and this is low on his life's priority list.

I left a tiny PayPal payment, if more people were to do this maybe it would encourage PiotrMachowski to find time to get back to coding on this project.

bertybassett avatar Apr 24 '25 18:04 bertybassett

+1. Anybody knows if there is any other plugin to get this working?

bone6000 avatar Aug 12 '25 07:08 bone6000

Is there any plugin that supports S20+ except MIOT which doesn't support maps?

alamakot avatar Aug 12 '25 07:08 alamakot

Is there any plugin that supports S20+ except MIOT which doesn't support maps?

I have this one and it works great. Setup each room image

SoNyz85 avatar Aug 14 '25 14:08 SoNyz85

whi

Is there any plugin that supports S20+ except MIOT which doesn't support maps?

I have this one and it works great. Setup each room image

which plugin is that? does it support floor maps?

bone6000 avatar Aug 14 '25 14:08 bone6000