Skip to content

Commit 971eed7

Browse files
committed
refactor: restore declarative q7 scmap fields
1 parent f8c069c commit 971eed7

File tree

2 files changed

+281
-51
lines changed

2 files changed

+281
-51
lines changed

roborock/map/b01_map_parser.py

Lines changed: 208 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
- PKCS7 padded
77
- ASCII hex for a zlib-compressed SCMap payload
88
9-
The inner SCMap blob uses protobuf wire encoding. We keep the runtime parser
10-
minimal and declarative: only the fields needed to render the occupancy image
11-
and expose room names are described here.
9+
The inner SCMap blob is a protobuf-wire message. The declarative field layout
10+
below is taken from the reverse-engineered Q7 mobile app bundle (RR_API), so
11+
these fields are grounded in observed app schema/post-processing code rather
12+
than guessed parser branches. We still avoid shipping generated protobuf
13+
classes from a reverse-engineered schema, which keeps the runtime parser
14+
explicit without overstating certainty about the full message definition.
1215
"""
1316

1417
from __future__ import annotations
@@ -17,9 +20,10 @@
1720
import binascii
1821
import hashlib
1922
import io
23+
import struct
2024
import zlib
2125
from collections.abc import Callable
22-
from dataclasses import dataclass
26+
from dataclasses import dataclass, field
2327

2428
from Crypto.Cipher import AES
2529
from Crypto.Util.Padding import pad, unpad
@@ -49,6 +53,70 @@ class _ProtoField:
4953
parser: Callable[[object], object] | None = None
5054

5155

56+
@dataclass(frozen=True)
57+
class _ScPoint:
58+
x: float | None = None
59+
y: float | None = None
60+
61+
62+
@dataclass(frozen=True)
63+
class _ScMapBoundaryInfo:
64+
map_md5: str | None = None
65+
v_min_x: int | None = None
66+
v_max_x: int | None = None
67+
v_min_y: int | None = None
68+
v_max_y: int | None = None
69+
70+
71+
@dataclass(frozen=True)
72+
class _ScMapExtInfo:
73+
task_begin_date: int | None = None
74+
map_upload_date: int | None = None
75+
map_valid: int | None = None
76+
radian: int | None = None
77+
force: int | None = None
78+
clean_path: int | None = None
79+
boundary_info: _ScMapBoundaryInfo | None = None
80+
map_version: int | None = None
81+
map_value_type: int | None = None
82+
83+
84+
@dataclass(frozen=True)
85+
class _ScMapHead:
86+
map_head_id: int | None = None
87+
size_x: int | None = None
88+
size_y: int | None = None
89+
min_x: float | None = None
90+
min_y: float | None = None
91+
max_x: float | None = None
92+
max_y: float | None = None
93+
resolution: float | None = None
94+
95+
96+
@dataclass(frozen=True)
97+
class _ScRoomData:
98+
room_id: int | None = None
99+
room_name: str | None = None
100+
room_type_id: int | None = None
101+
material_id: int | None = None
102+
clean_state: int | None = None
103+
room_clean: int | None = None
104+
room_clean_index: int | None = None
105+
room_name_post: _ScPoint | None = None
106+
color_id: int | None = None
107+
floor_direction: int | None = None
108+
global_seq: int | None = None
109+
110+
111+
@dataclass(frozen=True)
112+
class _ScMapPayload:
113+
map_type: int | None = None
114+
map_ext_info: _ScMapExtInfo | None = None
115+
map_head: _ScMapHead | None = None
116+
map_data: bytes | None = None
117+
room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple)
118+
119+
52120
@dataclass
53121
class B01MapParserConfig:
54122
"""Configuration for the B01/Q7 map parser."""
@@ -66,7 +134,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None:
66134
def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData:
67135
"""Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
68136
inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model)
69-
size_x, size_y, grid, room_names = _parse_scmap_payload(inflated)
137+
scmap = _parse_scmap_payload(inflated)
138+
size_x, size_y, grid = _extract_grid(scmap)
139+
room_names = _extract_room_names(scmap.room_data_info)
70140

71141
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
72142

@@ -178,6 +248,10 @@ def _decode_utf8(value: bytes) -> str:
178248
return value.decode("utf-8", errors="replace")
179249

180250

251+
def _decode_float32(value: bytes) -> float:
252+
return struct.unpack("<f", value)[0]
253+
254+
181255
def _decode_map_data_bytes(value: bytes) -> bytes:
182256
try:
183257
return zlib.decompress(value)
@@ -218,47 +292,145 @@ def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context
218292
return parsed
219293

220294

221-
def _parse_map_head_info(blob: bytes) -> tuple[int | None, int | None]:
222-
parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo")
223-
return parsed.get("size_x"), parsed.get("size_y")
295+
def _parse_sc_point(blob: bytes) -> _ScPoint:
296+
parsed = _parse_proto_message(blob, _DEVICE_POINT_INFO_SCHEMA, context="B01 DevicePointInfo")
297+
return _ScPoint(x=parsed.get("x"), y=parsed.get("y"))
298+
299+
300+
def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo:
301+
parsed = _parse_proto_message(blob, _MAP_BOUNDARY_INFO_SCHEMA, context="B01 MapBoundaryInfo")
302+
return _ScMapBoundaryInfo(
303+
map_md5=parsed.get("map_md5"),
304+
v_min_x=parsed.get("v_min_x"),
305+
v_max_x=parsed.get("v_max_x"),
306+
v_min_y=parsed.get("v_min_y"),
307+
v_max_y=parsed.get("v_max_y"),
308+
)
224309

225310

226-
def _parse_map_data_info(blob: bytes) -> bytes:
311+
def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo:
312+
parsed = _parse_proto_message(blob, _MAP_EXT_INFO_SCHEMA, context="B01 MapExtInfo")
313+
return _ScMapExtInfo(
314+
task_begin_date=parsed.get("task_begin_date"),
315+
map_upload_date=parsed.get("map_upload_date"),
316+
map_valid=parsed.get("map_valid"),
317+
radian=parsed.get("radian"),
318+
force=parsed.get("force"),
319+
clean_path=parsed.get("clean_path"),
320+
boundary_info=parsed.get("boundary_info"),
321+
map_version=parsed.get("map_version"),
322+
map_value_type=parsed.get("map_value_type"),
323+
)
324+
325+
326+
def _parse_sc_map_head(blob: bytes) -> _ScMapHead:
327+
parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo")
328+
return _ScMapHead(
329+
map_head_id=parsed.get("map_head_id"),
330+
size_x=parsed.get("size_x"),
331+
size_y=parsed.get("size_y"),
332+
min_x=parsed.get("min_x"),
333+
min_y=parsed.get("min_y"),
334+
max_x=parsed.get("max_x"),
335+
max_y=parsed.get("max_y"),
336+
resolution=parsed.get("resolution"),
337+
)
338+
339+
340+
def _parse_sc_map_data_info(blob: bytes) -> bytes:
227341
parsed = _parse_proto_message(blob, _MAP_DATA_INFO_SCHEMA, context="B01 MapDataInfo")
228342
if (map_data := parsed.get("map_data")) is None:
229343
raise RoborockException("B01 map payload missing mapData")
230344
return map_data
231345

232346

233-
def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]:
347+
def _parse_sc_room_data(blob: bytes) -> _ScRoomData:
234348
parsed = _parse_proto_message(blob, _ROOM_DATA_INFO_SCHEMA, context="B01 RoomDataInfo")
235-
return parsed.get("room_id"), parsed.get("room_name")
349+
return _ScRoomData(
350+
room_id=parsed.get("room_id"),
351+
room_name=parsed.get("room_name"),
352+
room_type_id=parsed.get("room_type_id"),
353+
material_id=parsed.get("material_id"),
354+
clean_state=parsed.get("clean_state"),
355+
room_clean=parsed.get("room_clean"),
356+
room_clean_index=parsed.get("room_clean_index"),
357+
room_name_post=parsed.get("room_name_post"),
358+
color_id=parsed.get("color_id"),
359+
floor_direction=parsed.get("floor_direction"),
360+
global_seq=parsed.get("global_seq"),
361+
)
362+
363+
364+
def _parse_scmap_payload(payload: bytes) -> _ScMapPayload:
365+
"""Parse inflated SCMap bytes using the reverse-engineered app field layout."""
366+
parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap")
367+
return _ScMapPayload(
368+
map_type=parsed.get("map_type"),
369+
map_ext_info=parsed.get("map_ext_info"),
370+
map_head=parsed.get("map_head"),
371+
map_data=parsed.get("map_data"),
372+
room_data_info=tuple(parsed.get("room_data_info", [])),
373+
)
236374

237375

238-
def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]:
239-
"""Parse inflated SCMap bytes using the reverse-engineered field layout we need."""
240-
parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap")
376+
def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]:
377+
if scmap.map_head is None or scmap.map_data is None:
378+
raise RoborockException("Failed to parse B01 map header/grid")
241379

242-
size_x, size_y = parsed.get("map_head", (None, None))
243-
grid = parsed.get("map_data")
244-
if not size_x or not size_y or not grid:
380+
size_x = scmap.map_head.size_x or 0
381+
size_y = scmap.map_head.size_y or 0
382+
if not size_x or not size_y or not scmap.map_data:
245383
raise RoborockException("Failed to parse B01 map header/grid")
246384

247385
expected_len = size_x * size_y
248-
if len(grid) < expected_len:
386+
if len(scmap.map_data) < expected_len:
249387
raise RoborockException("B01 map data shorter than expected dimensions")
250388

389+
return size_x, size_y, scmap.map_data[:expected_len]
390+
391+
392+
def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]:
251393
room_names: dict[int, str] = {}
252-
for room_id, room_name in parsed.get("room_data_info", []):
253-
if room_id is not None:
254-
room_names[room_id] = room_name or f"Room {room_id}"
394+
for room in rooms:
395+
if room.room_id is not None:
396+
room_names[room.room_id] = room.room_name or f"Room {room.room_id}"
397+
return room_names
255398

256-
return size_x, size_y, grid[:expected_len], room_names
257399

400+
_DEVICE_POINT_INFO_SCHEMA = {
401+
1: _ProtoField("x", _WIRE_FIXED32, parser=_decode_float32),
402+
2: _ProtoField("y", _WIRE_FIXED32, parser=_decode_float32),
403+
}
404+
405+
_MAP_BOUNDARY_INFO_SCHEMA = {
406+
1: _ProtoField("map_md5", _WIRE_LEN, parser=_decode_utf8),
407+
2: _ProtoField("v_min_x", _WIRE_VARINT, parser=_decode_uint32),
408+
3: _ProtoField("v_max_x", _WIRE_VARINT, parser=_decode_uint32),
409+
4: _ProtoField("v_min_y", _WIRE_VARINT, parser=_decode_uint32),
410+
5: _ProtoField("v_max_y", _WIRE_VARINT, parser=_decode_uint32),
411+
}
412+
413+
_MAP_EXT_INFO_SCHEMA = {
414+
1: _ProtoField("task_begin_date", _WIRE_VARINT, parser=_decode_uint32),
415+
2: _ProtoField("map_upload_date", _WIRE_VARINT, parser=_decode_uint32),
416+
3: _ProtoField("map_valid", _WIRE_VARINT, parser=_decode_uint32),
417+
4: _ProtoField("radian", _WIRE_VARINT, parser=_decode_uint32),
418+
5: _ProtoField("force", _WIRE_VARINT, parser=_decode_uint32),
419+
6: _ProtoField("clean_path", _WIRE_VARINT, parser=_decode_uint32),
420+
7: _ProtoField("boundary_info", _WIRE_LEN, parser=_parse_sc_map_boundary_info),
421+
8: _ProtoField("map_version", _WIRE_VARINT, parser=_decode_uint32),
422+
9: _ProtoField("map_value_type", _WIRE_VARINT, parser=_decode_uint32),
423+
}
258424

259425
_MAP_HEAD_INFO_SCHEMA = {
426+
1: _ProtoField("map_head_id", _WIRE_VARINT, parser=_decode_uint32),
260427
2: _ProtoField("size_x", _WIRE_VARINT, parser=_decode_uint32),
261428
3: _ProtoField("size_y", _WIRE_VARINT, parser=_decode_uint32),
429+
4: _ProtoField("min_x", _WIRE_FIXED32, parser=_decode_float32),
430+
5: _ProtoField("min_y", _WIRE_FIXED32, parser=_decode_float32),
431+
6: _ProtoField("max_x", _WIRE_FIXED32, parser=_decode_float32),
432+
7: _ProtoField("max_y", _WIRE_FIXED32, parser=_decode_float32),
433+
8: _ProtoField("resolution", _WIRE_FIXED32, parser=_decode_float32),
262434
}
263435

264436
_MAP_DATA_INFO_SCHEMA = {
@@ -268,12 +440,23 @@ def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str
268440
_ROOM_DATA_INFO_SCHEMA = {
269441
1: _ProtoField("room_id", _WIRE_VARINT, parser=_decode_uint32),
270442
2: _ProtoField("room_name", _WIRE_LEN, parser=_decode_utf8),
443+
3: _ProtoField("room_type_id", _WIRE_VARINT, parser=_decode_uint32),
444+
4: _ProtoField("material_id", _WIRE_VARINT, parser=_decode_uint32),
445+
5: _ProtoField("clean_state", _WIRE_VARINT, parser=_decode_uint32),
446+
6: _ProtoField("room_clean", _WIRE_VARINT, parser=_decode_uint32),
447+
7: _ProtoField("room_clean_index", _WIRE_VARINT, parser=_decode_uint32),
448+
8: _ProtoField("room_name_post", _WIRE_LEN, parser=_parse_sc_point),
449+
10: _ProtoField("color_id", _WIRE_VARINT, parser=_decode_uint32),
450+
11: _ProtoField("floor_direction", _WIRE_VARINT, parser=_decode_uint32),
451+
12: _ProtoField("global_seq", _WIRE_VARINT, parser=_decode_uint32),
271452
}
272453

273454
_ROBOT_MAP_SCHEMA = {
274-
3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_map_head_info),
275-
4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_map_data_info),
276-
12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_room_data_info),
455+
1: _ProtoField("map_type", _WIRE_VARINT, parser=_decode_uint32),
456+
2: _ProtoField("map_ext_info", _WIRE_LEN, parser=_parse_sc_map_ext_info),
457+
3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_sc_map_head),
458+
4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_sc_map_data_info),
459+
12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_sc_room_data),
277460
}
278461

279462

0 commit comments

Comments
 (0)