Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
bd4b3f9
feat: add Q10 (B01/ss07) map support with rooms and rendered image
tubededentifrice Jun 14, 2026
1c4353e
feat: add Q10 live position parsing from 02 01 packets
tubededentifrice Jun 14, 2026
57f2639
fix: frame Q10 02 01 trace as full cleaning-session path
tubededentifrice Jun 14, 2026
d45d488
fix: allow Q10 maps without room records
tubededentifrice Jun 14, 2026
e6feafc
refactor: make Q10 map support fully push-driven
tubededentifrice Jun 15, 2026
84fc5e8
feat: decompose Q10 map into separable layers (Tier 1)
tubededentifrice Jun 14, 2026
4f9806c
feat: Q10 map calibration + path/position on map (Tiers 2-3)
tubededentifrice Jun 14, 2026
396958f
feat: decode Q10 vector overlays - no-go/no-mop zones + charger (Tier 4)
tubededentifrice Jun 14, 2026
dac32f2
feat: reuse B01 grid layers + calibration for Q7 (shared abstraction)
tubededentifrice Jun 14, 2026
fbb5c27
feat: decode Q10 carpets from the map packet tail
tubededentifrice Jun 14, 2026
f425757
fix: don't wipe Q10 overlays on partial status updates
tubededentifrice Jun 14, 2026
fc11176
fix: correct Q10 map orientation and identify/apply erase zones
tubededentifrice Jun 14, 2026
4b0ca89
fix: preserve Q10 map overlays after reparse
tubededentifrice Jun 14, 2026
6efe266
fix: adapt Q10 map-layers CLI + overlay routing to push-driven map API
tubededentifrice Jun 15, 2026
74c88e9
Merge origin/main into q10-map-layers
tubededentifrice Jun 23, 2026
48d70c9
feat: derive Q10 calibration origin from the 0101 grid-frame header
tubededentifrice Jun 23, 2026
1a2826e
fix: decode Q10 virtual walls (DP 57) with their own frame
tubededentifrice Jun 23, 2026
2a6059e
test: add live RDC ss07 capture for the DP-57 virtual-wall decoder
tubededentifrice Jun 23, 2026
fa799d4
test: add live RDC ss07 capture for the DP-55 no-go zone decoder
tubededentifrice Jun 23, 2026
1d6956f
fix: decode Q10 trace heading from the 0201 SLAM field (bytes 10-11)
tubededentifrice Jun 24, 2026
cea9c74
fix: decode Q10 virtual walls (DP 57) in the same axis order as no-go…
tubededentifrice Jun 24, 2026
c6285fa
test: add ground-truthed R1 mixed-orientation virtual-wall capture
tubededentifrice Jun 24, 2026
3a03eb3
fix: widen Q10 calibration resolution range to bracket the real 20 pa…
tubededentifrice Jun 24, 2026
b980853
docs: pin Q10 path-unit scale (2.5mm) and ground-truth the heading co…
tubededentifrice Jun 24, 2026
3e9e34d
Merge remote-tracking branch 'origin/main' into q10-map-layers
tubededentifrice Jun 26, 2026
496474c
refactor: reconcile Q10 map dispatch onto #847's typed-message model
tubededentifrice Jun 27, 2026
52e6f1e
feat: decode the Q10 carpet mask from the map-packet tail
tubededentifrice Jun 27, 2026
c4cbe97
Merge branch 'main' into q10-map-layers
tubededentifrice Jun 30, 2026
baf9d70
refactor: route Q10 overlay DPs through the map trait's update_from_dps
tubededentifrice Jun 30, 2026
c646086
Merge remote-tracking branch 'origin/main' into q10-map-layers
tubededentifrice Jul 3, 2026
a3b095f
refactor: extract Q10 map compositing into a single render result object
tubededentifrice Jul 3, 2026
e0032f6
test: give the Q10 render test helper explicit typed params (mypy)
tubededentifrice Jul 3, 2026
c2bddba
Merge branch 'main' into q10-map-layers
tubededentifrice Jul 4, 2026
f9f085c
Merge branch 'main' into q10-map-layers
tubededentifrice Jul 5, 2026
8838626
Merge branch 'main' into q10-map-layers
tubededentifrice Jul 8, 2026
e09e27f
Merge branch 'main' into q10-map-layers
tubededentifrice Jul 19, 2026
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
Prev Previous commit
Next Next commit
feat: decode Q10 carpets from the map packet tail
The map (01 01) packet has a vector section after the compressed grid that the
parser previously ignored: [count][vertices_per] + count polygons of int16-BE
(x,y) pairs = carpet areas (user-defined + auto/self-identifying). Confirmed on
two ss07 devices (R1: 3 carpets, RDC: 2) and explains why the dpCarpetUp DP is
empty -- carpets ride in the map, not a DP. parse_map_packet now returns
packet.carpets; MapContentTrait exposes them and, with calibration, rasterises
them into MapData.carpet_map and draws them. The remaining tail (a run-length
raster + trailing signature) is left for later -- likely the carpet pixel mask
and/or obstacles.
  • Loading branch information
tubededentifrice committed Jun 15, 2026
commit fbb5c27872b8023454c3777e167061e62ba7cfe9
31 changes: 31 additions & 0 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from roborock.map.b01_q10_map_parser import (
B01Q10MapParser,
B01Q10MapParserConfig,
Q10Carpet,
Q10Point,
Q10Room,
decompose_layers,
Expand Down Expand Up @@ -93,6 +94,11 @@ class MapContent(RoborockBase):
virtual_walls: list[Q10Zone] = field(default_factory=list)
"""Virtual walls (line segments) in world coordinates."""

carpets: list[Q10Carpet] = field(default_factory=list)
"""Carpet areas (user-defined + auto-detected) in world coordinates, decoded
from the map packet tail. Placed onto ``map_data.carpet_map`` once a
calibration is available."""

raw_api_response: bytes | None = None
"""Raw bytes of the map payload from the device (opaque blob for re-parsing)."""

Expand Down Expand Up @@ -173,7 +179,10 @@ def parse_map_content(self) -> None:
self.image_content = parsed.image_content
self.map_data = parsed.map_data
self.rooms = packet.rooms
self.carpets = packet.carpets
self.layers = decompose_layers(packet)
if self.calibration is not None:
self._place_zones_on_map_data(self.calibration)

def solve_calibration(self) -> GridCalibration | None:
"""Fit and cache the world<->pixel calibration from the current path.
Expand Down Expand Up @@ -238,6 +247,21 @@ def to_area(zone: Q10Zone) -> Area | None:
walls.append(Wall(p0[0], p0[1], p1[0], p1[1]))
self.map_data.walls = walls or None

# Carpets -> carpet_map (set of pixel indices), filling each rectangle's bbox.
if self.carpets and self.layers is not None:
width, height = self.layers.width, self.layers.height
carpet_cells: set[int] = set()
for carpet in self.carpets:
pixels = [calibration.world_to_pixel(x, y) for x, y in carpet.vertices]
xs = [p[0] for p in pixels]
ys = [p[1] for p in pixels]
x0, x1 = int(min(xs)), int(max(xs))
y0, y1 = int(min(ys)), int(max(ys))
for py in range(max(0, y0), min(height, y1 + 1)):
for px in range(max(0, x0), min(width, x1 + 1)):
carpet_cells.add(py * width + px)
self.map_data.carpet_map = carpet_cells or None

# The robot starts a session at its dock, so the path origin is the charger.
if self.path:
cx, cy = calibration.world_to_pixel(self.path[0].x, self.path[0].y)
Expand Down Expand Up @@ -293,6 +317,13 @@ def world_to_image(x: float, y: float) -> tuple[float, float]:
px, py = calibration.world_to_pixel(x, y)
return (px * scale, (height - 1 - py) * scale)

# Carpets (purple, beneath zones).
for carpet in self.carpets:
if len(carpet.vertices) < 3:
continue
polygon = [world_to_image(x, y) for x, y in carpet.vertices]
draw.polygon(polygon, fill=(150, 90, 220, 60), outline=(120, 60, 190, 200))

# No-go (blue) and no-mop (magenta) zones beneath the path.
for zone in self.zones:
if len(zone.vertices) < 3:
Expand Down
50 changes: 49 additions & 1 deletion roborock/map/b01_q10_map_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def decompose_layers(packet: "Q10MapPacket") -> GridLayers:
_ROOM_RECORD_LENGTH = 47
_ROOM_NAME_LENGTH_OFFSET = 26
_MAX_ROOMS = 32
# Sanity bound for the carpet vector section's vertices-per-polygon field.
_MAX_CARPET_VERTICES = 16

# Grid cell values >= this are walls / borders rather than room segments.
_WALL_THRESHOLD = 240
Expand All @@ -97,6 +99,16 @@ def name(self) -> str:
return self.raw_name.removeprefix("rr_").replace("_", " ").strip().title()


@dataclass
class Q10Carpet:
"""A carpet area (polygon) carried in the map packet, in world coordinates.

Includes both user-defined and auto-detected ("self-identifying") carpets.
"""

vertices: list[tuple[int, int]] = field(default_factory=list)


@dataclass
class Q10MapPacket:
"""Decoded contents of a Q10 ``01 01`` map packet."""
Expand All @@ -106,6 +118,8 @@ class Q10MapPacket:
height: int
grid: bytes
rooms: list[Q10Room] = field(default_factory=list)
carpets: list[Q10Carpet] = field(default_factory=list)
"""Carpet areas decoded from the packet tail (world coordinates)."""


@dataclass
Expand Down Expand Up @@ -295,7 +309,41 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
decoded = lz4_block_decompress(payload[_LAYOUT_COMPRESSED_OFFSET:layout_end])
height, grid, room_data = _infer_layout(decoded, width)
rooms = _parse_rooms(room_data, grid)
return Q10MapPacket(map_id=map_id, width=width, height=height, grid=grid, rooms=rooms)
carpets = _parse_carpets(payload[layout_end:])
return Q10MapPacket(map_id=map_id, width=width, height=height, grid=grid, rooms=rooms, carpets=carpets)


def _parse_carpets(tail: bytes) -> list[Q10Carpet]:
"""Decode carpet areas from the bytes after the compressed grid block.

The tail begins with a vector section ``[count: u8][vertices_per: u8]`` then
``count`` polygons of ``vertices_per`` int16-BE (x, y) pairs (axis-aligned
rectangles in practice). Confirmed on two ss07 devices (R1: 3, RDC: 2). The
remaining tail (a run-length raster + signature) is not decoded here.
"""
if len(tail) < 2:
return []
count = tail[0]
vertices_per = tail[1]
if count == 0 or not 1 <= vertices_per <= _MAX_CARPET_VERTICES:
return []

carpets: list[Q10Carpet] = []
offset = 2
for _ in range(count):
end = offset + vertices_per * 4
if end > len(tail):
break
vertices = [
(
int.from_bytes(tail[offset + j * 4 : offset + j * 4 + 2], "big", signed=True),
int.from_bytes(tail[offset + j * 4 + 2 : offset + j * 4 + 4], "big", signed=True),
)
for j in range(vertices_per)
]
carpets.append(Q10Carpet(vertices=vertices))
offset = end
return carpets


@dataclass
Expand Down
18 changes: 18 additions & 0 deletions tests/map/test_b01_q10_map_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,24 @@ def test_parse_trace_empty_path_has_no_position() -> None:
assert trace.robot_position is None


def test_parse_carpets_from_map_packet_tail() -> None:
"""Carpet rectangles appended after the grid decode to world polygons."""
rects = [
[(100, 200), (300, 200), (300, 50), (100, 50)],
[(-40, -10), (10, -10), (10, -60), (-40, -60)],
]
tail = bytes([len(rects), 4])
for rect in rects:
for x, y in rect:
tail += int.to_bytes(x & 0xFFFF, 2, "big") + int.to_bytes(y & 0xFFFF, 2, "big")
packet = parse_map_packet(FIXTURE.read_bytes() + tail)
assert [c.vertices for c in packet.carpets] == rects # incl. signed coords


def test_parse_map_packet_without_carpet_tail() -> None:
assert parse_map_packet(FIXTURE.read_bytes()).carpets == []


def test_parse_trace_rejects_non_trace_packet() -> None:
with pytest.raises(RoborockException, match="not a Q10 trace packet"):
parse_trace_packet(_payload())
Expand Down