66- PKCS7 padded
77- ASCII hex for a zlib-compressed SCMap payload
88
9- The inner SCMap blob uses protobuf wire encoding. We keep the runtime parser
10- minimal and declarative: only the fields needed to render the occupancy image
11- and expose room names are described here.
9+ The inner SCMap blob is a protobuf-wire message. The declarative field layout
10+ below is taken from the reverse-engineered Q7 mobile app bundle (RR_API), so
11+ these fields are grounded in observed app schema/post-processing code rather
12+ than guessed parser branches. We still avoid shipping generated protobuf
13+ classes from a reverse-engineered schema, which keeps the runtime parser
14+ explicit without overstating certainty about the full message definition.
1215"""
1316
1417from __future__ import annotations
1720import binascii
1821import hashlib
1922import io
23+ import struct
2024import zlib
2125from collections .abc import Callable
22- from dataclasses import dataclass
26+ from dataclasses import dataclass , field
2327
2428from Crypto .Cipher import AES
2529from Crypto .Util .Padding import pad , unpad
@@ -49,6 +53,70 @@ class _ProtoField:
4953 parser : Callable [[object ], object ] | None = None
5054
5155
56+ @dataclass (frozen = True )
57+ class _ScPoint :
58+ x : float | None = None
59+ y : float | None = None
60+
61+
62+ @dataclass (frozen = True )
63+ class _ScMapBoundaryInfo :
64+ map_md5 : str | None = None
65+ v_min_x : int | None = None
66+ v_max_x : int | None = None
67+ v_min_y : int | None = None
68+ v_max_y : int | None = None
69+
70+
71+ @dataclass (frozen = True )
72+ class _ScMapExtInfo :
73+ task_begin_date : int | None = None
74+ map_upload_date : int | None = None
75+ map_valid : int | None = None
76+ radian : int | None = None
77+ force : int | None = None
78+ clean_path : int | None = None
79+ boundary_info : _ScMapBoundaryInfo | None = None
80+ map_version : int | None = None
81+ map_value_type : int | None = None
82+
83+
84+ @dataclass (frozen = True )
85+ class _ScMapHead :
86+ map_head_id : int | None = None
87+ size_x : int | None = None
88+ size_y : int | None = None
89+ min_x : float | None = None
90+ min_y : float | None = None
91+ max_x : float | None = None
92+ max_y : float | None = None
93+ resolution : float | None = None
94+
95+
96+ @dataclass (frozen = True )
97+ class _ScRoomData :
98+ room_id : int | None = None
99+ room_name : str | None = None
100+ room_type_id : int | None = None
101+ material_id : int | None = None
102+ clean_state : int | None = None
103+ room_clean : int | None = None
104+ room_clean_index : int | None = None
105+ room_name_post : _ScPoint | None = None
106+ color_id : int | None = None
107+ floor_direction : int | None = None
108+ global_seq : int | None = None
109+
110+
111+ @dataclass (frozen = True )
112+ class _ScMapPayload :
113+ map_type : int | None = None
114+ map_ext_info : _ScMapExtInfo | None = None
115+ map_head : _ScMapHead | None = None
116+ map_data : bytes | None = None
117+ room_data_info : tuple [_ScRoomData , ...] = field (default_factory = tuple )
118+
119+
52120@dataclass
53121class B01MapParserConfig :
54122 """Configuration for the B01/Q7 map parser."""
@@ -66,7 +134,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None:
66134 def parse (self , raw_payload : bytes , * , serial : str , model : str ) -> ParsedMapData :
67135 """Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
68136 inflated = _decode_b01_map_payload (raw_payload , serial = serial , model = model )
69- size_x , size_y , grid , room_names = _parse_scmap_payload (inflated )
137+ scmap = _parse_scmap_payload (inflated )
138+ size_x , size_y , grid = _extract_grid (scmap )
139+ room_names = _extract_room_names (scmap .room_data_info )
70140
71141 image = _render_occupancy_image (grid , size_x = size_x , size_y = size_y , scale = self ._config .map_scale )
72142
@@ -178,6 +248,10 @@ def _decode_utf8(value: bytes) -> str:
178248 return value .decode ("utf-8" , errors = "replace" )
179249
180250
251+ def _decode_float32 (value : bytes ) -> float :
252+ return struct .unpack ("<f" , value )[0 ]
253+
254+
181255def _decode_map_data_bytes (value : bytes ) -> bytes :
182256 try :
183257 return zlib .decompress (value )
@@ -218,47 +292,145 @@ def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context
218292 return parsed
219293
220294
221- def _parse_map_head_info (blob : bytes ) -> tuple [int | None , int | None ]:
222- parsed = _parse_proto_message (blob , _MAP_HEAD_INFO_SCHEMA , context = "B01 MapHeadInfo" )
223- return parsed .get ("size_x" ), parsed .get ("size_y" )
295+ def _parse_sc_point (blob : bytes ) -> _ScPoint :
296+ parsed = _parse_proto_message (blob , _DEVICE_POINT_INFO_SCHEMA , context = "B01 DevicePointInfo" )
297+ return _ScPoint (x = parsed .get ("x" ), y = parsed .get ("y" ))
298+
299+
300+ def _parse_sc_map_boundary_info (blob : bytes ) -> _ScMapBoundaryInfo :
301+ parsed = _parse_proto_message (blob , _MAP_BOUNDARY_INFO_SCHEMA , context = "B01 MapBoundaryInfo" )
302+ return _ScMapBoundaryInfo (
303+ map_md5 = parsed .get ("map_md5" ),
304+ v_min_x = parsed .get ("v_min_x" ),
305+ v_max_x = parsed .get ("v_max_x" ),
306+ v_min_y = parsed .get ("v_min_y" ),
307+ v_max_y = parsed .get ("v_max_y" ),
308+ )
224309
225310
226- def _parse_map_data_info (blob : bytes ) -> bytes :
311+ def _parse_sc_map_ext_info (blob : bytes ) -> _ScMapExtInfo :
312+ parsed = _parse_proto_message (blob , _MAP_EXT_INFO_SCHEMA , context = "B01 MapExtInfo" )
313+ return _ScMapExtInfo (
314+ task_begin_date = parsed .get ("task_begin_date" ),
315+ map_upload_date = parsed .get ("map_upload_date" ),
316+ map_valid = parsed .get ("map_valid" ),
317+ radian = parsed .get ("radian" ),
318+ force = parsed .get ("force" ),
319+ clean_path = parsed .get ("clean_path" ),
320+ boundary_info = parsed .get ("boundary_info" ),
321+ map_version = parsed .get ("map_version" ),
322+ map_value_type = parsed .get ("map_value_type" ),
323+ )
324+
325+
326+ def _parse_sc_map_head (blob : bytes ) -> _ScMapHead :
327+ parsed = _parse_proto_message (blob , _MAP_HEAD_INFO_SCHEMA , context = "B01 MapHeadInfo" )
328+ return _ScMapHead (
329+ map_head_id = parsed .get ("map_head_id" ),
330+ size_x = parsed .get ("size_x" ),
331+ size_y = parsed .get ("size_y" ),
332+ min_x = parsed .get ("min_x" ),
333+ min_y = parsed .get ("min_y" ),
334+ max_x = parsed .get ("max_x" ),
335+ max_y = parsed .get ("max_y" ),
336+ resolution = parsed .get ("resolution" ),
337+ )
338+
339+
340+ def _parse_sc_map_data_info (blob : bytes ) -> bytes :
227341 parsed = _parse_proto_message (blob , _MAP_DATA_INFO_SCHEMA , context = "B01 MapDataInfo" )
228342 if (map_data := parsed .get ("map_data" )) is None :
229343 raise RoborockException ("B01 map payload missing mapData" )
230344 return map_data
231345
232346
233- def _parse_room_data_info (blob : bytes ) -> tuple [ int | None , str | None ] :
347+ def _parse_sc_room_data (blob : bytes ) -> _ScRoomData :
234348 parsed = _parse_proto_message (blob , _ROOM_DATA_INFO_SCHEMA , context = "B01 RoomDataInfo" )
235- return parsed .get ("room_id" ), parsed .get ("room_name" )
349+ return _ScRoomData (
350+ room_id = parsed .get ("room_id" ),
351+ room_name = parsed .get ("room_name" ),
352+ room_type_id = parsed .get ("room_type_id" ),
353+ material_id = parsed .get ("material_id" ),
354+ clean_state = parsed .get ("clean_state" ),
355+ room_clean = parsed .get ("room_clean" ),
356+ room_clean_index = parsed .get ("room_clean_index" ),
357+ room_name_post = parsed .get ("room_name_post" ),
358+ color_id = parsed .get ("color_id" ),
359+ floor_direction = parsed .get ("floor_direction" ),
360+ global_seq = parsed .get ("global_seq" ),
361+ )
362+
363+
364+ def _parse_scmap_payload (payload : bytes ) -> _ScMapPayload :
365+ """Parse inflated SCMap bytes using the reverse-engineered app field layout."""
366+ parsed = _parse_proto_message (payload , _ROBOT_MAP_SCHEMA , context = "B01 SCMap" )
367+ return _ScMapPayload (
368+ map_type = parsed .get ("map_type" ),
369+ map_ext_info = parsed .get ("map_ext_info" ),
370+ map_head = parsed .get ("map_head" ),
371+ map_data = parsed .get ("map_data" ),
372+ room_data_info = tuple (parsed .get ("room_data_info" , [])),
373+ )
236374
237375
238- def _parse_scmap_payload ( payload : bytes ) -> tuple [int , int , bytes , dict [ int , str ] ]:
239- """Parse inflated SCMap bytes using the reverse-engineered field layout we need."""
240- parsed = _parse_proto_message ( payload , _ROBOT_MAP_SCHEMA , context = " B01 SCMap " )
376+ def _extract_grid ( scmap : _ScMapPayload ) -> tuple [int , int , bytes ]:
377+ if scmap . map_head is None or scmap . map_data is None :
378+ raise RoborockException ( "Failed to parse B01 map header/grid " )
241379
242- size_x , size_y = parsed . get ( " map_head" , ( None , None ))
243- grid = parsed . get ( "map_data" )
244- if not size_x or not size_y or not grid :
380+ size_x = scmap . map_head . size_x or 0
381+ size_y = scmap . map_head . size_y or 0
382+ if not size_x or not size_y or not scmap . map_data :
245383 raise RoborockException ("Failed to parse B01 map header/grid" )
246384
247385 expected_len = size_x * size_y
248- if len (grid ) < expected_len :
386+ if len (scmap . map_data ) < expected_len :
249387 raise RoborockException ("B01 map data shorter than expected dimensions" )
250388
389+ return size_x , size_y , scmap .map_data [:expected_len ]
390+
391+
392+ def _extract_room_names (rooms : tuple [_ScRoomData , ...]) -> dict [int , str ]:
251393 room_names : dict [int , str ] = {}
252- for room_id , room_name in parsed .get ("room_data_info" , []):
253- if room_id is not None :
254- room_names [room_id ] = room_name or f"Room { room_id } "
394+ for room in rooms :
395+ if room .room_id is not None :
396+ room_names [room .room_id ] = room .room_name or f"Room { room .room_id } "
397+ return room_names
255398
256- return size_x , size_y , grid [:expected_len ], room_names
257399
400+ _DEVICE_POINT_INFO_SCHEMA = {
401+ 1 : _ProtoField ("x" , _WIRE_FIXED32 , parser = _decode_float32 ),
402+ 2 : _ProtoField ("y" , _WIRE_FIXED32 , parser = _decode_float32 ),
403+ }
404+
405+ _MAP_BOUNDARY_INFO_SCHEMA = {
406+ 1 : _ProtoField ("map_md5" , _WIRE_LEN , parser = _decode_utf8 ),
407+ 2 : _ProtoField ("v_min_x" , _WIRE_VARINT , parser = _decode_uint32 ),
408+ 3 : _ProtoField ("v_max_x" , _WIRE_VARINT , parser = _decode_uint32 ),
409+ 4 : _ProtoField ("v_min_y" , _WIRE_VARINT , parser = _decode_uint32 ),
410+ 5 : _ProtoField ("v_max_y" , _WIRE_VARINT , parser = _decode_uint32 ),
411+ }
412+
413+ _MAP_EXT_INFO_SCHEMA = {
414+ 1 : _ProtoField ("task_begin_date" , _WIRE_VARINT , parser = _decode_uint32 ),
415+ 2 : _ProtoField ("map_upload_date" , _WIRE_VARINT , parser = _decode_uint32 ),
416+ 3 : _ProtoField ("map_valid" , _WIRE_VARINT , parser = _decode_uint32 ),
417+ 4 : _ProtoField ("radian" , _WIRE_VARINT , parser = _decode_uint32 ),
418+ 5 : _ProtoField ("force" , _WIRE_VARINT , parser = _decode_uint32 ),
419+ 6 : _ProtoField ("clean_path" , _WIRE_VARINT , parser = _decode_uint32 ),
420+ 7 : _ProtoField ("boundary_info" , _WIRE_LEN , parser = _parse_sc_map_boundary_info ),
421+ 8 : _ProtoField ("map_version" , _WIRE_VARINT , parser = _decode_uint32 ),
422+ 9 : _ProtoField ("map_value_type" , _WIRE_VARINT , parser = _decode_uint32 ),
423+ }
258424
259425_MAP_HEAD_INFO_SCHEMA = {
426+ 1 : _ProtoField ("map_head_id" , _WIRE_VARINT , parser = _decode_uint32 ),
260427 2 : _ProtoField ("size_x" , _WIRE_VARINT , parser = _decode_uint32 ),
261428 3 : _ProtoField ("size_y" , _WIRE_VARINT , parser = _decode_uint32 ),
429+ 4 : _ProtoField ("min_x" , _WIRE_FIXED32 , parser = _decode_float32 ),
430+ 5 : _ProtoField ("min_y" , _WIRE_FIXED32 , parser = _decode_float32 ),
431+ 6 : _ProtoField ("max_x" , _WIRE_FIXED32 , parser = _decode_float32 ),
432+ 7 : _ProtoField ("max_y" , _WIRE_FIXED32 , parser = _decode_float32 ),
433+ 8 : _ProtoField ("resolution" , _WIRE_FIXED32 , parser = _decode_float32 ),
262434}
263435
264436_MAP_DATA_INFO_SCHEMA = {
@@ -268,12 +440,23 @@ def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str
268440_ROOM_DATA_INFO_SCHEMA = {
269441 1 : _ProtoField ("room_id" , _WIRE_VARINT , parser = _decode_uint32 ),
270442 2 : _ProtoField ("room_name" , _WIRE_LEN , parser = _decode_utf8 ),
443+ 3 : _ProtoField ("room_type_id" , _WIRE_VARINT , parser = _decode_uint32 ),
444+ 4 : _ProtoField ("material_id" , _WIRE_VARINT , parser = _decode_uint32 ),
445+ 5 : _ProtoField ("clean_state" , _WIRE_VARINT , parser = _decode_uint32 ),
446+ 6 : _ProtoField ("room_clean" , _WIRE_VARINT , parser = _decode_uint32 ),
447+ 7 : _ProtoField ("room_clean_index" , _WIRE_VARINT , parser = _decode_uint32 ),
448+ 8 : _ProtoField ("room_name_post" , _WIRE_LEN , parser = _parse_sc_point ),
449+ 10 : _ProtoField ("color_id" , _WIRE_VARINT , parser = _decode_uint32 ),
450+ 11 : _ProtoField ("floor_direction" , _WIRE_VARINT , parser = _decode_uint32 ),
451+ 12 : _ProtoField ("global_seq" , _WIRE_VARINT , parser = _decode_uint32 ),
271452}
272453
273454_ROBOT_MAP_SCHEMA = {
274- 3 : _ProtoField ("map_head" , _WIRE_LEN , parser = _parse_map_head_info ),
275- 4 : _ProtoField ("map_data" , _WIRE_LEN , parser = _parse_map_data_info ),
276- 12 : _ProtoField ("room_data_info" , _WIRE_LEN , repeated = True , parser = _parse_room_data_info ),
455+ 1 : _ProtoField ("map_type" , _WIRE_VARINT , parser = _decode_uint32 ),
456+ 2 : _ProtoField ("map_ext_info" , _WIRE_LEN , parser = _parse_sc_map_ext_info ),
457+ 3 : _ProtoField ("map_head" , _WIRE_LEN , parser = _parse_sc_map_head ),
458+ 4 : _ProtoField ("map_data" , _WIRE_LEN , parser = _parse_sc_map_data_info ),
459+ 12 : _ProtoField ("room_data_info" , _WIRE_LEN , repeated = True , parser = _parse_sc_room_data ),
277460}
278461
279462
0 commit comments