Skip to content

Commit 22d69dc

Browse files
author
Alexander "Tarh
committed
Added support for ijai.vacuum.xxx map image
1 parent 6968cc0 commit 22d69dc

13 files changed

Lines changed: 956 additions & 9 deletions

File tree

custom_components/xiaomi_cloud_map_extractor/camera.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
from .vacuum_platforms.vacuum_roborock import RoborockCloudVacuum
2626
from .vacuum_platforms.vacuum_roidmi import RoidmiCloudVacuum
2727
from .vacuum_platforms.vacuum_viomi import ViomiCloudVacuum
28+
from .vacuum_platforms.vacuum_ijai import IjaiCloudVacuum
2829
from .vacuum_platforms.vacuum_unsupported import UnsupportedCloudVacuum
2930
from .const import *
3031

32+
3133
_LOGGER = logging.getLogger(__name__)
3234

3335
SCAN_INTERVAL = timedelta(seconds=5)
@@ -297,11 +299,11 @@ def _login(self):
297299

298300
def _initialize_device(self):
299301
_LOGGER.debug("Retrieving device info, country: %s", self._country)
300-
country, user_id, device_id, model = self._connector.get_device_details(self._token, self._country)
302+
country, user_id, device_id, model, mac = self._connector.get_device_details(self._vacuum.token, self._country)
301303
if model is not None:
302304
self._country = country
303305
_LOGGER.debug("Retrieved device model: %s", model)
304-
self._device = self._create_device(user_id, device_id, model)
306+
self._device = self._create_device(user_id, device_id, model, mac)
305307
_LOGGER.debug("Created device, used api: %s", self._used_api)
306308
else:
307309
_LOGGER.error("Failed to retrieve model")
@@ -339,7 +341,7 @@ def _set_map_data(self, map_data: MapData):
339341
self._map_data = map_data
340342
self._store_image()
341343

342-
def _create_device(self, user_id: str, device_id: str, model: str) -> XiaomiCloudVacuum:
344+
def _create_device(self, user_id: str, device_id: str, model: str, mac: str) -> XiaomiCloudVacuum:
343345
self._used_api = self._detect_api(model)
344346
store_map_path = self._store_map_path if self._store_map_raw else None
345347
vacuum_config = VacuumConfig(
@@ -361,6 +363,8 @@ def _create_device(self, user_id: str, device_id: str, model: str) -> XiaomiClou
361363
return RoborockCloudVacuum(vacuum_config)
362364
if self._used_api == CONF_AVAILABLE_API_VIOMI:
363365
return ViomiCloudVacuum(vacuum_config)
366+
if self._used_api == CONF_AVAILABLE_API_IJAI:
367+
return IjaiCloudVacuum(self._connector, self._country, user_id, device_id, model, mac)
364368
if self._used_api == CONF_AVAILABLE_API_ROIDMI:
365369
return RoidmiCloudVacuum(vacuum_config)
366370
if self._used_api == CONF_AVAILABLE_API_DREAME:
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from abc import abstractmethod
2+
from typing import Optional, Tuple
3+
4+
from custom_components.xiaomi_cloud_map_extractor.common.map_data import MapData
5+
from custom_components.xiaomi_cloud_map_extractor.common.map_data_parser import MapDataParser
6+
from custom_components.xiaomi_cloud_map_extractor.common.xiaomi_cloud_connector import XiaomiCloudConnector
7+
from custom_components.xiaomi_cloud_map_extractor.types import Colors, Drawables, ImageConfig, Sizes, Texts
8+
9+
10+
class XiaomiCloudVacuum:
11+
12+
def __init__(self, connector: XiaomiCloudConnector, country: str, user_id: str, device_id: str, model: str, mac: str):
13+
self._connector = connector
14+
self._country = country
15+
self._user_id = user_id
16+
self._device_id = device_id
17+
self.model = model
18+
self.mac = mac
19+
20+
def decrypt_map(self, data:bytes, wifi_info_sn:str, user_id:str, device_id:str, model:str, mac:str):
21+
return data
22+
23+
def get_map(self,
24+
map_name: str,
25+
colors: Colors,
26+
drawables: Drawables,
27+
texts: Texts,
28+
sizes: Sizes,
29+
image_config: ImageConfig,
30+
store_map_path: Optional[str] = None) -> Tuple[Optional[MapData], bool]:
31+
response = self.get_raw_map_data(map_name)
32+
if response is None:
33+
return None, False
34+
map_stored = False
35+
if store_map_path is not None:
36+
raw_map_file = open(f"{store_map_path}/map_data_{self.model}.{self.get_map_archive_extension()}", "wb")
37+
raw_map_file.write(response)
38+
raw_map_file.close()
39+
map_stored = True
40+
41+
map_data_decrypted = self.decrypt_map(response, "######SD##########", self._user_id, self._device_id, self.model, self.mac)
42+
map_data = self.decode_map(map_data_decrypted, colors, drawables, texts, sizes, image_config)
43+
if map_data is None:
44+
return None, map_stored
45+
map_data.map_name = map_name
46+
return map_data, map_stored
47+
48+
def get_raw_map_data(self, map_name: Optional[str]) -> Optional[bytes]:
49+
if map_name is None:
50+
return None
51+
map_url = self.get_map_url(map_name)
52+
return self._connector.get_raw_map_data(map_url)
53+
54+
def decode_map(self,
55+
raw_map: bytes,
56+
colors: Colors,
57+
drawables: Drawables,
58+
texts: Texts,
59+
sizes: Sizes,
60+
image_config: ImageConfig) -> Optional[MapData]:
61+
return MapDataParser.create_empty(colors, f"Vacuum\n{self.model}\nis not supported")
62+
63+
@abstractmethod
64+
def get_map_url(self, map_name: str) -> Optional[str]:
65+
pass
66+
67+
@abstractmethod
68+
def should_get_map_from_vacuum(self) -> bool:
69+
pass
70+
71+
def get_map_archive_extension(self) -> str:
72+
pass

custom_components/xiaomi_cloud_map_extractor/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
CONF_AVAILABLE_API_DREAME = "dreame"
77
CONF_AVAILABLE_API_ROIDMI = "roidmi"
88
CONF_AVAILABLE_API_VIOMI = "viomi"
9+
CONF_AVAILABLE_API_IJAI = "ijai"
910
CONF_AVAILABLE_API_XIAOMI = "xiaomi"
1011
CONF_AVAILABLE_COUNTRIES = ["cn", "de", "us", "ru", "tw", "sg", "in", "i2"]
1112
CONF_BOTTOM = "bottom"
@@ -215,6 +216,7 @@
215216
CONF_AVAILABLE_API_DREAME: ["dreame.vacuum."],
216217
CONF_AVAILABLE_API_ROIDMI: ["roidmi.vacuum.", "zhimi.vacuum.", "chuangmi.vacuum."],
217218
CONF_AVAILABLE_API_VIOMI: ["viomi.vacuum."],
219+
CONF_AVAILABLE_API_IJAI: ["ijai.vacuum."],
218220
CONF_AVAILABLE_API_XIAOMI: ["roborock.vacuum", "rockrobo.vacuum"]
219221
}
220222

custom_components/xiaomi_cloud_map_extractor/ijai/__init__.py

Whitespace-only changes.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from Crypto.Cipher import AES
2+
from Crypto.Hash import MD5
3+
from Crypto.Util.Padding import pad, unpad
4+
import base64
5+
import logging
6+
7+
_LOGGER = logging.getLogger(__name__)
8+
9+
isEncryptKeyTypeHex = True
10+
11+
def aesEncrypted(data, key: str):
12+
cipher = AES.new(key.encode("utf-8"), AES.MODE_ECB)
13+
14+
encryptedData = cipher.encrypt(
15+
pad(data.encode("utf-8"), AES.block_size, 'pkcs7'))
16+
encryptedBase64Str = base64.b64encode(encryptedData).decode("utf-8")
17+
18+
return encryptedBase64Str
19+
20+
def aesDecrypted(data, key: str):
21+
parsedKey = key.encode("utf-8")
22+
if isEncryptKeyTypeHex:
23+
parsedKey = bytes.fromhex(key)
24+
25+
cipher = AES.new(parsedKey, AES.MODE_ECB)
26+
27+
decryptedBytes = cipher.decrypt(base64.b64decode(data))
28+
29+
decryptedData = unpad(decryptedBytes, AES.block_size, 'pkcs7')
30+
31+
return bytes.fromhex(decryptedData.decode("utf-8"))
32+
33+
34+
def md5key(string: str, model: str, device_mac: str):
35+
pjstr = "".join(device_mac.lower().split(":"))
36+
37+
tempModel = model.split('.')[-1]
38+
39+
if len(tempModel) == 2:
40+
tempModel = "00" + tempModel
41+
elif len(tempModel) == 3:
42+
tempModel = "0" + tempModel
43+
44+
tempKey = pjstr + tempModel
45+
aeskey = aesEncrypted(string, tempKey)
46+
#aeskey = string
47+
48+
temp = MD5.new(aeskey.encode('utf-8')).hexdigest()
49+
if isEncryptKeyTypeHex:
50+
return temp
51+
else:
52+
return temp[8:-8].upper()
53+
54+
55+
def genMD5key(wifi_info_sn: str, owner_id: str, device_id: str, model: str, device_mac: str):
56+
arr = [wifi_info_sn, owner_id, device_id]
57+
tempString = '+'.join(arr)
58+
return md5key(tempString, model, device_mac)
59+
60+
61+
def unGzipCommon(data: str, wifi_info_sn: str, owner_id: str, device_id: str, model: str, device_mac: str) -> bytes:
62+
#base64map = base64.b64encode(data)
63+
# with open("0.encrypted.map", 'wb') as file:
64+
# file.write(data)
65+
return aesDecrypted(data, genMD5key(wifi_info_sn, owner_id, device_id, model, device_mac))
66+
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import logging
2+
from typing import Dict, Optional, Set, Tuple
3+
4+
from PIL import Image
5+
from PIL.Image import Image as ImageType
6+
7+
from custom_components.xiaomi_cloud_map_extractor.common.image_handler import ImageHandler
8+
from custom_components.xiaomi_cloud_map_extractor.const import *
9+
from custom_components.xiaomi_cloud_map_extractor.types import Colors, ImageConfig
10+
from custom_components.xiaomi_cloud_map_extractor.ijai.parsing_buffer import ParsingBuffer
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
class ImageHandlerIjai(ImageHandler):
16+
MAP_OUTSIDE = 0x00
17+
MAP_WALL = 0xff
18+
MAP_SCAN = 0x01
19+
MAP_NEW_DISCOVERED_AREA = 0x02
20+
MAP_ROOM_MIN = 10
21+
MAP_ROOM_MAX = 59
22+
MAP_SELECTED_ROOM_MIN = 60
23+
MAP_SELECTED_ROOM_MAX = 109
24+
@staticmethod
25+
def parse(buf: ParsingBuffer, width: int, height: int, colors: Colors, image_config: ImageConfig,
26+
draw_cleaned_area: bool) \
27+
-> Tuple[ImageType, Dict[int, Tuple[int, int, int, int]], Set[int], Optional[ImageType]]:
28+
palette = {\
29+
ImageHandlerIjai.MAP_OUTSIDE: ImageHandler.__get_color__(COLOR_MAP_OUTSIDE, colors),\
30+
ImageHandlerIjai.MAP_WALL: ImageHandler.__get_color__(COLOR_MAP_WALL_V2, colors),\
31+
ImageHandlerIjai.MAP_SCAN: ImageHandler.__get_color__(COLOR_SCAN, colors),\
32+
ImageHandlerIjai.MAP_NEW_DISCOVERED_AREA: ImageHandler.__get_color__(COLOR_NEW_DISCOVERED_AREA, colors)}
33+
rooms = {}
34+
cleaned_areas = set()
35+
scale = image_config[CONF_SCALE]
36+
trim_left = int(image_config[CONF_TRIM][CONF_LEFT] * width / 100)
37+
trim_right = int(image_config[CONF_TRIM][CONF_RIGHT] * width / 100)
38+
trim_top = int(image_config[CONF_TRIM][CONF_TOP] * height / 100)
39+
trim_bottom = int(image_config[CONF_TRIM][CONF_BOTTOM] * height / 100)
40+
trimmed_height = height - trim_top - trim_bottom
41+
trimmed_width = width - trim_left - trim_right
42+
if trimmed_width == 0 or trimmed_height == 0:
43+
return ImageHandler.create_empty_map_image(colors), rooms, cleaned_areas, None
44+
image = Image.new('RGBA', (trimmed_width, trimmed_height))
45+
pixels = image.load()
46+
cleaned_areas_layer = None
47+
cleaned_areas_pixels = None
48+
if draw_cleaned_area:
49+
cleaned_areas_layer = Image.new('RGBA', (trimmed_width, trimmed_height))
50+
cleaned_areas_pixels = cleaned_areas_layer.load()
51+
_LOGGER.debug(f"trim_bottom = {trim_bottom}, trim_top = {trim_top}, trim_left = {trim_left}, trim_right = {trim_right}")
52+
buf.skip('trim_bottom', trim_bottom * width)
53+
unknown_pixels = set()
54+
_LOGGER.debug(f"buffer: [{buf._offs:#x}] = {buf.peek_uint32("some_int32")}")
55+
for img_y in range(trimmed_height):
56+
buf.skip('trim_left', trim_left)
57+
for img_x in range(trimmed_width):
58+
pixel_type = buf.get_uint8('pixel')
59+
x = img_x
60+
y = trimmed_height - 1 - img_y
61+
if pixel_type in palette.keys():
62+
pixels[x, y] = palette[pixel_type]
63+
elif ImageHandlerIjai.MAP_ROOM_MIN <= pixel_type <= ImageHandlerIjai.MAP_SELECTED_ROOM_MAX:
64+
room_x = img_x + trim_left
65+
room_y = img_y + trim_bottom
66+
if pixel_type < ImageHandlerIjai.MAP_SELECTED_ROOM_MIN:
67+
room_number = pixel_type
68+
else:
69+
room_number = pixel_type - ImageHandlerIjai.MAP_SELECTED_ROOM_MIN + ImageHandlerIjai.MAP_ROOM_MIN
70+
cleaned_areas.add(room_number)
71+
if draw_cleaned_area:
72+
cleaned_areas_pixels[x, y] = ImageHandler.__get_color__(COLOR_CLEANED_AREA, colors)
73+
if room_number not in rooms:
74+
rooms[room_number] = (room_x, room_y, room_x, room_y)
75+
else:
76+
rooms[room_number] = (min(rooms[room_number][0], room_x),
77+
min(rooms[room_number][1], room_y),
78+
max(rooms[room_number][2], room_x),
79+
max(rooms[room_number][3], room_y))
80+
default = ImageHandler.ROOM_COLORS[room_number % len(ImageHandler.ROOM_COLORS)]
81+
pixels[x, y] = ImageHandler.__get_color__(f"{COLOR_ROOM_PREFIX}{room_number}", colors, default)
82+
else:
83+
pixels[x, y] = ImageHandler.__get_color__(COLOR_UNKNOWN, colors)
84+
unknown_pixels.add(pixel_type)
85+
_LOGGER.debug(f"unknown pixel [{x},{y}] = {pixel_type}")
86+
buf.skip('trim_right', trim_right)
87+
buf.skip('trim_top', trim_top * width)
88+
if image_config["scale"] != 1 and trimmed_width != 0 and trimmed_height != 0:
89+
image = image.resize((int(trimmed_width * scale), int(trimmed_height * scale)), resample=Image.NEAREST)
90+
if draw_cleaned_area:
91+
cleaned_areas_layer = cleaned_areas_layer.resize(
92+
(int(trimmed_width * scale), int(trimmed_height * scale)), resample=Image.NEAREST)
93+
if len(unknown_pixels) > 0:
94+
_LOGGER.warning('unknown pixel_types: %s', unknown_pixels)
95+
return image, rooms, cleaned_areas, cleaned_areas_layer

0 commit comments

Comments
 (0)