Skip to content

Commit f9efa68

Browse files
committed
refactor(q7): remove intermediate SCMap mapping layer
1 parent 8611c2b commit f9efa68

File tree

2 files changed

+44
-184
lines changed

2 files changed

+44
-184
lines changed

roborock/map/b01_map_parser.py

Lines changed: 21 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import hashlib
1616
import io
1717
import zlib
18-
from dataclasses import dataclass, field
18+
from dataclasses import dataclass
1919

2020
from Crypto.Cipher import AES
2121
from Crypto.Util.Padding import pad, unpad
@@ -25,85 +25,14 @@
2525
from vacuum_map_parser_base.map_data import ImageData, MapData
2626

2727
from roborock.exceptions import RoborockException
28-
from roborock.map.proto.b01_scmap_pb2 import ( # type: ignore[attr-defined]
29-
DevicePointInfo,
30-
MapBoundaryInfo,
31-
MapExtInfo,
32-
MapHeadInfo,
33-
RobotMap,
34-
RoomDataInfo,
35-
)
28+
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
3629

3730
from .map_parser import ParsedMapData
3831

3932
_B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
4033
_MAP_FILE_FORMAT = "PNG"
4134

4235

43-
@dataclass(frozen=True)
44-
class _ScPoint:
45-
x: float | None = None
46-
y: float | None = None
47-
48-
49-
@dataclass(frozen=True)
50-
class _ScMapBoundaryInfo:
51-
map_md5: str | None = None
52-
v_min_x: int | None = None
53-
v_max_x: int | None = None
54-
v_min_y: int | None = None
55-
v_max_y: int | None = None
56-
57-
58-
@dataclass(frozen=True)
59-
class _ScMapExtInfo:
60-
task_begin_date: int | None = None
61-
map_upload_date: int | None = None
62-
map_valid: int | None = None
63-
radian: int | None = None
64-
force: int | None = None
65-
clean_path: int | None = None
66-
boundary_info: _ScMapBoundaryInfo | None = None
67-
map_version: int | None = None
68-
map_value_type: int | None = None
69-
70-
71-
@dataclass(frozen=True)
72-
class _ScMapHead:
73-
map_head_id: int | None = None
74-
size_x: int | None = None
75-
size_y: int | None = None
76-
min_x: float | None = None
77-
min_y: float | None = None
78-
max_x: float | None = None
79-
max_y: float | None = None
80-
resolution: float | None = None
81-
82-
83-
@dataclass(frozen=True)
84-
class _ScRoomData:
85-
room_id: int | None = None
86-
room_name: str | None = None
87-
room_type_id: int | None = None
88-
material_id: int | None = None
89-
clean_state: int | None = None
90-
room_clean: int | None = None
91-
room_clean_index: int | None = None
92-
room_name_post: _ScPoint | None = None
93-
color_id: int | None = None
94-
floor_direction: int | None = None
95-
global_seq: int | None = None
96-
97-
98-
@dataclass(frozen=True)
99-
class _ScMapPayload:
100-
map_type: int | None = None
101-
map_ext_info: _ScMapExtInfo | None = None
102-
map_head: _ScMapHead | None = None
103-
map_data: bytes | None = None
104-
room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple)
105-
106-
10736
@dataclass
10837
class B01MapParserConfig:
10938
"""Configuration for the B01/Q7 map parser."""
@@ -121,9 +50,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None:
12150
def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData:
12251
"""Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
12352
inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model)
124-
scmap = _parse_scmap_payload(inflated)
125-
size_x, size_y, grid = _extract_grid(scmap)
126-
room_names = _extract_room_names(scmap.room_data_info)
53+
parsed = _parse_scmap_payload(inflated)
54+
size_x, size_y, grid = _extract_grid(parsed)
55+
room_names = _extract_room_names(parsed)
12756

12857
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
12958

@@ -203,108 +132,37 @@ def _decode_map_data_bytes(value: bytes) -> bytes:
203132
return value
204133

205134

206-
def _parse_sc_point(parsed: DevicePointInfo) -> _ScPoint:
207-
return _ScPoint(
208-
x=parsed.x if parsed.HasField("x") else None,
209-
y=parsed.y if parsed.HasField("y") else None,
210-
)
211-
212-
213-
def _parse_sc_map_boundary_info(parsed: MapBoundaryInfo) -> _ScMapBoundaryInfo:
214-
return _ScMapBoundaryInfo(
215-
map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None,
216-
v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None,
217-
v_max_x=parsed.vMaxX if parsed.HasField("vMaxX") else None,
218-
v_min_y=parsed.vMinY if parsed.HasField("vMinY") else None,
219-
v_max_y=parsed.vMaxY if parsed.HasField("vMaxY") else None,
220-
)
221-
222-
223-
def _parse_sc_map_ext_info(parsed: MapExtInfo) -> _ScMapExtInfo:
224-
return _ScMapExtInfo(
225-
task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None,
226-
map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None,
227-
map_valid=parsed.mapValid if parsed.HasField("mapValid") else None,
228-
radian=parsed.radian if parsed.HasField("radian") else None,
229-
force=parsed.force if parsed.HasField("force") else None,
230-
clean_path=parsed.cleanPath if parsed.HasField("cleanPath") else None,
231-
boundary_info=_parse_sc_map_boundary_info(parsed.boudaryInfo) if parsed.HasField("boudaryInfo") else None,
232-
map_version=parsed.mapVersion if parsed.HasField("mapVersion") else None,
233-
map_value_type=parsed.mapValueType if parsed.HasField("mapValueType") else None,
234-
)
235-
236-
237-
def _parse_sc_map_head(parsed: MapHeadInfo) -> _ScMapHead:
238-
return _ScMapHead(
239-
map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None,
240-
size_x=parsed.sizeX if parsed.HasField("sizeX") else None,
241-
size_y=parsed.sizeY if parsed.HasField("sizeY") else None,
242-
min_x=parsed.minX if parsed.HasField("minX") else None,
243-
min_y=parsed.minY if parsed.HasField("minY") else None,
244-
max_x=parsed.maxX if parsed.HasField("maxX") else None,
245-
max_y=parsed.maxY if parsed.HasField("maxY") else None,
246-
resolution=parsed.resolution if parsed.HasField("resolution") else None,
247-
)
248-
249-
250-
def _parse_sc_room_data(parsed: RoomDataInfo) -> _ScRoomData:
251-
return _ScRoomData(
252-
room_id=parsed.roomId if parsed.HasField("roomId") else None,
253-
room_name=parsed.roomName if parsed.HasField("roomName") else None,
254-
room_type_id=parsed.roomTypeId if parsed.HasField("roomTypeId") else None,
255-
material_id=parsed.meterialId if parsed.HasField("meterialId") else None,
256-
clean_state=parsed.cleanState if parsed.HasField("cleanState") else None,
257-
room_clean=parsed.roomClean if parsed.HasField("roomClean") else None,
258-
room_clean_index=parsed.roomCleanIndex if parsed.HasField("roomCleanIndex") else None,
259-
room_name_post=_parse_sc_point(parsed.roomNamePost) if parsed.HasField("roomNamePost") else None,
260-
color_id=parsed.colorId if parsed.HasField("colorId") else None,
261-
floor_direction=parsed.floor_direction if parsed.HasField("floor_direction") else None,
262-
global_seq=parsed.global_seq if parsed.HasField("global_seq") else None,
263-
)
264-
265-
266-
def _parse_scmap_payload(payload: bytes) -> _ScMapPayload:
267-
"""Parse inflated SCMap bytes into typed map metadata."""
135+
def _parse_scmap_payload(payload: bytes) -> RobotMap:
136+
"""Parse inflated SCMap bytes into a generated protobuf message."""
268137
parsed = RobotMap()
269138
_parse_proto(payload, parsed, context="B01 SCMap")
270-
271-
map_data = None
272-
if parsed.HasField("mapData"):
273-
if not parsed.mapData.HasField("mapData"):
274-
raise RoborockException("B01 map payload missing mapData")
275-
map_data = _decode_map_data_bytes(parsed.mapData.mapData)
276-
277-
return _ScMapPayload(
278-
map_type=parsed.mapType if parsed.HasField("mapType") else None,
279-
map_ext_info=_parse_sc_map_ext_info(parsed.mapExtInfo) if parsed.HasField("mapExtInfo") else None,
280-
map_head=_parse_sc_map_head(parsed.mapHead) if parsed.HasField("mapHead") else None,
281-
map_data=map_data,
282-
room_data_info=tuple(_parse_sc_room_data(room) for room in parsed.roomDataInfo),
283-
)
139+
return parsed
284140

285141

286-
def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]:
287-
if scmap.map_head is None or scmap.map_data is None:
142+
def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]:
143+
if not parsed.HasField("mapHead") or not parsed.HasField("mapData"):
288144
raise RoborockException("Failed to parse B01 map header/grid")
289145

290-
size_x = scmap.map_head.size_x or 0
291-
size_y = scmap.map_head.size_y or 0
292-
if not size_x or not size_y or not scmap.map_data:
146+
size_x = parsed.mapHead.sizeX if parsed.mapHead.HasField("sizeX") else 0
147+
size_y = parsed.mapHead.sizeY if parsed.mapHead.HasField("sizeY") else 0
148+
if not size_x or not size_y or not parsed.mapData.HasField("mapData"):
293149
raise RoborockException("Failed to parse B01 map header/grid")
294150

151+
map_data = _decode_map_data_bytes(parsed.mapData.mapData)
295152
expected_len = size_x * size_y
296-
if len(scmap.map_data) < expected_len:
153+
if len(map_data) < expected_len:
297154
raise RoborockException("B01 map data shorter than expected dimensions")
298155

299-
return size_x, size_y, scmap.map_data[:expected_len]
156+
return size_x, size_y, map_data[:expected_len]
300157

301158

302-
def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]:
159+
def _extract_room_names(parsed: RobotMap) -> dict[int, str]:
303160
# Expose room id/name mapping without inventing room geometry/polygons.
304161
room_names: dict[int, str] = {}
305-
for room in rooms:
306-
if room.room_id is not None:
307-
room_names[room.room_id] = room.room_name or f"Room {room.room_id}"
162+
for room in parsed.roomDataInfo:
163+
if room.HasField("roomId"):
164+
room_id = room.roomId
165+
room_names[room_id] = room.roomName if room.HasField("roomName") else f"Room {room_id}"
308166
return room_names
309167

310168

tests/map/test_b01_map_parser.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -144,27 +144,29 @@ def test_b01_scmap_parser_maps_observed_schema_fields() -> None:
144144

145145
parsed = _parse_scmap_payload(payload)
146146

147-
assert parsed.map_type == 1
148-
assert parsed.map_ext_info is not None
149-
assert parsed.map_ext_info.task_begin_date == 100
150-
assert parsed.map_ext_info.map_upload_date == 200
151-
assert parsed.map_ext_info.boundary_info is not None
152-
assert parsed.map_ext_info.boundary_info.v_max_y == 40
153-
assert parsed.map_head is not None
154-
assert parsed.map_head.map_head_id == 7
155-
assert parsed.map_head.size_x == 2
156-
assert parsed.map_head.size_y == 2
157-
assert parsed.map_head.resolution == pytest.approx(0.05)
158-
assert parsed.map_data == bytes([0, 127, 128, 128])
159-
assert parsed.room_data_info[0].room_id == 42
160-
assert parsed.room_data_info[0].room_name == "Kitchen"
161-
assert parsed.room_data_info[0].room_name_post is not None
162-
assert parsed.room_data_info[0].room_name_post.x == pytest.approx(11.25)
163-
assert parsed.room_data_info[0].room_name_post.y == pytest.approx(22.5)
164-
assert parsed.room_data_info[0].color_id == 7
165-
assert parsed.room_data_info[0].global_seq == 9
166-
assert parsed.room_data_info[1].room_id == 99
167-
assert parsed.room_data_info[1].room_name is None
147+
assert parsed.mapType == 1
148+
assert parsed.HasField("mapExtInfo")
149+
assert parsed.mapExtInfo.taskBeginDate == 100
150+
assert parsed.mapExtInfo.mapUploadDate == 200
151+
assert parsed.mapExtInfo.HasField("boudaryInfo")
152+
assert parsed.mapExtInfo.boudaryInfo.vMaxY == 40
153+
assert parsed.HasField("mapHead")
154+
assert parsed.mapHead.mapHeadId == 7
155+
assert parsed.mapHead.sizeX == 2
156+
assert parsed.mapHead.sizeY == 2
157+
assert parsed.mapHead.resolution == pytest.approx(0.05)
158+
assert parsed.HasField("mapData")
159+
assert parsed.mapData.HasField("mapData")
160+
assert zlib.decompress(parsed.mapData.mapData) == bytes([0, 127, 128, 128])
161+
assert parsed.roomDataInfo[0].roomId == 42
162+
assert parsed.roomDataInfo[0].roomName == "Kitchen"
163+
assert parsed.roomDataInfo[0].HasField("roomNamePost")
164+
assert parsed.roomDataInfo[0].roomNamePost.x == pytest.approx(11.25)
165+
assert parsed.roomDataInfo[0].roomNamePost.y == pytest.approx(22.5)
166+
assert parsed.roomDataInfo[0].colorId == 7
167+
assert parsed.roomDataInfo[0].global_seq == 9
168+
assert parsed.roomDataInfo[1].roomId == 99
169+
assert not parsed.roomDataInfo[1].HasField("roomName")
168170

169171

170172
def test_b01_map_parser_rejects_invalid_payload() -> None:

0 commit comments

Comments
 (0)