Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 195 additions & 28 deletions roborock/map/b01_q10_map_parser.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Parser for Roborock Q10 (B01/ss07) map packets.

Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a
``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf
format, the Q10 uses a custom, unencrypted binary packet:
Q10 devices deliver map data as protocol-301 ``MAP_RESPONSE`` pushes. Current
maps follow a read-only status request, while saved-map and clean-record detail
packets follow their respective ``select`` requests. Unlike the Q7 ``SCMap``
protobuf format, the Q10 uses a custom, unencrypted binary packet:

- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
``u16be`` dimensions: grid width (bytes 7-8) and grid height (bytes 9-10).
Expand All @@ -22,13 +23,15 @@
import io
import math
import statistics
import struct
from dataclasses import dataclass, field, replace

from PIL import Image
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.map_data import ImageData, MapData, Point

from roborock.data.code_mappings import RoborockEnum
from roborock.data.containers import RoborockBase
from roborock.exceptions import RoborockException

Expand Down Expand Up @@ -66,9 +69,6 @@ def classify_q10_cell(value: int) -> str:
return LAYER_FLOOR


MAP_PACKET_MARKER = b"\x01\x01"
TRACE_PACKET_MARKER = b"\x02\x01"

_MAP_ID_OFFSET = 2
# Width and height are two consecutive u16be fields. An earlier revision read the
# width as u16le at offset 8; that high byte is actually the height's high byte,
Expand All @@ -83,6 +83,7 @@ def classify_q10_cell(value: int) -> str:
_ROOM_RECORD_LENGTH = 47
_ROOM_NAME_LENGTH_OFFSET = 26
_MAX_ROOMS = 32
_MAX_GRID_CELLS = 16_000_000
# Sanity bound for the erase-zone vector section's vertices-per-polygon field.
_MAX_ERASE_ZONE_VERTICES = 16

Expand Down Expand Up @@ -194,10 +195,30 @@ def charger_pixels(self) -> tuple[float, float] | None:
)


class Q10MapPacketKind(RoborockEnum):
"""Semantic kind identified by a Q10 map packet's two-byte marker."""

CURRENT = 1
TRACE = 2
CLEAN_RECORD_DETAIL = 3
SAVED_MAP_DETAIL = 4

@property
def marker(self) -> bytes:
"""Return the two-byte wire marker for this packet kind."""
return bytes((self.value, 1))

@classmethod
def from_payload(cls, payload: bytes) -> "Q10MapPacketKind | None":
"""Return the recognized kind for a payload marker."""
return next((kind for kind in cls if payload[:2] == kind.marker), None)


@dataclass
class Q10MapPacket:
"""Decoded contents of a Q10 ``01 01`` map packet."""
"""Decoded contents of a Q10 current or archived map packet."""

kind: Q10MapPacketKind
map_id: int
width: int
height: int
Expand All @@ -220,6 +241,18 @@ def layers(self) -> GridLayers:
return decompose_grid(self.width, self.height, self.grid, rooms, classify_q10_cell, flip=False)


@dataclass
class Q10CleanRecordMapPacket(Q10MapPacket):
"""A clean-record map with its embedded historical cleaning path.

Current, saved and clean-record maps share the grid, rooms, erase zones,
carpet and calibration layout. Only clean-record packets carry this path;
it is not a separately received live trace.
"""

historical_trace: "Q10HistoricalTracePacket | None" = None


@dataclass
class Q10Point(RoborockBase):
"""A single point in Q10 map/trace coordinate space."""
Expand Down Expand Up @@ -266,6 +299,27 @@ def robot_position(self) -> Q10Point | None:
return self.points[-1] if self.points else None


@dataclass
class Q10HistoricalTracePacket:
"""Cleaning path embedded in a Q10 ``03 01`` clean-record detail packet.

This is a different wire layout from the live ``02 01`` trace. Its header
carries a 16-bit format version, a 32-bit opaque value, a 32-bit
point count, a signed heading, and a zero reserved word. Points use the same
signed big-endian ``(x, y)`` coordinate pairs as the live trace.
"""

points: list[Q10Point] = field(default_factory=list)
version: int = 0
opaque_value: int = 0
heading: int = 0

@property
def robot_position(self) -> Q10Point | None:
"""The final recorded position, if the historical path is non-empty."""
return self.points[-1] if self.points else None


# Trace packet (``02 01``): a 14-byte header followed by big-endian int16 (x, y)
# point pairs forming the accumulated session path. Header layout confirmed
# against live ss07 captures and cross-checked by @andrewlyeats:
Expand All @@ -275,18 +329,27 @@ def robot_position(self) -> Q10Point | None:
# - bytes 10-11: the 0201 SLAM heading (s16be degrees; 0 = +x, +90 = +y,
# +-180 = -x, -90 = -y) -- the robot's current orientation.
# - bytes 12-13: a constant (0x0000).
# - byte 14 onward: the path points.
# - byte 14 onward: exactly ``point_count`` path points.
# An earlier revision used a 10-byte header, which folded the heading word into
# a phantom leading point ``(heading, 0)`` -- that is the "stray point" the
# heuristic below was papering over, and why the count read "one high". The
# parser reads all 4-byte pairs in the body rather than trusting the count
# field, so a truncated tail can't desync it.
# parser requires the declared point count to match the complete body, so a
# truncated or extended tail cannot be silently interpreted as path data.
# NOTE: the format documented by roborock-qseries-map-bridge (18-byte header)
# did not match this firmware -- this 14-byte layout is what the device sent.
_TRACE_HEADER_LENGTH = 14
_TRACE_SEQUENCE_OFFSET = 3
_TRACE_POINT_COUNT_OFFSET = 8
_TRACE_HEADING_OFFSET = 10

_HISTORICAL_TRACE_HEADER_LENGTH = 14
_HISTORICAL_TRACE_PREFIX_LENGTH = 1
_HISTORICAL_TRACE_VERSION = 1
_HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 2
_HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6
_HISTORICAL_TRACE_HEADING_OFFSET = 10
_HISTORICAL_TRACE_RESERVED_OFFSET = 12

# Some cleans still prepend a single near-origin sentinel as the first real
# point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800));
# it skews the rendered start/bounding box and any path-based calibration. (This
Expand All @@ -301,12 +364,22 @@ def robot_position(self) -> Q10Point | None:

def is_map_packet(payload: bytes) -> bool:
"""Return True if the payload is a Q10 full-map (``01 01``) packet."""
return payload[:2] == MAP_PACKET_MARKER
return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.CURRENT


def is_clean_record_map_packet(payload: bytes) -> bool:
"""Return True for a Q10 clean-record detail (``03 01``) packet."""
return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.CLEAN_RECORD_DETAIL


def is_saved_map_packet(payload: bytes) -> bool:
"""Return True for a Q10 saved-map detail (``04 01``) packet."""
return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.SAVED_MAP_DETAIL


def is_trace_packet(payload: bytes) -> bool:
"""Return True if the payload is a Q10 live trace (``02 01``) packet."""
return payload[:2] == TRACE_PACKET_MARKER
return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.TRACE


def parse_trace_packet(payload: bytes) -> Q10TracePacket:
Expand All @@ -318,6 +391,9 @@ def parse_trace_packet(payload: bytes) -> Q10TracePacket:
body = payload[_TRACE_HEADER_LENGTH:]
if len(body) % 4:
raise RoborockException("Q10 trace points are not 4-byte (x, y) pairs")
declared_point_count = int.from_bytes(payload[_TRACE_POINT_COUNT_OFFSET : _TRACE_POINT_COUNT_OFFSET + 2], "big")
if declared_point_count != len(body) // 4:
raise RoborockException("Q10 trace point count does not match its payload")

heading = int.from_bytes(payload[_TRACE_HEADING_OFFSET : _TRACE_HEADING_OFFSET + 2], "big", signed=True)
points = [
Expand Down Expand Up @@ -348,11 +424,13 @@ def _drop_stray_leading_point(points: list[Q10Point]) -> list[Q10Point]:
return points


def lz4_block_decompress(data: bytes) -> bytes:
def lz4_block_decompress(data: bytes, max_output_size: int) -> bytes:
"""Decompress a raw LZ4 *block* (no frame header).

The Q10 map grid is stored as a single LZ4 block. This implements the
standard LZ4 block format so we don't add a native dependency.
standard LZ4 block format so we don't add a native dependency. Expansion beyond
``max_output_size`` is rejected before
allocating the excess output.
"""
index = 0
output = bytearray()
Expand Down Expand Up @@ -380,6 +458,8 @@ def read_length(value: int) -> int:
end = index + literal_length
if end > len(data):
raise RoborockException("Truncated LZ4 block while reading literals")
if len(output) + literal_length > max_output_size:
raise RoborockException("LZ4 block exceeds maximum output size")
output.extend(data[index:end])
index = end

Expand All @@ -394,6 +474,8 @@ def read_length(value: int) -> int:
raise RoborockException("Invalid LZ4 back-reference offset")

match_length = read_length(token & 0x0F) + 4
if len(output) + match_length > max_output_size:
raise RoborockException("LZ4 block exceeds maximum output size")
for _ in range(match_length):
output.append(output[-offset])

Expand Down Expand Up @@ -458,15 +540,18 @@ def _parse_rooms(room_data: bytes, grid: bytes) -> list[Q10Room]:


def parse_map_packet(payload: bytes) -> Q10MapPacket:
"""Parse a Q10 ``01 01`` map packet into grid + room metadata."""
if len(payload) < _LAYOUT_COMPRESSED_OFFSET or not is_map_packet(payload):
"""Parse a Q10 current or archived map into typed source data."""
kind = Q10MapPacketKind.from_payload(payload)
if len(payload) < _LAYOUT_COMPRESSED_OFFSET or kind is None or kind is Q10MapPacketKind.TRACE:
raise RoborockException("Payload is not a Q10 map packet")

map_id = int.from_bytes(payload[_MAP_ID_OFFSET : _MAP_ID_OFFSET + 4], "big")
width = int.from_bytes(payload[_WIDTH_OFFSET : _WIDTH_OFFSET + 2], "big")
height = int.from_bytes(payload[_HEIGHT_OFFSET : _HEIGHT_OFFSET + 2], "big")
if width <= 0:
raise RoborockException("Q10 map packet has invalid width")
if height > 0 and width * height > _MAX_GRID_CELLS:
raise RoborockException("Q10 map packet dimensions exceed the supported grid size")

compressed_length = int.from_bytes(
payload[_COMPRESSED_LAYOUT_LENGTH_OFFSET : _COMPRESSED_LAYOUT_LENGTH_OFFSET + 2], "big"
Expand All @@ -475,7 +560,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
if compressed_length <= 0 or layout_end > len(payload):
raise RoborockException("Q10 map packet has invalid layout block length")

decoded = lz4_block_decompress(payload[_LAYOUT_COMPRESSED_OFFSET:layout_end])
decoded = lz4_block_decompress(
payload[_LAYOUT_COMPRESSED_OFFSET:layout_end],
max_output_size=_MAX_GRID_CELLS + 2 + _MAX_ROOMS * _ROOM_RECORD_LENGTH,
)
# Prefer the header height; fall back to inference if it doesn't line up
# (e.g. older captures/fixtures that don't populate the height field).
split = _split_with_dims(decoded, width, height) if height > 0 else None
Expand All @@ -486,9 +574,14 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
rooms = _parse_rooms(room_data, grid)
tail = payload[layout_end:]
erase_zones = _parse_erase_zones(tail)
carpet_mask = _parse_carpet_mask(tail, width, height)
carpet_mask, carpet_end = _parse_carpet_block(tail, width, height)
if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL and carpet_end is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when this kind of map packet is received, how many of these other fields are also included?

What i'm wondering is if a historical trace kind has a lot of overlap with other fields in Q10MapPacket or if it needs to be a separate type. basically as more fields are added the map packet seems like a sparse object. You could imagine each kind Q10MapPacketKind has a separate dataclass for example, if the overlap is low.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-reading the code I don't actually see the historical trace even being used anywhere yet, except in the tests.

Naively, it seems to me like we shouldn't be sticking this on to a map package and instead just using the kind to parse a clean record?

historical_trace = _parse_clean_record_trace(tail, carpet_end)
else:
historical_trace = None
header_calibration = _parse_header_calibration(payload)
return Q10MapPacket(
packet = Q10MapPacket(
kind=kind,
map_id=map_id,
width=width,
height=height,
Expand All @@ -498,6 +591,9 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
header_calibration=header_calibration,
carpet_mask=carpet_mask,
)
if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL:
return Q10CleanRecordMapPacket(**vars(packet), historical_trace=historical_trace)
return packet


def _parse_header_calibration(payload: bytes) -> Q10HeaderCalibration | None:
Expand Down Expand Up @@ -569,7 +665,18 @@ def _carpet_offset(tail: bytes) -> int:
return 2 + count * vertices_per * 4


def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
def _erase_section_end(tail: bytes) -> int:
"""Return the end of a complete, structurally valid erase section."""
if len(tail) < 2:
return 0
count, vertices_per = tail[0], tail[1]
if count and not 1 <= vertices_per <= _MAX_ERASE_ZONE_VERTICES:
return 0
end = _carpet_offset(tail)
return end if end <= len(tail) else 0


def _parse_carpet_block(tail: bytes, width: int, height: int) -> tuple[bytes | None, int | None]:
"""Decode the carpet mask that follows the erase section in the packet tail.

Framing matches the main grid block: ``[u32 uncompressed_len]``
Expand All @@ -578,23 +685,83 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
non-zero cell is carpet (the value is the carpet kind). Confirmed byte-exact
on live ss07 captures (R1 / RDC), where ``uncompressed_len == width*height``.

Returns the decompressed mask, or ``None`` if the section is absent or does
not line up (the ``uncompressed_len == width*height`` invariant is used as the
guard so a mis-located section yields no carpet rather than garbage).
Returns the decompressed mask and its end offset. Both are ``None`` if the
section is absent or does not line up. The end offset is used to anchor
optional later sections without scanning arbitrary trailing bytes.
"""
offset = _carpet_offset(tail)
offset = _erase_section_end(tail)
if offset == 0:
return None, None
if offset + 6 > len(tail):
return None
return None, None
uncompressed_len = int.from_bytes(tail[offset : offset + 4], "big")
compressed_len = int.from_bytes(tail[offset + 4 : offset + 6], "big")
block_end = offset + 6 + compressed_len
if uncompressed_len != width * height or compressed_len <= 0 or block_end > len(tail):
return None
return None, None
try:
mask = lz4_block_decompress(tail[offset + 6 : block_end])
mask = lz4_block_decompress(tail[offset + 6 : block_end], max_output_size=width * height)
except RoborockException:
return None, None
if len(mask) != width * height:
return None, None
return mask, block_end


def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
"""Decode only the optional carpet mask (compatibility helper)."""
return _parse_carpet_block(tail, width, height)[0]


def _parse_clean_record_trace(
tail: bytes,
offset: int,
) -> Q10HistoricalTracePacket | None:
"""Decode the bounded historical path following a ``03 01`` carpet block.

The header and declared point count were validated against a physical ss07
clean-record response and its point bytes match captured prefixes of the
corresponding live trace exactly. One observed zero byte precedes the path;
its meaning is unknown, so a non-zero value makes the entire section opaque.
Any unsupported version, non-zero reserved word, or truncated point table is
likewise left completely opaque. Bytes after the declared points are
deliberately not consumed: the observed 12-byte suffix appears structured,
but there is not enough controlled evidence to name or decode it safely.
"""
if offset >= len(tail) or tail[offset] != 0:
return None
return mask if len(mask) == width * height else None
offset += _HISTORICAL_TRACE_PREFIX_LENGTH
header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH
if header_end > len(tail):
return None
version = int.from_bytes(tail[offset : offset + 2], "big")
reserved = int.from_bytes(
tail[offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2],
"big",
)
if version != _HISTORICAL_TRACE_VERSION or reserved != 0:
return None
point_count = int.from_bytes(
tail[offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET : offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET + 4],
"big",
)
points_end = header_end + point_count * 4
if points_end > len(tail):
return None
coordinates = struct.iter_unpack(">hh", memoryview(tail)[header_end:points_end])
return Q10HistoricalTracePacket(
points=[Q10Point(x=x, y=y) for x, y in coordinates],
version=version,
opaque_value=int.from_bytes(
tail[offset + _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET : offset + _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET + 4],
"big",
),
heading=int.from_bytes(
tail[offset + _HISTORICAL_TRACE_HEADING_OFFSET : offset + _HISTORICAL_TRACE_HEADING_OFFSET + 2],
"big",
signed=True,
),
)


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