2525import statistics
2626import struct
2727from dataclasses import dataclass , field , replace
28+ from typing import TypeVar
2829
2930from PIL import Image
3031from vacuum_map_parser_base .config .color import ColorsPalette , SupportedColor
32+ from vacuum_map_parser_base .config .drawable import Drawable
3133from vacuum_map_parser_base .config .image_config import ImageConfig
3234from vacuum_map_parser_base .map_data import ImageData , MapData , Point
3335
@@ -234,6 +236,10 @@ class Q10MapPacket:
234236 carpet (the value is the carpet kind). ``None`` if the packet carried none."""
235237 historical_trace : "Q10HistoricalTracePacket | None" = None
236238 """Cleaning path embedded in a clean-record detail packet, if present."""
239+ obstacles : list ["Q10Obstacle" ] = field (default_factory = list )
240+ """Obstacle markers embedded after the carpet block (50 raw units/pixel)."""
241+ skip_cleaning_points : list ["Q10Point" ] = field (default_factory = list )
242+ """Firmware skip-clean markers embedded after obstacles (10 raw units/pixel)."""
237243
238244 @property
239245 def layers (self ) -> GridLayers :
@@ -251,6 +257,19 @@ class Q10Point(RoborockBase):
251257 y : int
252258
253259
260+ @dataclass
261+ class Q10Obstacle (Q10Point ):
262+ """A Q10 map obstacle marker in its raw map-package coordinate frame.
263+
264+ The map package supplies positions only: there is no validated type,
265+ confidence, or photo identifier on this model. Fifty raw units equal one
266+ occupancy-grid pixel; placement is anchored by the map header origin.
267+ """
268+
269+
270+ _PointType = TypeVar ("_PointType" , bound = Q10Point )
271+
272+
254273@dataclass
255274class Q10TracePacket :
256275 """Decoded contents of a Q10 ``02 01`` cleaning-path packet.
@@ -332,13 +351,12 @@ def robot_position(self) -> Q10Point | None:
332351_TRACE_POINT_COUNT_OFFSET = 8
333352_TRACE_HEADING_OFFSET = 10
334353
335- _HISTORICAL_TRACE_HEADER_LENGTH = 14
336- _HISTORICAL_TRACE_PREFIX_LENGTH = 1
354+ _HISTORICAL_TRACE_HEADER_LENGTH = 13
337355_HISTORICAL_TRACE_VERSION = 1
338- _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 2
339- _HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6
340- _HISTORICAL_TRACE_HEADING_OFFSET = 10
341- _HISTORICAL_TRACE_RESERVED_OFFSET = 12
356+ _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 1
357+ _HISTORICAL_TRACE_POINT_COUNT_OFFSET = 5
358+ _HISTORICAL_TRACE_HEADING_OFFSET = 9
359+ _HISTORICAL_TRACE_RESERVED_OFFSET = 11
342360
343361# Some cleans still prepend a single near-origin sentinel as the first real
344362# point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800));
@@ -565,10 +583,18 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
565583 tail = payload [layout_end :]
566584 erase_zones = _parse_erase_zones (tail )
567585 carpet_mask , carpet_end = _parse_carpet_block (tail , width , height )
568- if kind is Q10MapPacketKind .CLEAN_RECORD_DETAIL and carpet_end is not None :
569- historical_trace , _ = _parse_clean_record_trace (tail , carpet_end )
570- else :
571- historical_trace = None
586+ obstacles : list [Q10Obstacle ] = []
587+ skip_cleaning_points : list [Q10Point ] = []
588+ historical_trace = None
589+ if carpet_end is not None :
590+ parsed_obstacles , obstacle_end = _parse_counted_points (tail , carpet_end , Q10Obstacle )
591+ if obstacle_end is not None :
592+ parsed_skip_points , skip_end = _parse_counted_points (tail , obstacle_end , Q10Point )
593+ if skip_end is not None :
594+ obstacles = parsed_obstacles
595+ skip_cleaning_points = parsed_skip_points
596+ if kind is Q10MapPacketKind .CLEAN_RECORD_DETAIL :
597+ historical_trace , _ = _parse_clean_record_trace (tail , skip_end )
572598 header_calibration = _parse_header_calibration (payload )
573599 return Q10MapPacket (
574600 kind = kind ,
@@ -580,6 +606,8 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
580606 erase_zones = erase_zones ,
581607 header_calibration = header_calibration ,
582608 carpet_mask = carpet_mask ,
609+ obstacles = obstacles ,
610+ skip_cleaning_points = skip_cleaning_points ,
583611 historical_trace = historical_trace ,
584612 )
585613
@@ -701,6 +729,29 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
701729 return _parse_carpet_block (tail , width , height )[0 ]
702730
703731
732+ def _parse_counted_points (
733+ tail : bytes ,
734+ offset : int ,
735+ point_type : type [_PointType ],
736+ ) -> tuple [list [_PointType ], int | None ]:
737+ """Decode one bounded ``u8 count`` + signed-BE ``(x, y)`` point table.
738+
739+ Obstacle and skip-clean sections use the same framing but different
740+ coordinate scales. The caller owns those semantics; this helper only
741+ validates and decodes the table atomically. A truncated table returns no
742+ points and no end offset, preventing later sections from being misaligned.
743+ """
744+ if offset >= len (tail ):
745+ return [], None
746+ count = tail [offset ]
747+ points_start = offset + 1
748+ points_end = points_start + count * 4
749+ if points_end > len (tail ):
750+ return [], None
751+ coordinates = struct .iter_unpack (">hh" , memoryview (tail )[points_start :points_end ])
752+ return ([point_type (x = x , y = y ) for x , y in coordinates ], points_end )
753+
754+
704755def _parse_clean_record_trace (
705756 tail : bytes ,
706757 offset : int ,
@@ -709,20 +760,17 @@ def _parse_clean_record_trace(
709760
710761 The header and declared point count were validated against a physical ss07
711762 clean-record response and its point bytes match captured prefixes of the
712- corresponding live trace exactly. One observed zero byte precedes the path;
713- its meaning is unknown, so a non-zero value makes the entire section opaque.
714- Any unsupported version, non-zero reserved word, or truncated point table is
715- likewise left completely opaque. Bytes after the declared points are
716- deliberately not consumed: the observed 12-byte suffix appears structured,
717- but there is not enough controlled evidence to name or decode it safely .
763+ corresponding live trace exactly. The caller first consumes the obstacle
764+ and skip-clean point tables; ``offset`` therefore starts at the one-byte
765+ path version. Any unsupported version, non-zero reserved word, or truncated
766+ point table is left completely opaque. Bytes after the declared points are
767+ deliberately not consumed: the observed invariant 12-byte suffix appears
768+ structured, but controlled captures disprove it as per-clean obstacles .
718769 """
719- if offset >= len (tail ) or tail [offset ] != 0 :
720- return None , None
721- offset += _HISTORICAL_TRACE_PREFIX_LENGTH
722770 header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH
723771 if header_end > len (tail ):
724772 return None , None
725- version = int . from_bytes ( tail [offset : offset + 2 ], "big" )
773+ version = tail [offset ]
726774 reserved = int .from_bytes (
727775 tail [offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2 ],
728776 "big" ,
@@ -778,6 +826,8 @@ class B01Q10MapParserConfig:
778826
779827 map_scale : int = 4
780828 """Scale factor for the rendered map image."""
829+ drawables : list [Drawable ] | None = None
830+ """Enabled map overlays, or ``None`` for the Q10 defaults."""
781831
782832
783833class B01Q10MapParser :
0 commit comments