Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
2 of 7 tasks
anton-knoc opened this issue Oct 29, 2024 · 22 comments
Open
2 of 7 tasks

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

anton-knoc opened this issue Oct 29, 2024 · 22 comments
Assignees
Labels
enhancement New feature or request new platform

Comments

@anton-knoc
Copy link

anton-knoc commented Oct 29, 2024

Checklist

  • I have updated the integration to the latest version available
  • 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!

@Nicooow
Copy link

Nicooow commented Oct 29, 2024

Judging by the model number (xiaomi.vacuum.b108), I think it's the same integration as #460 (xiaomi.vacuum.b106)

@tam481
Copy link

tam481 commented Nov 10, 2024

Adding my +1
Thank you

@dankarization
Copy link

+1 waiting for this model

@0rangutan
Copy link

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

@mdudek
Copy link

mdudek commented Dec 11, 2024

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

@PiotrMachowski
Copy link
Owner

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

@munaaf
Copy link

munaaf commented Jan 1, 2025

+1 and subscribed to updates

@Sevii88
Copy link

Sevii88 commented Jan 5, 2025

+1

6 similar comments
@alamakot
Copy link

alamakot commented Jan 5, 2025

+1

@uNiqu3MK
Copy link

uNiqu3MK commented Jan 5, 2025

+1

@karot9100
Copy link

+1

@spascanu
Copy link

+1

@GoNzCiD
Copy link

GoNzCiD commented Jan 15, 2025

+1

@typ-turbo
Copy link

+1

@svpikalov
Copy link

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?

@PiotrMachowski
Copy link
Owner

Hi, where do I need to add this code?

It's not ready to be used

@svpikalov
Copy link

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!

@0rangutan
Copy link

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

@spascanu
Copy link

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.

@PiotrMachowski
Copy link
Owner

PiotrMachowski commented Jan 21, 2025

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

@Mykls1
Copy link

Mykls1 commented Jan 24, 2025

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request new platform
Projects
None yet
Development

No branches or pull requests