11"""Parser for Roborock Q10 (B01/ss07) map packets.
22
3- Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message (pushed a
4- few seconds after a ``dpRequestDps `` request) . Unlike the Q7 ``SCMap`` protobuf
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
55format, the Q10 uses a custom, unencrypted binary packet:
66
77- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
1919https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
2020"""
2121
22- import colorsys
2322import io
2423import math
2524import statistics
2625from dataclasses import dataclass , field , replace
2726
2827from PIL import Image
28+ from vacuum_map_parser_base .config .color import ColorsPalette , SupportedColor
2929from vacuum_map_parser_base .config .image_config import ImageConfig
30- from vacuum_map_parser_base .map_data import ImageData , MapData
30+ from vacuum_map_parser_base .map_data import ImageData , MapData , Point
3131
3232from roborock .exceptions import RoborockException
3333
@@ -129,7 +129,8 @@ class Q10EraseZone:
129129
130130 These are the app's *Erase* tool rectangles -- regions the user marked to be
131131 removed from the map (e.g. phantom floor the lidar mapped through windows).
132- Coordinates are world units (millimetres), same frame as the path/zones.
132+ Coordinates are 5 mm world units, the same frame as restriction zones.
133+ Trace points use a separate 2.5 mm scale.
133134
134135 Confirmed by a controlled diff: removing the two erase zones on a live device
135136 dropped this section's count from 2 to 0 while the grid and the trailing
@@ -639,7 +640,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
639640 width = packet .width ,
640641 image_config = ImageConfig (scale = self ._config .map_scale ),
641642 data = image ,
642- img_transformation = lambda p : p ,
643+ # ImageDimensions uses V1's bottom-up map convention before
644+ # projecting into the top-down PNG. Q10 points are already
645+ # top-down grid pixels, so this adapter cancels that standard flip
646+ # and lets Q10 use the shared V1 ImageGenerator without moving
647+ # overlays vertically.
648+ img_transformation = lambda p : Point (p .x , packet .height - p .y - 1 , p .a ),
643649 )
644650 room_names = {room .id : room .name for room in packet .rooms }
645651 if room_names :
@@ -655,12 +661,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
655661 return ParsedMapData (image_content = image_bytes .getvalue (), map_data = map_data )
656662
657663 def _render (self , packet : Q10MapPacket ) -> Image .Image :
658- """Render the Q10 grid: rooms get distinct colors, walls white, rest dark ."""
664+ """Render the Q10 grid with the V1 map palette ."""
659665 palette = _build_palette (packet .grid )
660- rgb = bytearray ()
666+ rgba = bytearray ()
661667 for value in packet .grid :
662- rgb .extend (palette [value ])
663- img = Image .frombytes ("RGB " , (packet .width , packet .height ), bytes (rgb ))
668+ rgba .extend (palette [value ])
669+ img = Image .frombytes ("RGBA " , (packet .width , packet .height ), bytes (rgba ))
664670 # The ss07 grid is stored top-down (row 0 = top of the home), so it is
665671 # rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
666672 scale = self ._config .map_scale
@@ -669,15 +675,22 @@ def _render(self, packet: Q10MapPacket) -> Image.Image:
669675 return img
670676
671677
672- def _build_palette (grid : bytes ) -> list [tuple [int , int , int ]]:
673- """Map each grid value to an RGB color (rooms distinct, walls white)."""
674- palette : list [tuple [int , int , int ]] = [(28 , 30 , 38 )] * 256 # default: unknown/outside
675- room_values = sorted ({v for v in set (grid ) if 0 < v < _WALL_THRESHOLD })
676- for index , value in enumerate (room_values ):
677- hue = (index * 0.139 ) % 1.0
678- r , g , b = colorsys .hsv_to_rgb (hue , 0.5 , 0.95 )
679- palette [value ] = (int (r * 255 ), int (g * 255 ), int (b * 255 ))
678+ def _opaque (color : tuple [int , ...]) -> tuple [int , int , int , int ]:
679+ """Return a palette color as RGBA."""
680+ return (color [0 ], color [1 ], color [2 ], color [3 ] if len (color ) == 4 else 255 )
681+
682+
683+ def _build_palette (grid : bytes ) -> list [tuple [int , int , int , int ]]:
684+ """Map Q10 cells onto the same colors used by the V1 map renderer."""
685+ colors = ColorsPalette ()
686+ outside = (0 , 0 , 0 , 0 )
687+ palette = [outside ] * 256
688+ for value in {value for value in grid if 0 < value < _WALL_THRESHOLD }:
689+ palette [value ] = _opaque (colors .get_room_color (max (1 , value // 4 )))
690+ wall = _opaque (colors .get_color (SupportedColor .GREY_WALL ))
680691 for value in range (_WALL_THRESHOLD , 256 ):
681- palette [value ] = (235 , 235 , 240 ) # walls / borders
682- palette [0 ] = (28 , 30 , 38 )
692+ palette [value ] = wall
693+ palette [_UNSEGMENTED_FLOOR_VALUE ] = _opaque (colors .get_color (SupportedColor .MAP_INSIDE ))
694+ palette [_BACKGROUND_VALUE ] = outside
695+ palette [0 ] = outside
683696 return palette
0 commit comments