Skip to content

Commit 14b4bc6

Browse files
authored
feat: parse Q10 archived map packets (#936)
* fix: request Q10 maps without starting cleaning * feat: parse Q10 archived map packets * refactor: give Q10 clean-record maps ownership of historical paths * fix: preserve immutable Q10 points in model conformance checks
1 parent d967baf commit 14b4bc6

6 files changed

Lines changed: 401 additions & 46 deletions

File tree

‎roborock/map/b01_q10_map_parser.py‎

Lines changed: 181 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
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).
@@ -22,6 +23,7 @@
2223
import io
2324
import math
2425
import statistics
26+
import struct
2527
from dataclasses import dataclass, field, replace
2628

2729
from PIL import Image
@@ -30,6 +32,7 @@
3032
from vacuum_map_parser_base.map_data import ImageData, MapData, Point
3133

3234
from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
35+
from roborock.data.code_mappings import RoborockEnum
3336
from roborock.data.containers import RoborockBase
3437
from 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
199223
class 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

307367
def 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

312372
def 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

317377
def 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

465534
def 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

508600
def _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

605758
def erased_packet(packet: "Q10MapPacket", cells: set[int]) -> "Q10MapPacket":

0 commit comments

Comments
 (0)