11"""Parser for Roborock Q10 (B01/ss07) map packets.
22
3- Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a
4- ``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf
5- format, the Q10 uses a custom, unencrypted binary packet:
3+ Q10 devices deliver map data as protocol-301 ``MAP_RESPONSE`` pushes. Current
4+ maps follow a read-only status request, while saved-map and clean-record detail
5+ packets follow their respective ``select`` requests. Unlike the Q7 ``SCMap``
6+ protobuf format, the Q10 uses a custom, unencrypted binary packet:
67
78- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
89 ``u16be`` dimensions: grid width (bytes 7-8) and grid height (bytes 9-10).
2223import io
2324import math
2425import statistics
26+ import struct
2527from dataclasses import dataclass , field , replace
2628
2729from PIL import Image
3032from vacuum_map_parser_base .map_data import ImageData , MapData , Point
3133
3234from roborock .data .b01_q10 .b01_q10_containers import Q10RoborockPoint
35+ from roborock .data .code_mappings import RoborockEnum
3336from roborock .data .containers import RoborockBase
3437from roborock .exceptions import RoborockException
3538
@@ -67,9 +70,6 @@ def classify_q10_cell(value: int) -> str:
6770 return LAYER_FLOOR
6871
6972
70- MAP_PACKET_MARKER = b"\x01 \x01 "
71- TRACE_PACKET_MARKER = b"\x02 \x01 "
72-
7373_MAP_ID_OFFSET = 2
7474# Width and height are two consecutive u16be fields. An earlier revision read the
7575# width as u16le at offset 8; that high byte is actually the height's high byte,
@@ -84,6 +84,7 @@ def classify_q10_cell(value: int) -> str:
8484_ROOM_RECORD_LENGTH = 47
8585_ROOM_NAME_LENGTH_OFFSET = 26
8686_MAX_ROOMS = 32
87+ _MAX_GRID_CELLS = 16_000_000
8788# Sanity bound for the erase-zone vector section's vertices-per-polygon field.
8889_MAX_ERASE_ZONE_VERTICES = 16
8990
@@ -195,10 +196,34 @@ def charger_pixels(self) -> tuple[float, float] | None:
195196 )
196197
197198
199+ class Q10MapPacketKind (RoborockEnum ):
200+ """Semantic kind identified by a Q10 map packet's two-byte marker."""
201+
202+ unknown = - 1
203+ CURRENT = 1
204+ TRACE = 2
205+ CLEAN_RECORD_DETAIL = 3
206+ SAVED_MAP_DETAIL = 4
207+
208+ @property
209+ def marker (self ) -> bytes :
210+ """Return the two-byte wire marker for this packet kind."""
211+ return b"" if self is self .unknown else bytes ((self .value , 1 ))
212+
213+ @classmethod
214+ def from_payload (cls , payload : bytes ) -> "Q10MapPacketKind | None" :
215+ """Return the recognized kind for a payload marker."""
216+ if len (payload ) < 2 or payload [1 ] != 1 :
217+ return None
218+ kind = cls (payload [0 ])
219+ return None if kind is cls .unknown else kind
220+
221+
198222@dataclass
199223class Q10MapPacket :
200- """Decoded contents of a Q10 ``01 01`` map packet."""
224+ """Decoded contents of a Q10 current or archived map packet."""
201225
226+ kind : Q10MapPacketKind
202227 map_id : int
203228 width : int
204229 height : int
@@ -271,6 +296,33 @@ def robot_position(self) -> Q10Point | None:
271296 return self .points [- 1 ] if self .points else None
272297
273298
299+ @dataclass
300+ class Q10HistoricalTracePacket :
301+ """Cleaning path embedded in a Q10 ``03 01`` clean-record detail packet.
302+
303+ This is a different wire layout from the live ``02 01`` trace. Its header
304+ carries a 16-bit format version, a 32-bit opaque value, a 32-bit
305+ point count, a signed heading, and a zero reserved word. Points use the same
306+ signed big-endian ``(x, y)`` coordinate pairs as the live trace.
307+ """
308+
309+ points : list [Q10Point ] = field (default_factory = list )
310+ heading : int = 0
311+
312+ @property
313+ def robot_position (self ) -> Q10Point | None :
314+ """The final recorded position, if the historical path is non-empty."""
315+ return self .points [- 1 ] if self .points else None
316+
317+
318+ @dataclass
319+ class Q10CleanRecordDetail :
320+ """A clean-record response containing a map and its recorded path."""
321+
322+ map : Q10MapPacket
323+ trace : Q10HistoricalTracePacket | None = None
324+
325+
274326# Trace packet (``02 01``): a 14-byte header followed by big-endian int16 (x, y)
275327# point pairs forming the accumulated session path. Header layout confirmed
276328# against live ss07 captures and cross-checked by @andrewlyeats:
@@ -280,18 +332,26 @@ def robot_position(self) -> Q10Point | None:
280332# - bytes 10-11: the 0201 SLAM heading (s16be degrees; 0 = +x, +90 = +y,
281333# +-180 = -x, -90 = -y) -- the robot's current orientation.
282334# - bytes 12-13: a constant (0x0000).
283- # - byte 14 onward: the path points.
335+ # - byte 14 onward: exactly ``point_count`` path points.
284336# An earlier revision used a 10-byte header, which folded the heading word into
285337# a phantom leading point ``(heading, 0)`` -- that is the "stray point" the
286338# heuristic below was papering over, and why the count read "one high". The
287- # parser reads all 4-byte pairs in the body rather than trusting the count
288- # field, so a truncated tail can't desync it .
339+ # parser requires the declared point count to match the complete body, so a
340+ # truncated or extended tail cannot be silently interpreted as path data .
289341# NOTE: the format documented by roborock-qseries-map-bridge (18-byte header)
290342# did not match this firmware -- this 14-byte layout is what the device sent.
291343_TRACE_HEADER_LENGTH = 14
292344_TRACE_SEQUENCE_OFFSET = 3
345+ _TRACE_POINT_COUNT_OFFSET = 8
293346_TRACE_HEADING_OFFSET = 10
294347
348+ _HISTORICAL_TRACE_HEADER_LENGTH = 14
349+ _HISTORICAL_TRACE_PREFIX_LENGTH = 1
350+ _HISTORICAL_TRACE_VERSION = 1
351+ _HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6
352+ _HISTORICAL_TRACE_HEADING_OFFSET = 10
353+ _HISTORICAL_TRACE_RESERVED_OFFSET = 12
354+
295355# Some cleans still prepend a single near-origin sentinel as the first real
296356# point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800));
297357# it skews the rendered start/bounding box and any path-based calibration. (This
@@ -306,12 +366,12 @@ def robot_position(self) -> Q10Point | None:
306366
307367def is_map_packet (payload : bytes ) -> bool :
308368 """Return True if the payload is a Q10 full-map (``01 01``) packet."""
309- return payload [: 2 ] == MAP_PACKET_MARKER
369+ return Q10MapPacketKind . from_payload ( payload ) is Q10MapPacketKind . CURRENT
310370
311371
312372def is_trace_packet (payload : bytes ) -> bool :
313373 """Return True if the payload is a Q10 live trace (``02 01``) packet."""
314- return payload [: 2 ] == TRACE_PACKET_MARKER
374+ return Q10MapPacketKind . from_payload ( payload ) is Q10MapPacketKind . TRACE
315375
316376
317377def parse_trace_packet (payload : bytes ) -> Q10TracePacket :
@@ -323,6 +383,9 @@ def parse_trace_packet(payload: bytes) -> Q10TracePacket:
323383 body = payload [_TRACE_HEADER_LENGTH :]
324384 if len (body ) % 4 :
325385 raise RoborockException ("Q10 trace points are not 4-byte (x, y) pairs" )
386+ declared_point_count = int .from_bytes (payload [_TRACE_POINT_COUNT_OFFSET : _TRACE_POINT_COUNT_OFFSET + 2 ], "big" )
387+ if declared_point_count != len (body ) // 4 :
388+ raise RoborockException ("Q10 trace point count does not match its payload" )
326389
327390 heading = int .from_bytes (payload [_TRACE_HEADING_OFFSET : _TRACE_HEADING_OFFSET + 2 ], "big" , signed = True )
328391 points = [
@@ -353,11 +416,13 @@ def _drop_stray_leading_point(points: list[Q10Point]) -> list[Q10Point]:
353416 return points
354417
355418
356- def lz4_block_decompress (data : bytes ) -> bytes :
419+ def lz4_block_decompress (data : bytes , max_output_size : int ) -> bytes :
357420 """Decompress a raw LZ4 *block* (no frame header).
358421
359422 The Q10 map grid is stored as a single LZ4 block. This implements the
360- standard LZ4 block format so we don't add a native dependency.
423+ standard LZ4 block format so we don't add a native dependency. Expansion beyond
424+ ``max_output_size`` is rejected before
425+ allocating the excess output.
361426 """
362427 index = 0
363428 output = bytearray ()
@@ -385,6 +450,8 @@ def read_length(value: int) -> int:
385450 end = index + literal_length
386451 if end > len (data ):
387452 raise RoborockException ("Truncated LZ4 block while reading literals" )
453+ if len (output ) + literal_length > max_output_size :
454+ raise RoborockException ("LZ4 block exceeds maximum output size" )
388455 output .extend (data [index :end ])
389456 index = end
390457
@@ -399,6 +466,8 @@ def read_length(value: int) -> int:
399466 raise RoborockException ("Invalid LZ4 back-reference offset" )
400467
401468 match_length = read_length (token & 0x0F ) + 4
469+ if len (output ) + match_length > max_output_size :
470+ raise RoborockException ("LZ4 block exceeds maximum output size" )
402471 for _ in range (match_length ):
403472 output .append (output [- offset ])
404473
@@ -463,15 +532,33 @@ def _parse_rooms(room_data: bytes, grid: bytes) -> list[Q10Room]:
463532
464533
465534def parse_map_packet (payload : bytes ) -> Q10MapPacket :
466- """Parse a Q10 ``01 01`` map packet into grid + room metadata."""
467- if len (payload ) < _LAYOUT_COMPRESSED_OFFSET or not is_map_packet (payload ):
535+ """Parse the raster and shared metadata of a current or archived map."""
536+ packet , _ , _ = _parse_map_layout (payload )
537+ return packet
538+
539+
540+ def parse_clean_record_detail (payload : bytes ) -> Q10CleanRecordDetail :
541+ """Parse a clean-record response into its map and optional recorded path."""
542+ if Q10MapPacketKind .from_payload (payload ) is not Q10MapPacketKind .CLEAN_RECORD_DETAIL :
543+ raise RoborockException ("Payload is not a Q10 clean-record detail packet" )
544+ packet , tail , trace_offset = _parse_map_layout (payload )
545+ trace = _parse_clean_record_trace (tail , trace_offset ) if trace_offset is not None else None
546+ return Q10CleanRecordDetail (map = packet , trace = trace )
547+
548+
549+ def _parse_map_layout (payload : bytes ) -> tuple [Q10MapPacket , bytes , int | None ]:
550+ """Decode shared map fields and return the tail and historical path offset."""
551+ kind = Q10MapPacketKind .from_payload (payload )
552+ if len (payload ) < _LAYOUT_COMPRESSED_OFFSET or kind is None or kind is Q10MapPacketKind .TRACE :
468553 raise RoborockException ("Payload is not a Q10 map packet" )
469554
470555 map_id = int .from_bytes (payload [_MAP_ID_OFFSET : _MAP_ID_OFFSET + 4 ], "big" )
471556 width = int .from_bytes (payload [_WIDTH_OFFSET : _WIDTH_OFFSET + 2 ], "big" )
472557 height = int .from_bytes (payload [_HEIGHT_OFFSET : _HEIGHT_OFFSET + 2 ], "big" )
473558 if width <= 0 :
474559 raise RoborockException ("Q10 map packet has invalid width" )
560+ if height > 0 and width * height > _MAX_GRID_CELLS :
561+ raise RoborockException ("Q10 map packet dimensions exceed the supported grid size" )
475562
476563 compressed_length = int .from_bytes (
477564 payload [_COMPRESSED_LAYOUT_LENGTH_OFFSET : _COMPRESSED_LAYOUT_LENGTH_OFFSET + 2 ], "big"
@@ -480,7 +567,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
480567 if compressed_length <= 0 or layout_end > len (payload ):
481568 raise RoborockException ("Q10 map packet has invalid layout block length" )
482569
483- decoded = lz4_block_decompress (payload [_LAYOUT_COMPRESSED_OFFSET :layout_end ])
570+ decoded = lz4_block_decompress (
571+ payload [_LAYOUT_COMPRESSED_OFFSET :layout_end ],
572+ max_output_size = _MAX_GRID_CELLS + 2 + _MAX_ROOMS * _ROOM_RECORD_LENGTH ,
573+ )
484574 # Prefer the header height; fall back to inference if it doesn't line up
485575 # (e.g. older captures/fixtures that don't populate the height field).
486576 split = _split_with_dims (decoded , width , height ) if height > 0 else None
@@ -491,9 +581,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
491581 rooms = _parse_rooms (room_data , grid )
492582 tail = payload [layout_end :]
493583 erase_zones = _parse_erase_zones (tail )
494- carpet_mask = _parse_carpet_mask (tail , width , height )
584+ carpet_mask , carpet_end = _parse_carpet_block (tail , width , height )
495585 header_calibration = _parse_header_calibration (payload )
496- return Q10MapPacket (
586+ packet = Q10MapPacket (
587+ kind = kind ,
497588 map_id = map_id ,
498589 width = width ,
499590 height = height ,
@@ -503,6 +594,7 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
503594 header_calibration = header_calibration ,
504595 carpet_mask = carpet_mask ,
505596 )
597+ return packet , tail , carpet_end
506598
507599
508600def _parse_header_calibration (payload : bytes ) -> Q10HeaderCalibration | None :
@@ -574,7 +666,18 @@ def _carpet_offset(tail: bytes) -> int:
574666 return 2 + count * vertices_per * 4
575667
576668
577- def _parse_carpet_mask (tail : bytes , width : int , height : int ) -> bytes | None :
669+ def _erase_section_end (tail : bytes ) -> int :
670+ """Return the end of a complete, structurally valid erase section."""
671+ if len (tail ) < 2 :
672+ return 0
673+ count , vertices_per = tail [0 ], tail [1 ]
674+ if count and not 1 <= vertices_per <= _MAX_ERASE_ZONE_VERTICES :
675+ return 0
676+ end = _carpet_offset (tail )
677+ return end if end <= len (tail ) else 0
678+
679+
680+ def _parse_carpet_block (tail : bytes , width : int , height : int ) -> tuple [bytes | None , int | None ]:
578681 """Decode the carpet mask that follows the erase section in the packet tail.
579682
580683 Framing matches the main grid block: ``[u32 uncompressed_len]``
@@ -583,23 +686,73 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
583686 non-zero cell is carpet (the value is the carpet kind). Confirmed byte-exact
584687 on live ss07 captures (R1 / RDC), where ``uncompressed_len == width*height``.
585688
586- Returns the decompressed mask, or ``None`` if the section is absent or does
587- not line up (the ``uncompressed_len == width*height`` invariant is used as the
588- guard so a mis-located section yields no carpet rather than garbage) .
689+ Returns the decompressed mask and its end offset. Both are ``None`` if the
690+ section is absent or does not line up. The end offset is used to anchor
691+ optional later sections without scanning arbitrary trailing bytes .
589692 """
590- offset = _carpet_offset (tail )
693+ offset = _erase_section_end (tail )
694+ if offset == 0 :
695+ return None , None
591696 if offset + 6 > len (tail ):
592- return None
697+ return None , None
593698 uncompressed_len = int .from_bytes (tail [offset : offset + 4 ], "big" )
594699 compressed_len = int .from_bytes (tail [offset + 4 : offset + 6 ], "big" )
595700 block_end = offset + 6 + compressed_len
596701 if uncompressed_len != width * height or compressed_len <= 0 or block_end > len (tail ):
597- return None
702+ return None , None
598703 try :
599- mask = lz4_block_decompress (tail [offset + 6 : block_end ])
704+ mask = lz4_block_decompress (tail [offset + 6 : block_end ], max_output_size = width * height )
600705 except RoborockException :
706+ return None , None
707+ if len (mask ) != width * height :
708+ return None , None
709+ return mask , block_end
710+
711+
712+ def _parse_clean_record_trace (
713+ tail : bytes ,
714+ offset : int ,
715+ ) -> Q10HistoricalTracePacket | None :
716+ """Decode the bounded historical path following a ``03 01`` carpet block.
717+
718+ The header and declared point count were validated against a physical ss07
719+ clean-record response and its point bytes match captured prefixes of the
720+ corresponding live trace exactly. One observed zero byte precedes the path;
721+ its meaning is unknown, so a non-zero value makes the entire section opaque.
722+ Any unsupported version, non-zero reserved word, or truncated point table is
723+ likewise left completely opaque. Bytes after the declared points are
724+ deliberately not consumed: the observed 12-byte suffix appears structured,
725+ but there is not enough controlled evidence to name or decode it safely.
726+ """
727+ if offset >= len (tail ) or tail [offset ] != 0 :
728+ return None
729+ offset += _HISTORICAL_TRACE_PREFIX_LENGTH
730+ header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH
731+ if header_end > len (tail ):
601732 return None
602- return mask if len (mask ) == width * height else None
733+ version = int .from_bytes (tail [offset : offset + 2 ], "big" )
734+ reserved = int .from_bytes (
735+ tail [offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2 ],
736+ "big" ,
737+ )
738+ if version != _HISTORICAL_TRACE_VERSION or reserved != 0 :
739+ return None
740+ point_count = int .from_bytes (
741+ tail [offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET : offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET + 4 ],
742+ "big" ,
743+ )
744+ points_end = header_end + point_count * 4
745+ if points_end > len (tail ):
746+ return None
747+ coordinates = struct .iter_unpack (">hh" , memoryview (tail )[header_end :points_end ])
748+ return Q10HistoricalTracePacket (
749+ points = _drop_stray_leading_point ([Q10Point (x = x , y = y ) for x , y in coordinates ]),
750+ heading = int .from_bytes (
751+ tail [offset + _HISTORICAL_TRACE_HEADING_OFFSET : offset + _HISTORICAL_TRACE_HEADING_OFFSET + 2 ],
752+ "big" ,
753+ signed = True ,
754+ ),
755+ )
603756
604757
605758def erased_packet (packet : "Q10MapPacket" , cells : set [int ]) -> "Q10MapPacket" :
0 commit comments