Skip to content

Commit 0886033

Browse files
feat: expose shared map layers for Q7
1 parent b00d60b commit 0886033

3 files changed

Lines changed: 107 additions & 2 deletions

File tree

roborock/devices/traits/b01/q7/map_content.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel
1818
from roborock.devices.traits import Trait
1919
from roborock.exceptions import RoborockException
20-
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
20+
from roborock.map.b01_grid_layers import GridCalibration, GridLayers
21+
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig, decompose_q7_layers, q7_calibration
2122
from roborock.roborock_typing import RoborockB01Q7Methods
2223

2324
from .map import MapTrait
@@ -35,6 +36,16 @@ class MapContent(RoborockBase):
3536
map_data: MapData | None = None
3637
"""Parsed map data (metadata for points on the map)."""
3738

39+
layers: GridLayers | None = None
40+
"""Separable map layers (background / wall / floor) in grid-pixel space.
41+
42+
Q7's raster has no per-room segmentation, so ``layers.rooms`` is empty (room
43+
ids/names are in the map metadata)."""
44+
45+
calibration: GridCalibration | None = None
46+
"""World<->pixel transform, read directly from the SCMap ``mapHead``
47+
(``minX``/``minY``/``resolution``); world coordinates are in metres."""
48+
3849
raw_api_response: bytes | None = None
3950
"""Raw bytes of the map payload from the device.
4051
@@ -95,3 +106,9 @@ async def refresh(self) -> None:
95106
self.image_content = parsed_data.image_content
96107
self.map_data = parsed_data.map_data
97108
self.raw_api_response = raw_payload
109+
try:
110+
self.layers = decompose_q7_layers(raw_payload)
111+
self.calibration = q7_calibration(raw_payload)
112+
except RoborockException:
113+
self.layers = None
114+
self.calibration = None

roborock/map/b01_map_parser.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,67 @@
1515
from roborock.exceptions import RoborockException
1616
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1717

18+
from .b01_grid_layers import (
19+
LAYER_BACKGROUND,
20+
LAYER_FLOOR,
21+
LAYER_WALL,
22+
GridCalibration,
23+
GridLayers,
24+
decompose_grid,
25+
)
1826
from .map_parser import ParsedMapData
1927

2028
_MAP_FILE_FORMAT = "PNG"
2129

2230

31+
# The Q7 occupancy grid encodes only these classes (no per-room segmentation in
32+
# the raster -- room ids/names live in the protobuf metadata, not the pixels).
33+
_Q7_WALL_VALUE = 127
34+
_Q7_FLOOR_VALUE = 128
35+
36+
37+
def classify_q7_cell(value: int) -> str:
38+
"""Map a Q7 SCMap grid cell value to a canonical layer class."""
39+
if value == _Q7_WALL_VALUE:
40+
return LAYER_WALL
41+
if value == _Q7_FLOOR_VALUE:
42+
return LAYER_FLOOR
43+
return LAYER_BACKGROUND # 0 = outside / unknown
44+
45+
46+
def decompose_q7_layers(payload: bytes) -> GridLayers:
47+
"""Split an inflated Q7 SCMap into background / wall / floor layers.
48+
49+
Q7 has no per-room raster, so ``GridLayers.rooms`` is empty; room ids/names
50+
are available separately via the map metadata. Reuses the same device-agnostic
51+
decomposition as the Q10.
52+
"""
53+
parsed = _parse_scmap_payload(payload)
54+
size_x, size_y, grid = _extract_grid(parsed)
55+
return decompose_grid(size_x, size_y, grid, [], classify_q7_cell)
56+
57+
58+
def q7_calibration(payload: bytes) -> GridCalibration | None:
59+
"""Build a world<->pixel calibration straight from the Q7 ``mapHead``.
60+
61+
Unlike the Q10 (whose packet carries no calibration), the Q7 SCMap header
62+
provides ``minX``/``minY``/``resolution`` directly, so no path fitting is
63+
needed. World coordinates are in metres; resolution is metres-per-pixel.
64+
"""
65+
head = _parse_scmap_payload(payload).mapHead
66+
if not head.HasField("resolution") or head.resolution <= 0 or not head.HasField("sizeY"):
67+
return None
68+
resolution = head.resolution
69+
min_x = head.minX if head.HasField("minX") else 0.0
70+
min_y = head.minY if head.HasField("minY") else 0.0
71+
return GridCalibration(
72+
resolution=resolution,
73+
origin_x=-min_x / resolution,
74+
origin_y=(head.sizeY - 1) + min_y / resolution,
75+
y_sign=1,
76+
)
77+
78+
2379
@dataclass
2480
class B01MapParserConfig:
2581
"""Configuration for the B01/Q7 map parser."""

tests/map/test_b01_map_parser.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
from PIL import Image
1212

1313
from roborock.exceptions import RoborockException
14-
from roborock.map.b01_map_parser import B01MapParser, _parse_scmap_payload
14+
from roborock.map.b01_grid_layers import LAYER_BACKGROUND, LAYER_FLOOR, LAYER_WALL
15+
from roborock.map.b01_map_parser import (
16+
B01MapParser,
17+
_parse_scmap_payload,
18+
classify_q7_cell,
19+
decompose_q7_layers,
20+
q7_calibration,
21+
)
1522
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
1623
from roborock.protocols.b01_q7_protocol import create_map_key, decode_map_payload
1724

@@ -126,6 +133,31 @@ def test_b01_scmap_parser_maps_observed_schema_fields() -> None:
126133
assert not parsed.roomDataInfo[1].HasField("roomName")
127134

128135

136+
def test_classify_q7_cell() -> None:
137+
assert classify_q7_cell(0) == LAYER_BACKGROUND
138+
assert classify_q7_cell(127) == LAYER_WALL
139+
assert classify_q7_cell(128) == LAYER_FLOOR
140+
141+
142+
def test_q7_layers_and_calibration_from_fixture() -> None:
143+
"""Q7 reuses the shared grid decomposition + reads calibration from mapHead."""
144+
inflated = gzip.decompress(FIXTURE.read_bytes())
145+
146+
layers = decompose_q7_layers(inflated)
147+
assert set(layers.class_counts) == {LAYER_BACKGROUND, LAYER_WALL, LAYER_FLOOR}
148+
assert layers.class_counts[LAYER_FLOOR] > 0
149+
assert layers.rooms == [] # Q7 raster has no per-room segmentation
150+
151+
cal = q7_calibration(inflated)
152+
assert cal is not None
153+
# mapHead gives minX=-5, minY=-7, resolution=0.05 -> origin from those.
154+
assert cal.resolution == pytest.approx(0.05, abs=1e-4)
155+
assert cal.origin_x == pytest.approx(5.0 / cal.resolution, abs=1.0)
156+
# World origin (0,0) maps inside the grid.
157+
px, py = cal.world_to_pixel(0.0, 0.0)
158+
assert 0 <= px < layers.width and 0 <= py < layers.height
159+
160+
129161
def test_b01_map_parser_rejects_invalid_payload() -> None:
130162
parser = B01MapParser()
131163
with pytest.raises(RoborockException, match="Failed to parse B01 SCMap"):

0 commit comments

Comments
 (0)