Skip to content

Commit f8c069c

Browse files
committed
refactor: trim q7 map parser scope
1 parent 68f41a4 commit f8c069c

File tree

4 files changed

+55
-587
lines changed

4 files changed

+55
-587
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Trait for fetching parsed map content from B01/Q7 devices.
22
3-
This follows the same basic pattern as the v1 `MapContentTrait`:
3+
This intentionally mirrors the v1 `MapContentTrait` contract:
44
- `refresh()` performs I/O and populates cached fields
5+
- `parse_map_content()` reparses cached raw bytes without I/O
56
- fields `image_content`, `map_data`, and `raw_api_response` are then readable
67
78
For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
@@ -75,8 +76,8 @@ async def refresh(self) -> None:
7576
def parse_map_content(self, response: bytes) -> MapContent:
7677
"""Parse map content from raw bytes.
7778
78-
Exposed so callers can re-parse cached map payload bytes without
79-
performing I/O.
79+
This mirrors the v1 trait behavior so cached map payload bytes can be
80+
reparsed without going back to the device.
8081
"""
8182
if not self._serial or not self._model:
8283
raise RoborockException(

roborock/map/b01_map_parser.py

Lines changed: 25 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@
66
- PKCS7 padded
77
- ASCII hex for a zlib-compressed SCMap payload
88
9-
The inner SCMap blob is a protobuf-wire message. We know the app's field layout
10-
well enough to describe the fields we care about declaratively, but we still
11-
avoid shipping generated protobuf classes from a reverse-engineered schema.
12-
That keeps the runtime parser narrow without overstating certainty about the
13-
full message definition.
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.
1412
"""
1513

1614
from __future__ import annotations
@@ -19,10 +17,9 @@
1917
import binascii
2018
import hashlib
2119
import io
22-
import struct
2320
import zlib
2421
from collections.abc import Callable
25-
from dataclasses import dataclass, field
22+
from dataclasses import dataclass
2623

2724
from Crypto.Cipher import AES
2825
from Crypto.Util.Padding import pad, unpad
@@ -52,70 +49,6 @@ class _ProtoField:
5249
parser: Callable[[object], object] | None = None
5350

5451

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

14071
image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale)
14172

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

249180

250-
def _decode_float32(value: bytes) -> float:
251-
return struct.unpack("<f", value)[0]
252-
253-
254181
def _decode_map_data_bytes(value: bytes) -> bytes:
255182
try:
256183
return zlib.decompress(value)
@@ -291,145 +218,47 @@ def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context
291218
return parsed
292219

293220

294-
def _parse_sc_point(blob: bytes) -> _ScPoint:
295-
parsed = _parse_proto_message(blob, _DEVICE_POINT_INFO_SCHEMA, context="B01 DevicePointInfo")
296-
return _ScPoint(x=parsed.get("x"), y=parsed.get("y"))
297-
298-
299-
def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo:
300-
parsed = _parse_proto_message(blob, _MAP_BOUNDARY_INFO_SCHEMA, context="B01 MapBoundaryInfo")
301-
return _ScMapBoundaryInfo(
302-
map_md5=parsed.get("map_md5"),
303-
v_min_x=parsed.get("v_min_x"),
304-
v_max_x=parsed.get("v_max_x"),
305-
v_min_y=parsed.get("v_min_y"),
306-
v_max_y=parsed.get("v_max_y"),
307-
)
308-
309-
310-
def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo:
311-
parsed = _parse_proto_message(blob, _MAP_EXT_INFO_SCHEMA, context="B01 MapExtInfo")
312-
return _ScMapExtInfo(
313-
task_begin_date=parsed.get("task_begin_date"),
314-
map_upload_date=parsed.get("map_upload_date"),
315-
map_valid=parsed.get("map_valid"),
316-
radian=parsed.get("radian"),
317-
force=parsed.get("force"),
318-
clean_path=parsed.get("clean_path"),
319-
boundary_info=parsed.get("boundary_info"),
320-
map_version=parsed.get("map_version"),
321-
map_value_type=parsed.get("map_value_type"),
322-
)
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")
323224

324225

325-
def _parse_sc_map_head(blob: bytes) -> _ScMapHead:
326-
parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo")
327-
return _ScMapHead(
328-
map_head_id=parsed.get("map_head_id"),
329-
size_x=parsed.get("size_x"),
330-
size_y=parsed.get("size_y"),
331-
min_x=parsed.get("min_x"),
332-
min_y=parsed.get("min_y"),
333-
max_x=parsed.get("max_x"),
334-
max_y=parsed.get("max_y"),
335-
resolution=parsed.get("resolution"),
336-
)
337-
338-
339-
def _parse_sc_map_data_info(blob: bytes) -> bytes:
226+
def _parse_map_data_info(blob: bytes) -> bytes:
340227
parsed = _parse_proto_message(blob, _MAP_DATA_INFO_SCHEMA, context="B01 MapDataInfo")
341228
if (map_data := parsed.get("map_data")) is None:
342229
raise RoborockException("B01 map payload missing mapData")
343230
return map_data
344231

345232

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

374237

375-
def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]:
376-
if scmap.map_head is None or scmap.map_data is None:
377-
raise RoborockException("Failed to parse B01 map header/grid")
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")
378241

379-
size_x = scmap.map_head.size_x or 0
380-
size_y = scmap.map_head.size_y or 0
381-
if not size_x or not size_y or not scmap.map_data:
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:
382245
raise RoborockException("Failed to parse B01 map header/grid")
383246

384247
expected_len = size_x * size_y
385-
if len(scmap.map_data) < expected_len:
248+
if len(grid) < expected_len:
386249
raise RoborockException("B01 map data shorter than expected dimensions")
387250

388-
return size_x, size_y, scmap.map_data[:expected_len]
389-
390-
391-
def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]:
392251
room_names: dict[int, str] = {}
393-
for room in rooms:
394-
if room.room_id is not None:
395-
room_names[room.room_id] = room.room_name or f"Room {room.room_id}"
396-
return room_names
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}"
397255

256+
return size_x, size_y, grid[:expected_len], room_names
398257

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

424259
_MAP_HEAD_INFO_SCHEMA = {
425-
1: _ProtoField("map_head_id", _WIRE_VARINT, parser=_decode_uint32),
426260
2: _ProtoField("size_x", _WIRE_VARINT, parser=_decode_uint32),
427261
3: _ProtoField("size_y", _WIRE_VARINT, parser=_decode_uint32),
428-
4: _ProtoField("min_x", _WIRE_FIXED32, parser=_decode_float32),
429-
5: _ProtoField("min_y", _WIRE_FIXED32, parser=_decode_float32),
430-
6: _ProtoField("max_x", _WIRE_FIXED32, parser=_decode_float32),
431-
7: _ProtoField("max_y", _WIRE_FIXED32, parser=_decode_float32),
432-
8: _ProtoField("resolution", _WIRE_FIXED32, parser=_decode_float32),
433262
}
434263

435264
_MAP_DATA_INFO_SCHEMA = {
@@ -439,23 +268,12 @@ def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]:
439268
_ROOM_DATA_INFO_SCHEMA = {
440269
1: _ProtoField("room_id", _WIRE_VARINT, parser=_decode_uint32),
441270
2: _ProtoField("room_name", _WIRE_LEN, parser=_decode_utf8),
442-
3: _ProtoField("room_type_id", _WIRE_VARINT, parser=_decode_uint32),
443-
4: _ProtoField("material_id", _WIRE_VARINT, parser=_decode_uint32),
444-
5: _ProtoField("clean_state", _WIRE_VARINT, parser=_decode_uint32),
445-
6: _ProtoField("room_clean", _WIRE_VARINT, parser=_decode_uint32),
446-
7: _ProtoField("room_clean_index", _WIRE_VARINT, parser=_decode_uint32),
447-
8: _ProtoField("room_name_post", _WIRE_LEN, parser=_parse_sc_point),
448-
10: _ProtoField("color_id", _WIRE_VARINT, parser=_decode_uint32),
449-
11: _ProtoField("floor_direction", _WIRE_VARINT, parser=_decode_uint32),
450-
12: _ProtoField("global_seq", _WIRE_VARINT, parser=_decode_uint32),
451271
}
452272

453273
_ROBOT_MAP_SCHEMA = {
454-
1: _ProtoField("map_type", _WIRE_VARINT, parser=_decode_uint32),
455-
2: _ProtoField("map_ext_info", _WIRE_LEN, parser=_parse_sc_map_ext_info),
456-
3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_sc_map_head),
457-
4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_sc_map_data_info),
458-
12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_sc_room_data),
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),
459277
}
460278

461279

0 commit comments

Comments
 (0)