66- PKCS7 padded
77- ASCII hex for a zlib-compressed SCMap payload
88
9- The inner SCMap blob is a protobuf-wire message. We know the app's field layout
10- well enough to describe the fields we care about declaratively, but we still
11- avoid shipping generated protobuf classes from a reverse-engineered schema.
12- That keeps the runtime parser narrow without overstating certainty about the
13- full message definition.
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.
1412"""
1513
1614from __future__ import annotations
1917import binascii
2018import hashlib
2119import io
22- import struct
2320import zlib
2421from collections .abc import Callable
25- from dataclasses import dataclass , field
22+ from dataclasses import dataclass
2623
2724from Crypto .Cipher import AES
2825from Crypto .Util .Padding import pad , unpad
@@ -52,70 +49,6 @@ class _ProtoField:
5249 parser : Callable [[object ], object ] | None = None
5350
5451
55- @dataclass (frozen = True )
56- class _ScPoint :
57- x : float | None = None
58- y : float | None = None
59-
60-
61- @dataclass (frozen = True )
62- class _ScMapBoundaryInfo :
63- map_md5 : str | None = None
64- v_min_x : int | None = None
65- v_max_x : int | None = None
66- v_min_y : int | None = None
67- v_max_y : int | None = None
68-
69-
70- @dataclass (frozen = True )
71- class _ScMapExtInfo :
72- task_begin_date : int | None = None
73- map_upload_date : int | None = None
74- map_valid : int | None = None
75- radian : int | None = None
76- force : int | None = None
77- clean_path : int | None = None
78- boundary_info : _ScMapBoundaryInfo | None = None
79- map_version : int | None = None
80- map_value_type : int | None = None
81-
82-
83- @dataclass (frozen = True )
84- class _ScMapHead :
85- map_head_id : int | None = None
86- size_x : int | None = None
87- size_y : int | None = None
88- min_x : float | None = None
89- min_y : float | None = None
90- max_x : float | None = None
91- max_y : float | None = None
92- resolution : float | None = None
93-
94-
95- @dataclass (frozen = True )
96- class _ScRoomData :
97- room_id : int | None = None
98- room_name : str | None = None
99- room_type_id : int | None = None
100- material_id : int | None = None
101- clean_state : int | None = None
102- room_clean : int | None = None
103- room_clean_index : int | None = None
104- room_name_post : _ScPoint | None = None
105- color_id : int | None = None
106- floor_direction : int | None = None
107- global_seq : int | None = None
108-
109-
110- @dataclass (frozen = True )
111- class _ScMapPayload :
112- map_type : int | None = None
113- map_ext_info : _ScMapExtInfo | None = None
114- map_head : _ScMapHead | None = None
115- map_data : bytes | None = None
116- room_data_info : tuple [_ScRoomData , ...] = field (default_factory = tuple )
117-
118-
11952@dataclass
12053class B01MapParserConfig :
12154 """Configuration for the B01/Q7 map parser."""
@@ -133,9 +66,7 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None:
13366 def parse (self , raw_payload : bytes , * , serial : str , model : str ) -> ParsedMapData :
13467 """Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
13568 inflated = _decode_b01_map_payload (raw_payload , serial = serial , model = model )
136- scmap = _parse_scmap_payload (inflated )
137- size_x , size_y , grid = _extract_grid (scmap )
138- room_names = _extract_room_names (scmap .room_data_info )
69+ size_x , size_y , grid , room_names = _parse_scmap_payload (inflated )
13970
14071 image = _render_occupancy_image (grid , size_x = size_x , size_y = size_y , scale = self ._config .map_scale )
14172
@@ -247,10 +178,6 @@ def _decode_utf8(value: bytes) -> str:
247178 return value .decode ("utf-8" , errors = "replace" )
248179
249180
250- def _decode_float32 (value : bytes ) -> float :
251- return struct .unpack ("<f" , value )[0 ]
252-
253-
254181def _decode_map_data_bytes (value : bytes ) -> bytes :
255182 try :
256183 return zlib .decompress (value )
@@ -291,145 +218,47 @@ def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context
291218 return parsed
292219
293220
294- def _parse_sc_point (blob : bytes ) -> _ScPoint :
295- parsed = _parse_proto_message (blob , _DEVICE_POINT_INFO_SCHEMA , context = "B01 DevicePointInfo" )
296- return _ScPoint (x = parsed .get ("x" ), y = parsed .get ("y" ))
297-
298-
299- def _parse_sc_map_boundary_info (blob : bytes ) -> _ScMapBoundaryInfo :
300- parsed = _parse_proto_message (blob , _MAP_BOUNDARY_INFO_SCHEMA , context = "B01 MapBoundaryInfo" )
301- return _ScMapBoundaryInfo (
302- map_md5 = parsed .get ("map_md5" ),
303- v_min_x = parsed .get ("v_min_x" ),
304- v_max_x = parsed .get ("v_max_x" ),
305- v_min_y = parsed .get ("v_min_y" ),
306- v_max_y = parsed .get ("v_max_y" ),
307- )
308-
309-
310- def _parse_sc_map_ext_info (blob : bytes ) -> _ScMapExtInfo :
311- parsed = _parse_proto_message (blob , _MAP_EXT_INFO_SCHEMA , context = "B01 MapExtInfo" )
312- return _ScMapExtInfo (
313- task_begin_date = parsed .get ("task_begin_date" ),
314- map_upload_date = parsed .get ("map_upload_date" ),
315- map_valid = parsed .get ("map_valid" ),
316- radian = parsed .get ("radian" ),
317- force = parsed .get ("force" ),
318- clean_path = parsed .get ("clean_path" ),
319- boundary_info = parsed .get ("boundary_info" ),
320- map_version = parsed .get ("map_version" ),
321- map_value_type = parsed .get ("map_value_type" ),
322- )
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" )
323224
324225
325- def _parse_sc_map_head (blob : bytes ) -> _ScMapHead :
326- parsed = _parse_proto_message (blob , _MAP_HEAD_INFO_SCHEMA , context = "B01 MapHeadInfo" )
327- return _ScMapHead (
328- map_head_id = parsed .get ("map_head_id" ),
329- size_x = parsed .get ("size_x" ),
330- size_y = parsed .get ("size_y" ),
331- min_x = parsed .get ("min_x" ),
332- min_y = parsed .get ("min_y" ),
333- max_x = parsed .get ("max_x" ),
334- max_y = parsed .get ("max_y" ),
335- resolution = parsed .get ("resolution" ),
336- )
337-
338-
339- def _parse_sc_map_data_info (blob : bytes ) -> bytes :
226+ def _parse_map_data_info (blob : bytes ) -> bytes :
340227 parsed = _parse_proto_message (blob , _MAP_DATA_INFO_SCHEMA , context = "B01 MapDataInfo" )
341228 if (map_data := parsed .get ("map_data" )) is None :
342229 raise RoborockException ("B01 map payload missing mapData" )
343230 return map_data
344231
345232
346- def _parse_sc_room_data (blob : bytes ) -> _ScRoomData :
233+ def _parse_room_data_info (blob : bytes ) -> tuple [ int | None , str | None ] :
347234 parsed = _parse_proto_message (blob , _ROOM_DATA_INFO_SCHEMA , context = "B01 RoomDataInfo" )
348- return _ScRoomData (
349- room_id = parsed .get ("room_id" ),
350- room_name = parsed .get ("room_name" ),
351- room_type_id = parsed .get ("room_type_id" ),
352- material_id = parsed .get ("material_id" ),
353- clean_state = parsed .get ("clean_state" ),
354- room_clean = parsed .get ("room_clean" ),
355- room_clean_index = parsed .get ("room_clean_index" ),
356- room_name_post = parsed .get ("room_name_post" ),
357- color_id = parsed .get ("color_id" ),
358- floor_direction = parsed .get ("floor_direction" ),
359- global_seq = parsed .get ("global_seq" ),
360- )
361-
362-
363- def _parse_scmap_payload (payload : bytes ) -> _ScMapPayload :
364- """Parse inflated SCMap bytes using the reverse-engineered app field layout."""
365- parsed = _parse_proto_message (payload , _ROBOT_MAP_SCHEMA , context = "B01 SCMap" )
366- return _ScMapPayload (
367- map_type = parsed .get ("map_type" ),
368- map_ext_info = parsed .get ("map_ext_info" ),
369- map_head = parsed .get ("map_head" ),
370- map_data = parsed .get ("map_data" ),
371- room_data_info = tuple (parsed .get ("room_data_info" , [])),
372- )
235+ return parsed .get ("room_id" ), parsed .get ("room_name" )
373236
374237
375- def _extract_grid ( scmap : _ScMapPayload ) -> tuple [int , int , bytes ]:
376- if scmap . map_head is None or scmap . map_data is None :
377- raise RoborockException ( "Failed to parse B01 map header/grid " )
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 " )
378241
379- size_x = scmap . map_head . size_x or 0
380- size_y = scmap . map_head . size_y or 0
381- if not size_x or not size_y or not scmap . map_data :
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 :
382245 raise RoborockException ("Failed to parse B01 map header/grid" )
383246
384247 expected_len = size_x * size_y
385- if len (scmap . map_data ) < expected_len :
248+ if len (grid ) < expected_len :
386249 raise RoborockException ("B01 map data shorter than expected dimensions" )
387250
388- return size_x , size_y , scmap .map_data [:expected_len ]
389-
390-
391- def _extract_room_names (rooms : tuple [_ScRoomData , ...]) -> dict [int , str ]:
392251 room_names : dict [int , str ] = {}
393- for room in rooms :
394- if room .room_id is not None :
395- room_names [room .room_id ] = room .room_name or f"Room { room .room_id } "
396- return room_names
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 } "
397255
256+ return size_x , size_y , grid [:expected_len ], room_names
398257
399- _DEVICE_POINT_INFO_SCHEMA = {
400- 1 : _ProtoField ("x" , _WIRE_FIXED32 , parser = _decode_float32 ),
401- 2 : _ProtoField ("y" , _WIRE_FIXED32 , parser = _decode_float32 ),
402- }
403-
404- _MAP_BOUNDARY_INFO_SCHEMA = {
405- 1 : _ProtoField ("map_md5" , _WIRE_LEN , parser = _decode_utf8 ),
406- 2 : _ProtoField ("v_min_x" , _WIRE_VARINT , parser = _decode_uint32 ),
407- 3 : _ProtoField ("v_max_x" , _WIRE_VARINT , parser = _decode_uint32 ),
408- 4 : _ProtoField ("v_min_y" , _WIRE_VARINT , parser = _decode_uint32 ),
409- 5 : _ProtoField ("v_max_y" , _WIRE_VARINT , parser = _decode_uint32 ),
410- }
411-
412- _MAP_EXT_INFO_SCHEMA = {
413- 1 : _ProtoField ("task_begin_date" , _WIRE_VARINT , parser = _decode_uint32 ),
414- 2 : _ProtoField ("map_upload_date" , _WIRE_VARINT , parser = _decode_uint32 ),
415- 3 : _ProtoField ("map_valid" , _WIRE_VARINT , parser = _decode_uint32 ),
416- 4 : _ProtoField ("radian" , _WIRE_VARINT , parser = _decode_uint32 ),
417- 5 : _ProtoField ("force" , _WIRE_VARINT , parser = _decode_uint32 ),
418- 6 : _ProtoField ("clean_path" , _WIRE_VARINT , parser = _decode_uint32 ),
419- 7 : _ProtoField ("boundary_info" , _WIRE_LEN , parser = _parse_sc_map_boundary_info ),
420- 8 : _ProtoField ("map_version" , _WIRE_VARINT , parser = _decode_uint32 ),
421- 9 : _ProtoField ("map_value_type" , _WIRE_VARINT , parser = _decode_uint32 ),
422- }
423258
424259_MAP_HEAD_INFO_SCHEMA = {
425- 1 : _ProtoField ("map_head_id" , _WIRE_VARINT , parser = _decode_uint32 ),
426260 2 : _ProtoField ("size_x" , _WIRE_VARINT , parser = _decode_uint32 ),
427261 3 : _ProtoField ("size_y" , _WIRE_VARINT , parser = _decode_uint32 ),
428- 4 : _ProtoField ("min_x" , _WIRE_FIXED32 , parser = _decode_float32 ),
429- 5 : _ProtoField ("min_y" , _WIRE_FIXED32 , parser = _decode_float32 ),
430- 6 : _ProtoField ("max_x" , _WIRE_FIXED32 , parser = _decode_float32 ),
431- 7 : _ProtoField ("max_y" , _WIRE_FIXED32 , parser = _decode_float32 ),
432- 8 : _ProtoField ("resolution" , _WIRE_FIXED32 , parser = _decode_float32 ),
433262}
434263
435264_MAP_DATA_INFO_SCHEMA = {
@@ -439,23 +268,12 @@ def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]:
439268_ROOM_DATA_INFO_SCHEMA = {
440269 1 : _ProtoField ("room_id" , _WIRE_VARINT , parser = _decode_uint32 ),
441270 2 : _ProtoField ("room_name" , _WIRE_LEN , parser = _decode_utf8 ),
442- 3 : _ProtoField ("room_type_id" , _WIRE_VARINT , parser = _decode_uint32 ),
443- 4 : _ProtoField ("material_id" , _WIRE_VARINT , parser = _decode_uint32 ),
444- 5 : _ProtoField ("clean_state" , _WIRE_VARINT , parser = _decode_uint32 ),
445- 6 : _ProtoField ("room_clean" , _WIRE_VARINT , parser = _decode_uint32 ),
446- 7 : _ProtoField ("room_clean_index" , _WIRE_VARINT , parser = _decode_uint32 ),
447- 8 : _ProtoField ("room_name_post" , _WIRE_LEN , parser = _parse_sc_point ),
448- 10 : _ProtoField ("color_id" , _WIRE_VARINT , parser = _decode_uint32 ),
449- 11 : _ProtoField ("floor_direction" , _WIRE_VARINT , parser = _decode_uint32 ),
450- 12 : _ProtoField ("global_seq" , _WIRE_VARINT , parser = _decode_uint32 ),
451271}
452272
453273_ROBOT_MAP_SCHEMA = {
454- 1 : _ProtoField ("map_type" , _WIRE_VARINT , parser = _decode_uint32 ),
455- 2 : _ProtoField ("map_ext_info" , _WIRE_LEN , parser = _parse_sc_map_ext_info ),
456- 3 : _ProtoField ("map_head" , _WIRE_LEN , parser = _parse_sc_map_head ),
457- 4 : _ProtoField ("map_data" , _WIRE_LEN , parser = _parse_sc_map_data_info ),
458- 12 : _ProtoField ("room_data_info" , _WIRE_LEN , repeated = True , parser = _parse_sc_room_data ),
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 ),
459277}
460278
461279
0 commit comments