From 487778a2727438fbfe5e9be9ffd8d962d1297b94 Mon Sep 17 00:00:00 2001 From: arduano Date: Sun, 15 Mar 2026 13:11:49 +1100 Subject: [PATCH 01/14] feat(q7): add b01 map_content support --- roborock/devices/device_manager.py | 2 +- roborock/devices/traits/b01/q7/__init__.py | 33 +- roborock/devices/traits/b01/q7/map_content.py | 104 ++++++ roborock/map/b01_map_parser.py | 277 ++++++++++++++++ tests/devices/traits/b01/q7/conftest.py | 27 +- .../devices/traits/b01/q7/test_map_content.py | 81 +++++ tests/map/debug_b01_scmap.py | 304 ++++++++++++++++++ tests/map/test_b01_map_parser.py | 67 ++++ .../raw-mqtt-map301.bin.inflated.bin.gz | Bin 0 -> 22445 bytes 9 files changed, 884 insertions(+), 11 deletions(-) create mode 100644 roborock/devices/traits/b01/q7/map_content.py create mode 100644 roborock/map/b01_map_parser.py create mode 100644 tests/devices/traits/b01/q7/test_map_content.py create mode 100644 tests/map/debug_b01_scmap.py create mode 100644 tests/map/test_b01_map_parser.py create mode 100644 tests/map/testdata/raw-mqtt-map301.bin.inflated.bin.gz diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index b1ef6626..0be98ea1 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -251,7 +251,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat trait = b01.q10.create(channel) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. - trait = b01.q7.create(channel) + trait = b01.q7.create(product, device, channel) else: raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}") case _: diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 9bbe6818..51d4ef75 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -1,10 +1,14 @@ """Traits for Q7 B01 devices. -Potentially other devices may fall into this category in the future.""" + +Potentially other devices may fall into this category in the future. +""" + +from __future__ import annotations from typing import Any from roborock import B01Props -from roborock.data import Q7MapList, Q7MapListEntry +from roborock.data import HomeDataDevice, HomeDataProduct, Q7MapList, Q7MapListEntry from roborock.data.b01_q7.b01_q7_code_mappings import ( CleanPathPreferenceMapping, CleanRepeatMapping, @@ -23,18 +27,20 @@ from .clean_summary import CleanSummaryTrait from .map import MapTrait +from .map_content import MapContentTrait __all__ = [ "Q7PropertiesApi", "CleanSummaryTrait", "MapTrait", + "MapContentTrait", "Q7MapList", "Q7MapListEntry", ] class Q7PropertiesApi(Trait): - """API for interacting with B01 devices.""" + """API for interacting with B01 Q7 devices.""" clean_summary: CleanSummaryTrait """Trait for clean records / clean summary (Q7 `service.get_record_list`).""" @@ -42,11 +48,22 @@ class Q7PropertiesApi(Trait): map: MapTrait """Trait for map list metadata + raw map payload retrieval.""" - def __init__(self, channel: MqttChannel) -> None: - """Initialize the B01Props API.""" + map_content: MapContentTrait + """Trait for fetching parsed current map content.""" + + def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: HomeDataProduct) -> None: + """Initialize the Q7 API.""" self._channel = channel + self._device = device + self._product = product + self.clean_summary = CleanSummaryTrait(channel) self.map = MapTrait(channel) + self.map_content = MapContentTrait( + self.map, + serial=device.sn, + model=product.model, + ) async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" @@ -151,6 +168,6 @@ async def send(self, command: CommandType, params: ParamsType) -> Any: ) -def create(channel: MqttChannel) -> Q7PropertiesApi: - """Create traits for B01 devices.""" - return Q7PropertiesApi(channel) +def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi: + """Create traits for B01 Q7 devices.""" + return Q7PropertiesApi(channel, device=device, product=product) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py new file mode 100644 index 00000000..2fd7ee3b --- /dev/null +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -0,0 +1,104 @@ +"""Trait for fetching parsed map content from B01/Q7 devices. + +This follows the same basic pattern as the v1 `MapContentTrait`: +- `refresh()` performs I/O and populates cached fields +- fields `image_content`, `map_data`, and `raw_api_response` are then readable + +For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from vacuum_map_parser_base.map_data import MapData + +from roborock.data import RoborockBase +from roborock.devices.traits import Trait +from roborock.exceptions import RoborockException +from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig + +from .map import MapTrait + +_TRUNCATE_LENGTH = 20 + + +@dataclass +class MapContent(RoborockBase): + """Dataclass representing map content.""" + + image_content: bytes | None = None + """The rendered image of the map in PNG format.""" + + map_data: MapData | None = None + """Parsed map data (metadata for points on the map).""" + + raw_api_response: bytes | None = None + """Raw bytes of the map payload from the device. + + This should be treated as an opaque blob used only internally by this + library to re-parse the map data when needed. + """ + + def __repr__(self) -> str: + img = self.image_content + if img and len(img) > _TRUNCATE_LENGTH: + img = img[: _TRUNCATE_LENGTH - 3] + b"..." + return f"MapContent(image_content={img!r}, map_data={self.map_data!r})" + + +class MapContentTrait(MapContent, Trait): + """Trait for fetching parsed map content for Q7 devices.""" + + def __init__( + self, + map_trait: MapTrait, + *, + serial: str | None, + model: str | None, + map_parser_config: B01MapParserConfig | None = None, + ) -> None: + super().__init__() + self._map_trait = map_trait + self._serial = serial + self._model = model + self._map_parser = B01MapParser(map_parser_config) + + async def refresh(self) -> None: + """Fetch, decode, and parse the current map payload.""" + raw_payload = await self._map_trait.get_current_map_payload() + parsed = self.parse_map_content(raw_payload) + self.image_content = parsed.image_content + self.map_data = parsed.map_data + self.raw_api_response = parsed.raw_api_response + + def parse_map_content(self, response: bytes) -> MapContent: + """Parse map content from raw bytes. + + Exposed so callers can re-parse cached map payload bytes without + performing I/O. + """ + if not self._serial or not self._model: + raise RoborockException( + "B01 map parsing requires device serial number and model metadata, but they were missing" + ) + + try: + parsed_data = self._map_parser.parse( + response, + serial=self._serial, + model=self._model, + ) + except RoborockException: + raise + except Exception as ex: + raise RoborockException("Failed to parse B01 map data") from ex + + if parsed_data.image_content is None: + raise RoborockException("Failed to render B01 map image") + + return MapContent( + image_content=parsed_data.image_content, + map_data=parsed_data.map_data, + raw_api_response=response, + ) diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py new file mode 100644 index 00000000..c170709a --- /dev/null +++ b/roborock/map/b01_map_parser.py @@ -0,0 +1,277 @@ +"""Module for parsing B01/Q7 map content. + +Observed Q7 MAP_RESPONSE payloads are: +- base64-encoded ASCII +- AES-ECB encrypted with the derived map key +- PKCS7 padded +- ASCII hex for a zlib-compressed SCMap payload + +The inner SCMap blob appears to use protobuf wire encoding, but we do not have +an upstream `.proto` schema for it. We intentionally keep a tiny schema-free +wire parser here instead of introducing generated protobuf classes from a +reverse-engineered schema, because that would add maintenance/guesswork without +meaningfully reducing complexity for the small set of fields we actually use. + +This module keeps the decode path narrow and explicit to match the observed +payload shape as closely as possible. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import io +import zlib +from dataclasses import dataclass + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +from PIL import Image +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.map_data import ImageData, MapData + +from roborock.exceptions import RoborockException + +from .map_parser import ParsedMapData + +_B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") +_MAP_FILE_FORMAT = "PNG" + + +@dataclass +class B01MapParserConfig: + """Configuration for the B01/Q7 map parser.""" + + map_scale: int = 4 + """Scale factor for the rendered map image.""" + + +class B01MapParser: + """Decoder/parser for B01/Q7 SCMap payloads.""" + + def __init__(self, config: B01MapParserConfig | None = None) -> None: + self._config = config or B01MapParserConfig() + + def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData: + """Parse a raw MAP_RESPONSE payload and return a PNG + MapData.""" + inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model) + size_x, size_y, grid, room_names = _parse_scmap_payload(inflated) + + image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) + + map_data = MapData() + map_data.image = ImageData( + size=size_x * size_y, + top=0, + left=0, + height=size_y, + width=size_x, + image_config=ImageConfig(scale=self._config.map_scale), + data=image, + img_transformation=lambda p: p, + ) + if room_names: + map_data.additional_parameters["room_names"] = room_names + + image_bytes = io.BytesIO() + image.save(image_bytes, format=_MAP_FILE_FORMAT) + + return ParsedMapData( + image_content=image_bytes.getvalue(), + map_data=map_data, + ) + + +def _derive_map_key(serial: str, model: str) -> bytes: + """Derive the B01/Q7 map decrypt key from serial + model.""" + model_suffix = model.split(".")[-1] + model_key = (model_suffix + "0" * 16)[:16].encode() + material = f"{serial}+{model_suffix}+{serial}".encode() + encrypted = AES.new(model_key, AES.MODE_ECB).encrypt(pad(material, AES.block_size)) + md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest() + return md5[8:24].encode() + + +def _decode_base64_payload(raw_payload: bytes) -> bytes: + blob = raw_payload.strip() + if len(blob) < 32 or any(b not in _B64_CHARS for b in blob): + raise RoborockException("Failed to decode B01 map payload") + + padded = blob + b"=" * (-len(blob) % 4) + try: + return base64.b64decode(padded, validate=True) + except binascii.Error as err: + raise RoborockException("Failed to decode B01 map payload") from err + + +def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes: + """Decode raw B01 MAP_RESPONSE payload into inflated SCMap bytes.""" + encrypted_payload = _decode_base64_payload(raw_payload) + if len(encrypted_payload) % AES.block_size != 0: + raise RoborockException("Unexpected encrypted B01 map payload length") + + map_key = _derive_map_key(serial, model) + decrypted_hex = AES.new(map_key, AES.MODE_ECB).decrypt(encrypted_payload) + + try: + compressed_hex = unpad(decrypted_hex, AES.block_size).decode("ascii") + compressed_payload = bytes.fromhex(compressed_hex) + return zlib.decompress(compressed_payload) + except (ValueError, UnicodeDecodeError, zlib.error) as err: + raise RoborockException("Failed to decode B01 map payload") from err + + +def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: + value = 0 + shift = 0 + while True: + if idx >= len(buf): + raise RoborockException("Truncated varint in B01 map payload") + byte = buf[idx] + idx += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, idx + shift += 7 + if shift > 63: + raise RoborockException("Invalid varint in B01 map payload") + + +def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: + length, idx = _read_varint(buf, idx) + end = idx + length + if end > len(buf): + raise RoborockException("Invalid length-delimited field in B01 map payload") + return buf[idx:end], end + + +def _parse_map_data_info(blob: bytes) -> bytes: + """Extract and inflate occupancy raster bytes from SCMap mapDataInfo.""" + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + _, idx = _read_varint(blob, idx) + elif wire == 2: + value, idx = _read_len_delimited(blob, idx) + if field_no == 1: + try: + return zlib.decompress(value) + except zlib.error: + return value + elif wire == 5: + idx += 4 + else: + raise RoborockException(f"Unsupported wire type {wire} in B01 mapDataInfo") + raise RoborockException("B01 map payload missing mapData") + + +def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: + room_id: int | None = None + room_name: str | None = None + + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + int_value, idx = _read_varint(blob, idx) + if field_no == 1: + room_id = int(int_value) + elif wire == 2: + bytes_value, idx = _read_len_delimited(blob, idx) + if field_no == 2: + room_name = bytes_value.decode("utf-8", errors="replace") + elif wire == 5: + idx += 4 + else: + raise RoborockException(f"Unsupported wire type {wire} in B01 roomDataInfo") + + return room_id, room_name + + +def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]: + """Parse inflated SCMap bytes.""" + + size_x = 0 + size_y = 0 + grid = b"" + room_names: dict[int, str] = {} + + idx = 0 + while idx < len(payload): + key, idx = _read_varint(payload, idx) + field_no = key >> 3 + wire = key & 0x07 + + if wire == 0: + _, idx = _read_varint(payload, idx) + continue + + if wire != 2: + if wire == 5: + idx += 4 + continue + raise RoborockException(f"Unsupported wire type {wire} in B01 map payload") + + value, idx = _read_len_delimited(payload, idx) + + if field_no == 3: # mapHead + hidx = 0 + while hidx < len(value): + hkey, hidx = _read_varint(value, hidx) + hfield = hkey >> 3 + hwire = hkey & 0x07 + if hwire == 0: + hvalue, hidx = _read_varint(value, hidx) + if hfield == 2: + size_x = int(hvalue) + elif hfield == 3: + size_y = int(hvalue) + elif hwire == 5: + hidx += 4 + elif hwire == 2: + _, hidx = _read_len_delimited(value, hidx) + else: + raise RoborockException(f"Unsupported wire type {hwire} in B01 map header") + elif field_no == 4: # mapDataInfo + grid = _parse_map_data_info(value) + elif field_no == 12: # roomDataInfo (repeated) + room_id, room_name = _parse_room_data_info(value) + if room_id is not None: + room_names[room_id] = room_name or f"Room {room_id}" + + if not size_x or not size_y or not grid: + raise RoborockException("Failed to parse B01 map header/grid") + + expected_len = size_x * size_y + if len(grid) < expected_len: + raise RoborockException("B01 map data shorter than expected dimensions") + + return size_x, size_y, grid[:expected_len], room_names + + +def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image: + """Render the B01 occupancy grid into a simple image.""" + + # The observed occupancy grid contains only: + # - 0: outside/unknown + # - 127: wall/obstacle + # - 128: floor/free + table = bytearray(range(256)) + table[0] = 0 + table[127] = 180 + table[128] = 255 + + mapped = grid.translate(bytes(table)) + img = Image.frombytes("L", (size_x, size_y), mapped) + img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM).convert("RGB") + + if scale > 1: + img = img.resize((size_x * scale, size_y * scale), resample=Image.Resampling.NEAREST) + + return img diff --git a/tests/devices/traits/b01/q7/conftest.py b/tests/devices/traits/b01/q7/conftest.py index 5dc476f6..4f55ba0f 100644 --- a/tests/devices/traits/b01/q7/conftest.py +++ b/tests/devices/traits/b01/q7/conftest.py @@ -5,6 +5,7 @@ import pytest +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory from roborock.devices.traits.b01.q7 import Q7PropertiesApi from tests.fixtures.channel_fixtures import FakeChannel @@ -16,9 +17,30 @@ def fake_channel_fixture() -> FakeChannel: return FakeChannel() +@pytest.fixture(name="product") +def product_fixture() -> HomeDataProduct: + return HomeDataProduct( + id="product-id-q7", + name="Roborock Q7", + model="roborock.vacuum.sc05", + category=RoborockCategory.VACUUM, + ) + + +@pytest.fixture(name="device") +def device_fixture() -> HomeDataDevice: + return HomeDataDevice( + duid="abc123", + name="Q7", + local_key="key123key123key1", + product_id="product-id-q7", + sn="testsn012345", + ) + + @pytest.fixture(name="q7_api") -def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi: - return Q7PropertiesApi(fake_channel) # type: ignore[arg-type] +def q7_api_fixture(fake_channel: FakeChannel, device: HomeDataDevice, product: HomeDataProduct) -> Q7PropertiesApi: + return Q7PropertiesApi(fake_channel, device=device, product=product) # type: ignore[arg-type] @pytest.fixture(name="expected_msg_id", autouse=True) @@ -28,6 +50,7 @@ def next_message_id_fixture() -> Generator[int, None, None]: We pick an arbitrary number, but just need it to ensure we can craft a fake response with the message id matched to the outgoing RPC. """ + expected_msg_id = math.floor(time.time()) # Patch get_next_int to return our expected msg_id so the channel waits for it diff --git a/tests/devices/traits/b01/q7/test_map_content.py b/tests/devices/traits/b01/q7/test_map_content.py new file mode 100644 index 00000000..32e6e916 --- /dev/null +++ b/tests/devices/traits/b01/q7/test_map_content.py @@ -0,0 +1,81 @@ +from typing import cast +from unittest.mock import patch + +import pytest +from vacuum_map_parser_base.map_data import MapData + +from roborock.devices.traits.b01.q7 import Q7PropertiesApi +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from tests.fixtures.channel_fixtures import FakeChannel + +from . import B01MessageBuilder + + +async def test_q7_map_content_refresh_populates_cached_values( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) + fake_channel.response_queue.append( + RoborockMessage( + protocol=RoborockMessageProtocol.MAP_RESPONSE, + payload=b"raw-map-payload", + version=b"B01", + seq=message_builder.seq + 1, + ) + ) + + dummy_map_data = MapData() + with patch( + "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", + return_value=type("X", (), {"image_content": b"pngbytes", "map_data": dummy_map_data})(), + ) as parse: + await q7_api.map_content.refresh() + + assert q7_api.map_content.image_content == b"pngbytes" + assert q7_api.map_content.map_data is dummy_map_data + assert q7_api.map_content.raw_api_response == b"raw-map-payload" + + parse.assert_called_once() + + +def test_q7_map_content_parse_errors_cleanly(q7_api: Q7PropertiesApi): + with patch("roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", side_effect=ValueError("boom")): + with pytest.raises(RoborockException, match="Failed to parse B01 map data"): + q7_api.map_content.parse_map_content(b"raw") + + +def test_q7_map_content_preserves_specific_roborock_errors(q7_api: Q7PropertiesApi): + with patch( + "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", + side_effect=RoborockException("Specific decoder failure"), + ): + with pytest.raises(RoborockException, match="Specific decoder failure"): + q7_api.map_content.parse_map_content(b"raw") + + +def test_q7_map_content_missing_metadata_fails_lazily(fake_channel: FakeChannel): + from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory + + q7_api = Q7PropertiesApi( + cast(MqttChannel, fake_channel), + device=HomeDataDevice( + duid="abc123", + name="Q7", + local_key="key123key123key1", + product_id="product-id-q7", + sn=None, + ), + product=HomeDataProduct( + id="product-id-q7", + name="Roborock Q7", + model="roborock.vacuum.sc05", + category=RoborockCategory.VACUUM, + ), + ) + + with pytest.raises(RoborockException, match="requires device serial number and model metadata"): + q7_api.map_content.parse_map_content(b"raw") diff --git a/tests/map/debug_b01_scmap.py b/tests/map/debug_b01_scmap.py new file mode 100644 index 00000000..b9bba89c --- /dev/null +++ b/tests/map/debug_b01_scmap.py @@ -0,0 +1,304 @@ +"""Developer helper for inspecting B01/Q7 SCMap payloads. + +This script is intentionally kept outside the runtime package so it stays +non-obtrusive. It is useful when reverse-engineering new payload samples or +validating assumptions about the current parser. + +Why not generated protobuf classes here? +- The inflated SCMap payload looks like protobuf wire format. +- We do not have an upstream `.proto` schema. +- For runtime code, reverse-engineering and committing guessed schema files + would imply more certainty than we actually have. + +So the library keeps a tiny schema-free parser for the fields it needs, while +this script provides a convenient place to inspect unknown payloads during +future debugging. + +This helper is intentionally standalone and does not import private runtime +helpers. That keeps it useful for debugging without coupling test/dev tooling to +internal implementation details. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import gzip +import hashlib +import zlib +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad + +_B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") + + +def _derive_map_key(serial: str, model: str) -> bytes: + model_suffix = model.split(".")[-1] + model_key = (model_suffix + "0" * 16)[:16].encode() + material = f"{serial}+{model_suffix}+{serial}".encode() + encrypted = AES.new(model_key, AES.MODE_ECB).encrypt(pad(material, AES.block_size)) + md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest() + return md5[8:24].encode() + + +def _decode_base64_payload(raw_payload: bytes) -> bytes: + blob = raw_payload.strip() + if len(blob) < 32 or any(b not in _B64_CHARS for b in blob): + raise ValueError("Unexpected B01 map payload format") + + padded = blob + b"=" * (-len(blob) % 4) + try: + return base64.b64decode(padded, validate=True) + except binascii.Error as err: + raise ValueError("Failed to decode B01 map payload") from err + + +def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes: + encrypted_payload = _decode_base64_payload(raw_payload) + if len(encrypted_payload) % AES.block_size != 0: + raise ValueError("Unexpected encrypted B01 map payload length") + + map_key = _derive_map_key(serial, model) + decrypted_hex = AES.new(map_key, AES.MODE_ECB).decrypt(encrypted_payload) + + try: + compressed_hex = unpad(decrypted_hex, AES.block_size).decode("ascii") + compressed_payload = bytes.fromhex(compressed_hex) + return zlib.decompress(compressed_payload) + except (ValueError, UnicodeDecodeError, zlib.error) as err: + raise ValueError("Failed to decode B01 map payload") from err + + +def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: + value = 0 + shift = 0 + while True: + if idx >= len(buf): + raise ValueError("Truncated varint") + byte = buf[idx] + idx += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, idx + shift += 7 + if shift > 63: + raise ValueError("Invalid varint") + + +def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: + length, idx = _read_varint(buf, idx) + end = idx + length + if end > len(buf): + raise ValueError("Invalid length-delimited field") + return buf[idx:end], end + + +def _parse_map_data_info(blob: bytes) -> bytes: + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + _, idx = _read_varint(blob, idx) + elif wire == 2: + value, idx = _read_len_delimited(blob, idx) + if field_no == 1: + try: + return zlib.decompress(value) + except zlib.error: + return value + elif wire == 5: + idx += 4 + else: + raise ValueError(f"Unsupported wire type {wire} in mapDataInfo") + raise ValueError("SCMap missing mapData") + + +def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: + room_id: int | None = None + room_name: str | None = None + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + int_value, idx = _read_varint(blob, idx) + if field_no == 1: + room_id = int(int_value) + elif wire == 2: + bytes_value, idx = _read_len_delimited(blob, idx) + if field_no == 2: + room_name = bytes_value.decode("utf-8", errors="replace") + elif wire == 5: + idx += 4 + else: + raise ValueError(f"Unsupported wire type {wire} in roomDataInfo") + return room_id, room_name + + +def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]: + size_x = 0 + size_y = 0 + grid = b"" + room_names: dict[int, str] = {} + + idx = 0 + while idx < len(payload): + key, idx = _read_varint(payload, idx) + field_no = key >> 3 + wire = key & 0x07 + + if wire == 0: + _, idx = _read_varint(payload, idx) + continue + + if wire != 2: + if wire == 5: + idx += 4 + continue + raise ValueError(f"Unsupported wire type {wire} in SCMap payload") + + value, idx = _read_len_delimited(payload, idx) + if field_no == 3: + hidx = 0 + while hidx < len(value): + hkey, hidx = _read_varint(value, hidx) + hfield = hkey >> 3 + hwire = hkey & 0x07 + if hwire == 0: + hvalue, hidx = _read_varint(value, hidx) + if hfield == 2: + size_x = int(hvalue) + elif hfield == 3: + size_y = int(hvalue) + elif hwire == 5: + hidx += 4 + elif hwire == 2: + _, hidx = _read_len_delimited(value, hidx) + else: + raise ValueError(f"Unsupported wire type {hwire} in map header") + elif field_no == 4: + grid = _parse_map_data_info(value) + elif field_no == 12: + room_id, room_name = _parse_room_data_info(value) + if room_id is not None: + room_names[room_id] = room_name or f"Room {room_id}" + + return size_x, size_y, grid, room_names + + +def _looks_like_message(blob: bytes) -> bool: + if not blob or len(blob) > 4096: + return False + + idx = 0 + seen = 0 + try: + while idx < len(blob): + key, idx = _read_varint(blob, idx) + wire = key & 0x07 + seen += 1 + if wire == 0: + _, idx = _read_varint(blob, idx) + elif wire == 1: + idx += 8 + elif wire == 2: + _, idx = _read_len_delimited(blob, idx) + elif wire == 5: + idx += 4 + else: + return False + return seen > 0 and idx == len(blob) + except Exception: + return False + + +def _preview(blob: bytes, limit: int = 24) -> str: + text = blob[:limit].hex() + if len(blob) > limit: + return f"{text}... ({len(blob)} bytes)" + return f"{text} ({len(blob)} bytes)" + + +def _dump_message(blob: bytes, *, indent: str = "", max_depth: int = 2, depth: int = 0) -> None: + idx = 0 + while idx < len(blob): + start = idx + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + + if wire == 0: + int_value, idx = _read_varint(blob, idx) + print(f"{indent}field {field_no} @ {start}: varint {int_value}") + elif wire == 1: + bytes_value = blob[idx : idx + 8] + idx += 8 + print(f"{indent}field {field_no} @ {start}: fixed64 {_preview(bytes_value, 8)}") + elif wire == 2: + bytes_value, idx = _read_len_delimited(blob, idx) + print(f"{indent}field {field_no} @ {start}: len-delimited {_preview(bytes_value)}") + if depth < max_depth and _looks_like_message(bytes_value): + _dump_message(bytes_value, indent=indent + " ", max_depth=max_depth, depth=depth + 1) + elif wire == 5: + bytes_value = blob[idx : idx + 4] + idx += 4 + print(f"{indent}field {field_no} @ {start}: fixed32 {_preview(bytes_value, 4)}") + else: + print(f"{indent}field {field_no} @ {start}: unsupported wire type {wire}") + return + + +def _load_payload(args: argparse.Namespace) -> bytes: + if args.inflated_gzip is not None: + return gzip.decompress(args.inflated_gzip.read_bytes()) + if args.inflated_bin is not None: + return args.inflated_bin.read_bytes() + if args.raw_map_response is not None: + if not args.serial or not args.model: + raise SystemExit("--raw-map-response requires --serial and --model") + return _decode_b01_map_payload( + args.raw_map_response.read_bytes(), + serial=args.serial, + model=args.model, + ) + raise SystemExit("one of --inflated-gzip, --inflated-bin, or --raw-map-response is required") + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--inflated-gzip", type=Path, help="Path to gzipped inflated SCMap payload") + group.add_argument("--inflated-bin", type=Path, help="Path to raw inflated SCMap payload") + group.add_argument("--raw-map-response", type=Path, help="Path to raw MAP_RESPONSE payload bytes") + parser.add_argument("--serial", help="Device serial number (required for --raw-map-response)") + parser.add_argument("--model", help="Device model, e.g. roborock.vacuum.sc05 (required for --raw-map-response)") + parser.add_argument( + "--max-depth", + type=int, + default=2, + help="Maximum recursive dump depth for protobuf-like messages", + ) + return parser + + +def main() -> None: + args = _build_parser().parse_args() + payload = _load_payload(args) + + size_x, size_y, grid, room_names = _parse_scmap_payload(payload) + print(f"Inflated payload: {len(payload)} bytes") + print(f"Map size: {size_x} x {size_y}") + print(f"Grid bytes: {len(grid)}") + print(f"Room names: {room_names}") + print("\nTop-level field dump:") + _dump_message(payload, max_depth=args.max_depth) + + +if __name__ == "__main__": + main() diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py new file mode 100644 index 00000000..23a72b75 --- /dev/null +++ b/tests/map/test_b01_map_parser.py @@ -0,0 +1,67 @@ +import base64 +import gzip +import hashlib +import io +import zlib +from pathlib import Path + +import pytest +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad +from PIL import Image + +from roborock.exceptions import RoborockException +from roborock.map.b01_map_parser import B01MapParser + +FIXTURE = Path(__file__).resolve().parent / "testdata" / "raw-mqtt-map301.bin.inflated.bin.gz" + + +def _derive_map_key(serial: str, model: str) -> bytes: + model_suffix = model.split(".")[-1] + model_key = (model_suffix + "0" * 16)[:16].encode() + material = f"{serial}+{model_suffix}+{serial}".encode() + encrypted = AES.new(model_key, AES.MODE_ECB).encrypt(pad(material, AES.block_size)) + md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest() + return md5[8:24].encode() + + +def test_b01_map_parser_decodes_and_renders_fixture() -> None: + serial = "testsn012345" + model = "roborock.vacuum.sc05" + inflated = gzip.decompress(FIXTURE.read_bytes()) + + compressed = zlib.compress(inflated) + map_key = _derive_map_key(serial, model) + encrypted = AES.new(map_key, AES.MODE_ECB).encrypt(pad(compressed.hex().encode(), AES.block_size)) + payload = base64.b64encode(encrypted) + + parser = B01MapParser() + parsed = parser.parse(payload, serial=serial, model=model) + + assert parsed.image_content is not None + assert parsed.image_content.startswith(b"\x89PNG\r\n\x1a\n") + assert parsed.map_data is not None + + # The fixture includes 10 rooms with names room1..room10. + assert parsed.map_data.additional_parameters["room_names"] == { + 10: "room1", + 11: "room2", + 12: "room3", + 13: "room4", + 14: "room5", + 15: "room6", + 16: "room7", + 17: "room8", + 18: "room9", + 19: "room10", + } + + # Image should be scaled by default. + img = Image.open(io.BytesIO(parsed.image_content)) + assert img.size == (340 * 4, 300 * 4) + + +def test_b01_map_parser_rejects_invalid_payload() -> None: + parser = B01MapParser() + with pytest.raises(RoborockException, match="Failed to decode B01 map payload"): + parser.parse(b"not a map", serial="testsn012345", model="roborock.vacuum.sc05") diff --git a/tests/map/testdata/raw-mqtt-map301.bin.inflated.bin.gz b/tests/map/testdata/raw-mqtt-map301.bin.inflated.bin.gz new file mode 100644 index 0000000000000000000000000000000000000000..63b67ddbbd8f766e214f09efc0be1becad3f3b36 GIT binary patch literal 22445 zcmb4q1yfv2u=WcC4{pH;8ry*A1xruB_8w z$lI!Cgl@=R+SeQ~BRkcCE?J3oB>E+|hS&g}{=*0OXroAU65duxU7~Lb|83)X)165f zws*X7Po(?q->tIV(M!UL@6I1A_eTWpvIHkaMXpA&Gp{akxFm`$DfLCRw!fV>w?&nsIm*&tCT%Ib;-HmITepYB_&@Rue;3d2T3m zrmEpY-`wOOXo;^Z32}}+nbW@e*qZcpyiuFBRQ)5QO||LF8(1dyi=1EEAl*yKN!CAFNOs!rs_u2jm&GNY1g_a&803$dS*?K7cbi3)7+=M-z) zo!ZhZ&b+qqza#6Mz?d6)XPHuJ=Y=DX_6&j&m8(YVtdgBL+OKoZe?gwzD%rmpweX(v zq$`u*xK70yQ)L9_h!RQM*3^&W!|`WbWWaHs+%Ge$)TK&Hb;0CA+Hr9$sLL-O6Cvde zz`DHxQmZ35ktDX0EQJ|Ga1~>Im-?Cy+b#L$nNr*(!~}4P3e9R)0{fxNyhh22r9FNL z-cfk3qER}%g8SVF&#qLrc(|5$qj^R!muGvT55MG6^n97?^RsZSinOo9}=3)pQ$4rE@bEj$X3ubo5aCyGuRG#*>r01z+pCvLrM`RZ4 z;%1iiWZyg4hq&m1`-6`95+OTYvS6HrfE5z2fwtn+gJI{_34r1S?FIr$z=)@5N8^g5 z!9jsaW74c=;c?fomtu5P5?kjS{>qCg!Rw%#5@u;>SLh5u`lHxkLkc8Gp@315w@Jkl z0XJU)a?qAdSU!Jw$TSlw1z;>z)V9|aQiWnL&wK;Rv;P5%B`f_+j9c^dE=C^Zl?FSf z@z^hq%pSJ!13(xSDhc0ctd`B7RIPCe;1n(Xf7|J$bKDs-HxWcazaUAD%d6R{;CunE zD$@4G8LqJKiP*yf@1)84F1kJI46)NMvDrs6%3)G7$g7LJk3+QV$+}#gtGh>frB9Y5 zYcs_?glb?#R6c-ZnL|#d%)koZ_q4XvQU5PS4A=nonijr2iI294-AxN)3D2GzNue4= zVLYTUB(n~ZLc!%(f%I)n8>38JiObXcY3CWQc3-~z^-oHsB3BAR%9PHA_DJ##1TT6= zi>h+R>%hsgW#v!^0=RuhwIUI`B~=E$UZe5TrS~_puHH*i)Iu*xL{cAFM|kXcM8PVM ztE6a!B(k_fRM)k|u|Vq~p8gMr3$dbCJd%QdO?(v&ld@iOv|)bPpqu6@HN4Syr6z8I zjj^NtyZ&~@pUBp_Yu=+WJ8={qoPVK!iAjkxqSLI;spA2N-q9=H+i8A2bg3{ z?4NNYfG1ec_;ZBjFchFnk}7H1KkqP4JGf#mtg9%p&^=ASs5**wQ9QBqMg7h9!ra4Y zWh!ggI^f!9#!>f!I+cXAY}4%iBuIi)$t_LHe93(8?pcd@#x2+BFl}q8+ffWJey3&y zO?q28;|zZ4P-}LoT~=G}A~`uP(jP%Q_fTPg<*=^(4?Sh`EgldKZi-kUiXrY4me;B( zg&nn)*2mRFK=Mi$O%72_A^O^I+`@r#;EbWy5MXUi0j&n>KEo2HuO2wRM)R)X1ZWTO4XmJ3g!%r z?_v~BwjHug|4f7sXS=mG6i;HN*?uXs%ovw0w#IW~C`8N%e+@djE-q9P+OcGc2;?>Z zA8M%p_-PMyA~)oulg)HYJ&qI8AS(e8NfJLGyS29_#mb6}T?+g|0EHxGe3NX`EjiY) z(>#R|9fW^oi8tM($LWk!&Z8+J5a)n&`kdLwVt&OX$XZc{)OHeDt+?@Dhf-lBo#^(M z6CdelPmM2-S8Z=z6jYB!8j&gT<~%QYiMHe!Y^-Ddq&=QGc^1S7tMN#FjXgCQ_OgcJ zM6FU`1u(fNEwN56W+Fy945P)Xz%hK7CmZy<-z)u@A$b?yh0Cq2M52gxM&+VFqCB~S z_h6Hv$|SKWv*M?avHKA?HaME5L($u)Wd=98soyJECF$E$(Fjjq`!p3nL>L13-^n

g+Pds$%=ca5g&JsGL)rugua%RwGsz_5U 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

i%B4GG+r0DVNJAgOE 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 Date: Sun, 15 Mar 2026 15:12:56 +1100 Subject: [PATCH 02/14] refactor: make q7 scmap parsing declarative --- roborock/map/b01_map_parser.py | 406 +++++++++++++++++++++++-------- tests/map/debug_b01_scmap.py | 30 +-- tests/map/test_b01_map_parser.py | 108 +++++++- 3 files changed, 427 insertions(+), 117 deletions(-) diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index c170709a..4c4821a3 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -1,19 +1,16 @@ """Module for parsing B01/Q7 map content. -Observed Q7 MAP_RESPONSE payloads are: +Observed Q7 `MAP_RESPONSE` payloads follow the mobile app's decode pipeline: - base64-encoded ASCII - AES-ECB encrypted with the derived map key - PKCS7 padded - ASCII hex for a zlib-compressed SCMap payload -The inner SCMap blob appears to use protobuf wire encoding, but we do not have -an upstream `.proto` schema for it. We intentionally keep a tiny schema-free -wire parser here instead of introducing generated protobuf classes from a -reverse-engineered schema, because that would add maintenance/guesswork without -meaningfully reducing complexity for the small set of fields we actually use. - -This module keeps the decode path narrow and explicit to match the observed -payload shape as closely as possible. +The inner SCMap blob is a protobuf-wire message. We know the app's field layout +well enough to describe the fields we care about declaratively, but we still +avoid shipping generated protobuf classes from a reverse-engineered schema. +That keeps the runtime parser narrow without overstating certainty about the +full message definition. """ from __future__ import annotations @@ -22,8 +19,10 @@ import binascii import hashlib import io +import struct import zlib -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -37,6 +36,84 @@ _B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") _MAP_FILE_FORMAT = "PNG" +_WIRE_VARINT = 0 +_WIRE_FIXED64 = 1 +_WIRE_LEN = 2 +_WIRE_FIXED32 = 5 + + +@dataclass(frozen=True) +class _ProtoField: + """Declarative description of a protobuf-wire field we care about.""" + + name: str + wire_type: int + repeated: bool = False + parser: Callable[[object], object] | None = None + + +@dataclass(frozen=True) +class _ScPoint: + x: float | None = None + y: float | None = None + + +@dataclass(frozen=True) +class _ScMapBoundaryInfo: + map_md5: str | None = None + v_min_x: int | None = None + v_max_x: int | None = None + v_min_y: int | None = None + v_max_y: int | None = None + + +@dataclass(frozen=True) +class _ScMapExtInfo: + task_begin_date: int | None = None + map_upload_date: int | None = None + map_valid: int | None = None + radian: int | None = None + force: int | None = None + clean_path: int | None = None + boundary_info: _ScMapBoundaryInfo | None = None + map_version: int | None = None + map_value_type: int | None = None + + +@dataclass(frozen=True) +class _ScMapHead: + map_head_id: int | None = None + size_x: int | None = None + size_y: int | None = None + min_x: float | None = None + min_y: float | None = None + max_x: float | None = None + max_y: float | None = None + resolution: float | None = None + + +@dataclass(frozen=True) +class _ScRoomData: + room_id: int | None = None + room_name: str | None = None + room_type_id: int | None = None + material_id: int | None = None + clean_state: int | None = None + room_clean: int | None = None + room_clean_index: int | None = None + room_name_post: _ScPoint | None = None + color_id: int | None = None + floor_direction: int | None = None + global_seq: int | None = None + + +@dataclass(frozen=True) +class _ScMapPayload: + map_type: int | None = None + map_ext_info: _ScMapExtInfo | None = None + map_head: _ScMapHead | None = None + map_data: bytes | None = None + room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple) @dataclass @@ -56,7 +133,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None: def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData: """Parse a raw MAP_RESPONSE payload and return a PNG + MapData.""" inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model) - size_x, size_y, grid, room_names = _parse_scmap_payload(inflated) + scmap = _parse_scmap_payload(inflated) + size_x, size_y, grid = _extract_grid(scmap) + room_names = _extract_room_names(scmap.room_data_info) image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) @@ -106,7 +185,7 @@ def _decode_base64_payload(raw_payload: bytes) -> bytes: def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes: - """Decode raw B01 MAP_RESPONSE payload into inflated SCMap bytes.""" + """Decode raw B01 `MAP_RESPONSE` payload into inflated SCMap bytes.""" encrypted_payload = _decode_base64_payload(raw_payload) if len(encrypted_payload) % AES.block_size != 0: raise RoborockException("Unexpected encrypted B01 map payload length") @@ -138,6 +217,20 @@ def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: raise RoborockException("Invalid varint in B01 map payload") +def _read_fixed32(buf: bytes, idx: int, *, context: str) -> tuple[bytes, int]: + end = idx + 4 + if end > len(buf): + raise RoborockException(f"Truncated fixed32 in {context}") + return buf[idx:end], end + + +def _read_fixed64(buf: bytes, idx: int, *, context: str) -> tuple[bytes, int]: + end = idx + 8 + if end > len(buf): + raise RoborockException(f"Truncated fixed64 in {context}") + return buf[idx:end], end + + def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: length, idx = _read_varint(buf, idx) end = idx + length @@ -146,113 +239,224 @@ def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: return buf[idx:end], end -def _parse_map_data_info(blob: bytes) -> bytes: - """Extract and inflate occupancy raster bytes from SCMap mapDataInfo.""" - idx = 0 - while idx < len(blob): - key, idx = _read_varint(blob, idx) - field_no = key >> 3 - wire = key & 0x07 - if wire == 0: - _, idx = _read_varint(blob, idx) - elif wire == 2: - value, idx = _read_len_delimited(blob, idx) - if field_no == 1: - try: - return zlib.decompress(value) - except zlib.error: - return value - elif wire == 5: - idx += 4 - else: - raise RoborockException(f"Unsupported wire type {wire} in B01 mapDataInfo") - raise RoborockException("B01 map payload missing mapData") +def _decode_uint32(value: int) -> int: + return int(value) -def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: - room_id: int | None = None - room_name: str | None = None +def _decode_utf8(value: bytes) -> str: + return value.decode("utf-8", errors="replace") - idx = 0 - while idx < len(blob): - key, idx = _read_varint(blob, idx) - field_no = key >> 3 - wire = key & 0x07 - if wire == 0: - int_value, idx = _read_varint(blob, idx) - if field_no == 1: - room_id = int(int_value) - elif wire == 2: - bytes_value, idx = _read_len_delimited(blob, idx) - if field_no == 2: - room_name = bytes_value.decode("utf-8", errors="replace") - elif wire == 5: - idx += 4 - else: - raise RoborockException(f"Unsupported wire type {wire} in B01 roomDataInfo") - return room_id, room_name +def _decode_float32(value: bytes) -> float: + return struct.unpack(" tuple[int, int, bytes, dict[int, str]]: - """Parse inflated SCMap bytes.""" +def _decode_map_data_bytes(value: bytes) -> bytes: + try: + return zlib.decompress(value) + except zlib.error: + return value - size_x = 0 - size_y = 0 - grid = b"" - room_names: dict[int, str] = {} +def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context: str) -> dict[str, object]: + parsed: dict[str, object] = {} idx = 0 - while idx < len(payload): - key, idx = _read_varint(payload, idx) - field_no = key >> 3 + while idx < len(blob): + key, idx = _read_varint(blob, idx) wire = key & 0x07 + field_no = key >> 3 + + if wire == _WIRE_VARINT: + raw_value, idx = _read_varint(blob, idx) + elif wire == _WIRE_FIXED64: + raw_value, idx = _read_fixed64(blob, idx, context=context) + elif wire == _WIRE_LEN: + raw_value, idx = _read_len_delimited(blob, idx) + elif wire == _WIRE_FIXED32: + raw_value, idx = _read_fixed32(blob, idx, context=context) + else: + raise RoborockException(f"Unsupported wire type {wire} in {context}") - if wire == 0: - _, idx = _read_varint(payload, idx) + if (field_def := schema.get(field_no)) is None: continue + if wire != field_def.wire_type: + raise RoborockException(f"Unexpected wire type {wire} for field {field_no} in {context}") - if wire != 2: - if wire == 5: - idx += 4 - continue - raise RoborockException(f"Unsupported wire type {wire} in B01 map payload") - - value, idx = _read_len_delimited(payload, idx) - - if field_no == 3: # mapHead - hidx = 0 - while hidx < len(value): - hkey, hidx = _read_varint(value, hidx) - hfield = hkey >> 3 - hwire = hkey & 0x07 - if hwire == 0: - hvalue, hidx = _read_varint(value, hidx) - if hfield == 2: - size_x = int(hvalue) - elif hfield == 3: - size_y = int(hvalue) - elif hwire == 5: - hidx += 4 - elif hwire == 2: - _, hidx = _read_len_delimited(value, hidx) - else: - raise RoborockException(f"Unsupported wire type {hwire} in B01 map header") - elif field_no == 4: # mapDataInfo - grid = _parse_map_data_info(value) - elif field_no == 12: # roomDataInfo (repeated) - room_id, room_name = _parse_room_data_info(value) - if room_id is not None: - room_names[room_id] = room_name or f"Room {room_id}" - - if not size_x or not size_y or not grid: + value = field_def.parser(raw_value) if field_def.parser is not None else raw_value + if field_def.repeated: + parsed.setdefault(field_def.name, []).append(value) + else: + parsed[field_def.name] = value + + return parsed + + +def _parse_sc_point(blob: bytes) -> _ScPoint: + parsed = _parse_proto_message(blob, _DEVICE_POINT_INFO_SCHEMA, context="B01 DevicePointInfo") + return _ScPoint(x=parsed.get("x"), y=parsed.get("y")) + + +def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo: + parsed = _parse_proto_message(blob, _MAP_BOUNDARY_INFO_SCHEMA, context="B01 MapBoundaryInfo") + return _ScMapBoundaryInfo( + map_md5=parsed.get("map_md5"), + v_min_x=parsed.get("v_min_x"), + v_max_x=parsed.get("v_max_x"), + v_min_y=parsed.get("v_min_y"), + v_max_y=parsed.get("v_max_y"), + ) + + +def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo: + parsed = _parse_proto_message(blob, _MAP_EXT_INFO_SCHEMA, context="B01 MapExtInfo") + return _ScMapExtInfo( + task_begin_date=parsed.get("task_begin_date"), + map_upload_date=parsed.get("map_upload_date"), + map_valid=parsed.get("map_valid"), + radian=parsed.get("radian"), + force=parsed.get("force"), + clean_path=parsed.get("clean_path"), + boundary_info=parsed.get("boundary_info"), + map_version=parsed.get("map_version"), + map_value_type=parsed.get("map_value_type"), + ) + + +def _parse_sc_map_head(blob: bytes) -> _ScMapHead: + parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo") + return _ScMapHead( + map_head_id=parsed.get("map_head_id"), + size_x=parsed.get("size_x"), + size_y=parsed.get("size_y"), + min_x=parsed.get("min_x"), + min_y=parsed.get("min_y"), + max_x=parsed.get("max_x"), + max_y=parsed.get("max_y"), + resolution=parsed.get("resolution"), + ) + + +def _parse_sc_map_data_info(blob: bytes) -> bytes: + parsed = _parse_proto_message(blob, _MAP_DATA_INFO_SCHEMA, context="B01 MapDataInfo") + if (map_data := parsed.get("map_data")) is None: + raise RoborockException("B01 map payload missing mapData") + return map_data + + +def _parse_sc_room_data(blob: bytes) -> _ScRoomData: + parsed = _parse_proto_message(blob, _ROOM_DATA_INFO_SCHEMA, context="B01 RoomDataInfo") + return _ScRoomData( + room_id=parsed.get("room_id"), + room_name=parsed.get("room_name"), + room_type_id=parsed.get("room_type_id"), + material_id=parsed.get("material_id"), + clean_state=parsed.get("clean_state"), + room_clean=parsed.get("room_clean"), + room_clean_index=parsed.get("room_clean_index"), + room_name_post=parsed.get("room_name_post"), + color_id=parsed.get("color_id"), + floor_direction=parsed.get("floor_direction"), + global_seq=parsed.get("global_seq"), + ) + + +def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: + """Parse inflated SCMap bytes using the reverse-engineered app field layout.""" + parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap") + return _ScMapPayload( + map_type=parsed.get("map_type"), + map_ext_info=parsed.get("map_ext_info"), + map_head=parsed.get("map_head"), + map_data=parsed.get("map_data"), + room_data_info=tuple(parsed.get("room_data_info", [])), + ) + + +def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]: + if scmap.map_head is None or scmap.map_data is None: + raise RoborockException("Failed to parse B01 map header/grid") + + size_x = scmap.map_head.size_x or 0 + size_y = scmap.map_head.size_y or 0 + if not size_x or not size_y or not scmap.map_data: raise RoborockException("Failed to parse B01 map header/grid") expected_len = size_x * size_y - if len(grid) < expected_len: + if len(scmap.map_data) < expected_len: raise RoborockException("B01 map data shorter than expected dimensions") - return size_x, size_y, grid[:expected_len], room_names + return size_x, size_y, scmap.map_data[:expected_len] + + +def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]: + room_names: dict[int, str] = {} + for room in rooms: + if room.room_id is not None: + room_names[room.room_id] = room.room_name or f"Room {room.room_id}" + return room_names + + +_DEVICE_POINT_INFO_SCHEMA = { + 1: _ProtoField("x", _WIRE_FIXED32, parser=_decode_float32), + 2: _ProtoField("y", _WIRE_FIXED32, parser=_decode_float32), +} + +_MAP_BOUNDARY_INFO_SCHEMA = { + 1: _ProtoField("map_md5", _WIRE_LEN, parser=_decode_utf8), + 2: _ProtoField("v_min_x", _WIRE_VARINT, parser=_decode_uint32), + 3: _ProtoField("v_max_x", _WIRE_VARINT, parser=_decode_uint32), + 4: _ProtoField("v_min_y", _WIRE_VARINT, parser=_decode_uint32), + 5: _ProtoField("v_max_y", _WIRE_VARINT, parser=_decode_uint32), +} + +_MAP_EXT_INFO_SCHEMA = { + 1: _ProtoField("task_begin_date", _WIRE_VARINT, parser=_decode_uint32), + 2: _ProtoField("map_upload_date", _WIRE_VARINT, parser=_decode_uint32), + 3: _ProtoField("map_valid", _WIRE_VARINT, parser=_decode_uint32), + 4: _ProtoField("radian", _WIRE_VARINT, parser=_decode_uint32), + 5: _ProtoField("force", _WIRE_VARINT, parser=_decode_uint32), + 6: _ProtoField("clean_path", _WIRE_VARINT, parser=_decode_uint32), + 7: _ProtoField("boundary_info", _WIRE_LEN, parser=_parse_sc_map_boundary_info), + 8: _ProtoField("map_version", _WIRE_VARINT, parser=_decode_uint32), + 9: _ProtoField("map_value_type", _WIRE_VARINT, parser=_decode_uint32), +} + +_MAP_HEAD_INFO_SCHEMA = { + 1: _ProtoField("map_head_id", _WIRE_VARINT, parser=_decode_uint32), + 2: _ProtoField("size_x", _WIRE_VARINT, parser=_decode_uint32), + 3: _ProtoField("size_y", _WIRE_VARINT, parser=_decode_uint32), + 4: _ProtoField("min_x", _WIRE_FIXED32, parser=_decode_float32), + 5: _ProtoField("min_y", _WIRE_FIXED32, parser=_decode_float32), + 6: _ProtoField("max_x", _WIRE_FIXED32, parser=_decode_float32), + 7: _ProtoField("max_y", _WIRE_FIXED32, parser=_decode_float32), + 8: _ProtoField("resolution", _WIRE_FIXED32, parser=_decode_float32), +} + +_MAP_DATA_INFO_SCHEMA = { + 1: _ProtoField("map_data", _WIRE_LEN, parser=_decode_map_data_bytes), +} + +_ROOM_DATA_INFO_SCHEMA = { + 1: _ProtoField("room_id", _WIRE_VARINT, parser=_decode_uint32), + 2: _ProtoField("room_name", _WIRE_LEN, parser=_decode_utf8), + 3: _ProtoField("room_type_id", _WIRE_VARINT, parser=_decode_uint32), + 4: _ProtoField("material_id", _WIRE_VARINT, parser=_decode_uint32), + 5: _ProtoField("clean_state", _WIRE_VARINT, parser=_decode_uint32), + 6: _ProtoField("room_clean", _WIRE_VARINT, parser=_decode_uint32), + 7: _ProtoField("room_clean_index", _WIRE_VARINT, parser=_decode_uint32), + 8: _ProtoField("room_name_post", _WIRE_LEN, parser=_parse_sc_point), + 10: _ProtoField("color_id", _WIRE_VARINT, parser=_decode_uint32), + 11: _ProtoField("floor_direction", _WIRE_VARINT, parser=_decode_uint32), + 12: _ProtoField("global_seq", _WIRE_VARINT, parser=_decode_uint32), +} + +_ROBOT_MAP_SCHEMA = { + 1: _ProtoField("map_type", _WIRE_VARINT, parser=_decode_uint32), + 2: _ProtoField("map_ext_info", _WIRE_LEN, parser=_parse_sc_map_ext_info), + 3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_sc_map_head), + 4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_sc_map_data_info), + 12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_sc_room_data), +} def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image: diff --git a/tests/map/debug_b01_scmap.py b/tests/map/debug_b01_scmap.py index b9bba89c..fa8ce6c0 100644 --- a/tests/map/debug_b01_scmap.py +++ b/tests/map/debug_b01_scmap.py @@ -10,7 +10,7 @@ - For runtime code, reverse-engineering and committing guessed schema files would imply more certainty than we actually have. -So the library keeps a tiny schema-free parser for the fields it needs, while +So the library keeps a tiny declarative parser for the fields it needs, while this script provides a convenient place to inspect unknown payloads during future debugging. @@ -127,13 +127,13 @@ def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: field_no = key >> 3 wire = key & 0x07 if wire == 0: - int_value, idx = _read_varint(blob, idx) + value, idx = _read_varint(blob, idx) if field_no == 1: - room_id = int(int_value) + room_id = int(value) elif wire == 2: - bytes_value, idx = _read_len_delimited(blob, idx) + value, idx = _read_len_delimited(blob, idx) if field_no == 2: - room_name = bytes_value.decode("utf-8", errors="replace") + room_name = value.decode("utf-8", errors="replace") elif wire == 5: idx += 4 else: @@ -234,21 +234,21 @@ def _dump_message(blob: bytes, *, indent: str = "", max_depth: int = 2, depth: i wire = key & 0x07 if wire == 0: - int_value, idx = _read_varint(blob, idx) - print(f"{indent}field {field_no} @ {start}: varint {int_value}") + value, idx = _read_varint(blob, idx) + print(f"{indent}field {field_no} @ {start}: varint {value}") elif wire == 1: - bytes_value = blob[idx : idx + 8] + value = blob[idx : idx + 8] idx += 8 - print(f"{indent}field {field_no} @ {start}: fixed64 {_preview(bytes_value, 8)}") + print(f"{indent}field {field_no} @ {start}: fixed64 {_preview(value, 8)}") elif wire == 2: - bytes_value, idx = _read_len_delimited(blob, idx) - print(f"{indent}field {field_no} @ {start}: len-delimited {_preview(bytes_value)}") - if depth < max_depth and _looks_like_message(bytes_value): - _dump_message(bytes_value, indent=indent + " ", max_depth=max_depth, depth=depth + 1) + value, idx = _read_len_delimited(blob, idx) + print(f"{indent}field {field_no} @ {start}: len-delimited {_preview(value)}") + if depth < max_depth and _looks_like_message(value): + _dump_message(value, indent=indent + " ", max_depth=max_depth, depth=depth + 1) elif wire == 5: - bytes_value = blob[idx : idx + 4] + value = blob[idx : idx + 4] idx += 4 - print(f"{indent}field {field_no} @ {start}: fixed32 {_preview(bytes_value, 4)}") + print(f"{indent}field {field_no} @ {start}: fixed32 {_preview(value, 4)}") else: print(f"{indent}field {field_no} @ {start}: unsupported wire type {wire}") return diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 23a72b75..c6e6c8c7 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -2,6 +2,7 @@ import gzip import hashlib import io +import struct import zlib from pathlib import Path @@ -11,7 +12,7 @@ from PIL import Image from roborock.exceptions import RoborockException -from roborock.map.b01_map_parser import B01MapParser +from roborock.map.b01_map_parser import B01MapParser, _parse_scmap_payload FIXTURE = Path(__file__).resolve().parent / "testdata" / "raw-mqtt-map301.bin.inflated.bin.gz" @@ -25,6 +26,30 @@ def _derive_map_key(serial: str, model: str) -> bytes: return md5[8:24].encode() +def _encode_varint(value: int) -> bytes: + encoded = bytearray() + while True: + to_write = value & 0x7F + value >>= 7 + if value: + encoded.append(to_write | 0x80) + else: + encoded.append(to_write) + return bytes(encoded) + + +def _field_varint(field_no: int, value: int) -> bytes: + return _encode_varint((field_no << 3) | 0) + _encode_varint(value) + + +def _field_len(field_no: int, value: bytes) -> bytes: + return _encode_varint((field_no << 3) | 2) + _encode_varint(len(value)) + value + + +def _field_float32(field_no: int, value: float) -> bytes: + return _encode_varint((field_no << 3) | 5) + struct.pack(" None: serial = "testsn012345" model = "roborock.vacuum.sc05" @@ -61,6 +86,87 @@ def test_b01_map_parser_decodes_and_renders_fixture() -> None: assert img.size == (340 * 4, 300 * 4) +def test_b01_scmap_parser_maps_reverse_engineered_schema_fields() -> None: + room_name_post = _field_float32(1, 11.25) + _field_float32(2, 22.5) + room_one = b"".join( + [ + _field_varint(1, 42), + _field_len(2, b"Kitchen"), + _field_varint(5, 1), + _field_len(8, room_name_post), + _field_varint(10, 7), + _field_varint(12, 9), + ] + ) + room_two = b"".join([_field_varint(1, 99), _field_varint(5, 0)]) + + boundary_info = b"".join( + [ + _field_len(1, b"md5"), + _field_varint(2, 10), + _field_varint(3, 20), + _field_varint(4, 30), + _field_varint(5, 40), + ] + ) + map_ext_info = b"".join( + [ + _field_varint(1, 100), + _field_varint(2, 200), + _field_varint(3, 1), + _field_varint(8, 3), + _field_len(7, boundary_info), + ] + ) + map_head = b"".join( + [ + _field_varint(1, 7), + _field_varint(2, 2), + _field_varint(3, 2), + _field_float32(4, 1.5), + _field_float32(5, 2.5), + _field_float32(6, 3.5), + _field_float32(7, 4.5), + _field_float32(8, 0.05), + ] + ) + map_data = _field_len(1, zlib.compress(bytes([0, 127, 128, 128]))) + payload = b"".join( + [ + _field_varint(1, 1), + _field_len(2, map_ext_info), + _field_len(3, map_head), + _field_len(4, map_data), + _field_len(12, room_one), + _field_len(12, room_two), + ] + ) + + parsed = _parse_scmap_payload(payload) + + assert parsed.map_type == 1 + assert parsed.map_ext_info is not None + assert parsed.map_ext_info.task_begin_date == 100 + assert parsed.map_ext_info.map_upload_date == 200 + assert parsed.map_ext_info.boundary_info is not None + assert parsed.map_ext_info.boundary_info.v_max_y == 40 + assert parsed.map_head is not None + assert parsed.map_head.map_head_id == 7 + assert parsed.map_head.size_x == 2 + assert parsed.map_head.size_y == 2 + assert parsed.map_head.resolution == pytest.approx(0.05) + assert parsed.map_data == bytes([0, 127, 128, 128]) + assert parsed.room_data_info[0].room_id == 42 + assert parsed.room_data_info[0].room_name == "Kitchen" + assert parsed.room_data_info[0].room_name_post is not None + assert parsed.room_data_info[0].room_name_post.x == pytest.approx(11.25) + assert parsed.room_data_info[0].room_name_post.y == pytest.approx(22.5) + assert parsed.room_data_info[0].color_id == 7 + assert parsed.room_data_info[0].global_seq == 9 + assert parsed.room_data_info[1].room_id == 99 + assert parsed.room_data_info[1].room_name is None + + def test_b01_map_parser_rejects_invalid_payload() -> None: parser = B01MapParser() with pytest.raises(RoborockException, match="Failed to decode B01 map payload"): From f8c069c331724bd40873b2496dc3cf80bfdaac40 Mon Sep 17 00:00:00 2001 From: arduano Date: Sun, 15 Mar 2026 16:00:32 +1100 Subject: [PATCH 03/14] refactor: trim q7 map parser scope --- roborock/devices/traits/b01/q7/map_content.py | 7 +- roborock/map/b01_map_parser.py | 232 ++----------- tests/map/debug_b01_scmap.py | 304 ------------------ tests/map/test_b01_map_parser.py | 99 ++---- 4 files changed, 55 insertions(+), 587 deletions(-) delete mode 100644 tests/map/debug_b01_scmap.py diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index 2fd7ee3b..caced24e 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -1,7 +1,8 @@ """Trait for fetching parsed map content from B01/Q7 devices. -This follows the same basic pattern as the v1 `MapContentTrait`: +This intentionally mirrors the v1 `MapContentTrait` contract: - `refresh()` performs I/O and populates cached fields +- `parse_map_content()` reparses cached raw bytes without I/O - fields `image_content`, `map_data`, and `raw_api_response` are then readable For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`. @@ -75,8 +76,8 @@ async def refresh(self) -> None: def parse_map_content(self, response: bytes) -> MapContent: """Parse map content from raw bytes. - Exposed so callers can re-parse cached map payload bytes without - performing I/O. + This mirrors the v1 trait behavior so cached map payload bytes can be + reparsed without going back to the device. """ if not self._serial or not self._model: raise RoborockException( diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 4c4821a3..2e29b1e9 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -6,11 +6,9 @@ - PKCS7 padded - ASCII hex for a zlib-compressed SCMap payload -The inner SCMap blob is a protobuf-wire message. We know the app's field layout -well enough to describe the fields we care about declaratively, but we still -avoid shipping generated protobuf classes from a reverse-engineered schema. -That keeps the runtime parser narrow without overstating certainty about the -full message definition. +The inner SCMap blob uses protobuf wire encoding. We keep the runtime parser +minimal and declarative: only the fields needed to render the occupancy image +and expose room names are described here. """ from __future__ import annotations @@ -19,10 +17,9 @@ import binascii import hashlib import io -import struct import zlib from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -52,70 +49,6 @@ class _ProtoField: parser: Callable[[object], object] | None = None -@dataclass(frozen=True) -class _ScPoint: - x: float | None = None - y: float | None = None - - -@dataclass(frozen=True) -class _ScMapBoundaryInfo: - map_md5: str | None = None - v_min_x: int | None = None - v_max_x: int | None = None - v_min_y: int | None = None - v_max_y: int | None = None - - -@dataclass(frozen=True) -class _ScMapExtInfo: - task_begin_date: int | None = None - map_upload_date: int | None = None - map_valid: int | None = None - radian: int | None = None - force: int | None = None - clean_path: int | None = None - boundary_info: _ScMapBoundaryInfo | None = None - map_version: int | None = None - map_value_type: int | None = None - - -@dataclass(frozen=True) -class _ScMapHead: - map_head_id: int | None = None - size_x: int | None = None - size_y: int | None = None - min_x: float | None = None - min_y: float | None = None - max_x: float | None = None - max_y: float | None = None - resolution: float | None = None - - -@dataclass(frozen=True) -class _ScRoomData: - room_id: int | None = None - room_name: str | None = None - room_type_id: int | None = None - material_id: int | None = None - clean_state: int | None = None - room_clean: int | None = None - room_clean_index: int | None = None - room_name_post: _ScPoint | None = None - color_id: int | None = None - floor_direction: int | None = None - global_seq: int | None = None - - -@dataclass(frozen=True) -class _ScMapPayload: - map_type: int | None = None - map_ext_info: _ScMapExtInfo | None = None - map_head: _ScMapHead | None = None - map_data: bytes | None = None - room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple) - - @dataclass class B01MapParserConfig: """Configuration for the B01/Q7 map parser.""" @@ -133,9 +66,7 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None: def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData: """Parse a raw MAP_RESPONSE payload and return a PNG + MapData.""" inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model) - scmap = _parse_scmap_payload(inflated) - size_x, size_y, grid = _extract_grid(scmap) - room_names = _extract_room_names(scmap.room_data_info) + size_x, size_y, grid, room_names = _parse_scmap_payload(inflated) image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) @@ -247,10 +178,6 @@ def _decode_utf8(value: bytes) -> str: return value.decode("utf-8", errors="replace") -def _decode_float32(value: bytes) -> float: - return struct.unpack(" bytes: try: return zlib.decompress(value) @@ -291,145 +218,47 @@ def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context return parsed -def _parse_sc_point(blob: bytes) -> _ScPoint: - parsed = _parse_proto_message(blob, _DEVICE_POINT_INFO_SCHEMA, context="B01 DevicePointInfo") - return _ScPoint(x=parsed.get("x"), y=parsed.get("y")) - - -def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo: - parsed = _parse_proto_message(blob, _MAP_BOUNDARY_INFO_SCHEMA, context="B01 MapBoundaryInfo") - return _ScMapBoundaryInfo( - map_md5=parsed.get("map_md5"), - v_min_x=parsed.get("v_min_x"), - v_max_x=parsed.get("v_max_x"), - v_min_y=parsed.get("v_min_y"), - v_max_y=parsed.get("v_max_y"), - ) - - -def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo: - parsed = _parse_proto_message(blob, _MAP_EXT_INFO_SCHEMA, context="B01 MapExtInfo") - return _ScMapExtInfo( - task_begin_date=parsed.get("task_begin_date"), - map_upload_date=parsed.get("map_upload_date"), - map_valid=parsed.get("map_valid"), - radian=parsed.get("radian"), - force=parsed.get("force"), - clean_path=parsed.get("clean_path"), - boundary_info=parsed.get("boundary_info"), - map_version=parsed.get("map_version"), - map_value_type=parsed.get("map_value_type"), - ) +def _parse_map_head_info(blob: bytes) -> tuple[int | None, int | None]: + parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo") + return parsed.get("size_x"), parsed.get("size_y") -def _parse_sc_map_head(blob: bytes) -> _ScMapHead: - parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo") - return _ScMapHead( - map_head_id=parsed.get("map_head_id"), - size_x=parsed.get("size_x"), - size_y=parsed.get("size_y"), - min_x=parsed.get("min_x"), - min_y=parsed.get("min_y"), - max_x=parsed.get("max_x"), - max_y=parsed.get("max_y"), - resolution=parsed.get("resolution"), - ) - - -def _parse_sc_map_data_info(blob: bytes) -> bytes: +def _parse_map_data_info(blob: bytes) -> bytes: parsed = _parse_proto_message(blob, _MAP_DATA_INFO_SCHEMA, context="B01 MapDataInfo") if (map_data := parsed.get("map_data")) is None: raise RoborockException("B01 map payload missing mapData") return map_data -def _parse_sc_room_data(blob: bytes) -> _ScRoomData: +def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: parsed = _parse_proto_message(blob, _ROOM_DATA_INFO_SCHEMA, context="B01 RoomDataInfo") - return _ScRoomData( - room_id=parsed.get("room_id"), - room_name=parsed.get("room_name"), - room_type_id=parsed.get("room_type_id"), - material_id=parsed.get("material_id"), - clean_state=parsed.get("clean_state"), - room_clean=parsed.get("room_clean"), - room_clean_index=parsed.get("room_clean_index"), - room_name_post=parsed.get("room_name_post"), - color_id=parsed.get("color_id"), - floor_direction=parsed.get("floor_direction"), - global_seq=parsed.get("global_seq"), - ) - - -def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: - """Parse inflated SCMap bytes using the reverse-engineered app field layout.""" - parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap") - return _ScMapPayload( - map_type=parsed.get("map_type"), - map_ext_info=parsed.get("map_ext_info"), - map_head=parsed.get("map_head"), - map_data=parsed.get("map_data"), - room_data_info=tuple(parsed.get("room_data_info", [])), - ) + return parsed.get("room_id"), parsed.get("room_name") -def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]: - if scmap.map_head is None or scmap.map_data is None: - raise RoborockException("Failed to parse B01 map header/grid") +def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]: + """Parse inflated SCMap bytes using the reverse-engineered field layout we need.""" + parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap") - size_x = scmap.map_head.size_x or 0 - size_y = scmap.map_head.size_y or 0 - if not size_x or not size_y or not scmap.map_data: + size_x, size_y = parsed.get("map_head", (None, None)) + grid = parsed.get("map_data") + if not size_x or not size_y or not grid: raise RoborockException("Failed to parse B01 map header/grid") expected_len = size_x * size_y - if len(scmap.map_data) < expected_len: + if len(grid) < expected_len: raise RoborockException("B01 map data shorter than expected dimensions") - return size_x, size_y, scmap.map_data[:expected_len] - - -def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]: room_names: dict[int, str] = {} - for room in rooms: - if room.room_id is not None: - room_names[room.room_id] = room.room_name or f"Room {room.room_id}" - return room_names + for room_id, room_name in parsed.get("room_data_info", []): + if room_id is not None: + room_names[room_id] = room_name or f"Room {room_id}" + return size_x, size_y, grid[:expected_len], room_names -_DEVICE_POINT_INFO_SCHEMA = { - 1: _ProtoField("x", _WIRE_FIXED32, parser=_decode_float32), - 2: _ProtoField("y", _WIRE_FIXED32, parser=_decode_float32), -} - -_MAP_BOUNDARY_INFO_SCHEMA = { - 1: _ProtoField("map_md5", _WIRE_LEN, parser=_decode_utf8), - 2: _ProtoField("v_min_x", _WIRE_VARINT, parser=_decode_uint32), - 3: _ProtoField("v_max_x", _WIRE_VARINT, parser=_decode_uint32), - 4: _ProtoField("v_min_y", _WIRE_VARINT, parser=_decode_uint32), - 5: _ProtoField("v_max_y", _WIRE_VARINT, parser=_decode_uint32), -} - -_MAP_EXT_INFO_SCHEMA = { - 1: _ProtoField("task_begin_date", _WIRE_VARINT, parser=_decode_uint32), - 2: _ProtoField("map_upload_date", _WIRE_VARINT, parser=_decode_uint32), - 3: _ProtoField("map_valid", _WIRE_VARINT, parser=_decode_uint32), - 4: _ProtoField("radian", _WIRE_VARINT, parser=_decode_uint32), - 5: _ProtoField("force", _WIRE_VARINT, parser=_decode_uint32), - 6: _ProtoField("clean_path", _WIRE_VARINT, parser=_decode_uint32), - 7: _ProtoField("boundary_info", _WIRE_LEN, parser=_parse_sc_map_boundary_info), - 8: _ProtoField("map_version", _WIRE_VARINT, parser=_decode_uint32), - 9: _ProtoField("map_value_type", _WIRE_VARINT, parser=_decode_uint32), -} _MAP_HEAD_INFO_SCHEMA = { - 1: _ProtoField("map_head_id", _WIRE_VARINT, parser=_decode_uint32), 2: _ProtoField("size_x", _WIRE_VARINT, parser=_decode_uint32), 3: _ProtoField("size_y", _WIRE_VARINT, parser=_decode_uint32), - 4: _ProtoField("min_x", _WIRE_FIXED32, parser=_decode_float32), - 5: _ProtoField("min_y", _WIRE_FIXED32, parser=_decode_float32), - 6: _ProtoField("max_x", _WIRE_FIXED32, parser=_decode_float32), - 7: _ProtoField("max_y", _WIRE_FIXED32, parser=_decode_float32), - 8: _ProtoField("resolution", _WIRE_FIXED32, parser=_decode_float32), } _MAP_DATA_INFO_SCHEMA = { @@ -439,23 +268,12 @@ def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]: _ROOM_DATA_INFO_SCHEMA = { 1: _ProtoField("room_id", _WIRE_VARINT, parser=_decode_uint32), 2: _ProtoField("room_name", _WIRE_LEN, parser=_decode_utf8), - 3: _ProtoField("room_type_id", _WIRE_VARINT, parser=_decode_uint32), - 4: _ProtoField("material_id", _WIRE_VARINT, parser=_decode_uint32), - 5: _ProtoField("clean_state", _WIRE_VARINT, parser=_decode_uint32), - 6: _ProtoField("room_clean", _WIRE_VARINT, parser=_decode_uint32), - 7: _ProtoField("room_clean_index", _WIRE_VARINT, parser=_decode_uint32), - 8: _ProtoField("room_name_post", _WIRE_LEN, parser=_parse_sc_point), - 10: _ProtoField("color_id", _WIRE_VARINT, parser=_decode_uint32), - 11: _ProtoField("floor_direction", _WIRE_VARINT, parser=_decode_uint32), - 12: _ProtoField("global_seq", _WIRE_VARINT, parser=_decode_uint32), } _ROBOT_MAP_SCHEMA = { - 1: _ProtoField("map_type", _WIRE_VARINT, parser=_decode_uint32), - 2: _ProtoField("map_ext_info", _WIRE_LEN, parser=_parse_sc_map_ext_info), - 3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_sc_map_head), - 4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_sc_map_data_info), - 12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_sc_room_data), + 3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_map_head_info), + 4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_map_data_info), + 12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_room_data_info), } diff --git a/tests/map/debug_b01_scmap.py b/tests/map/debug_b01_scmap.py deleted file mode 100644 index fa8ce6c0..00000000 --- a/tests/map/debug_b01_scmap.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Developer helper for inspecting B01/Q7 SCMap payloads. - -This script is intentionally kept outside the runtime package so it stays -non-obtrusive. It is useful when reverse-engineering new payload samples or -validating assumptions about the current parser. - -Why not generated protobuf classes here? -- The inflated SCMap payload looks like protobuf wire format. -- We do not have an upstream `.proto` schema. -- For runtime code, reverse-engineering and committing guessed schema files - would imply more certainty than we actually have. - -So the library keeps a tiny declarative parser for the fields it needs, while -this script provides a convenient place to inspect unknown payloads during -future debugging. - -This helper is intentionally standalone and does not import private runtime -helpers. That keeps it useful for debugging without coupling test/dev tooling to -internal implementation details. -""" - -from __future__ import annotations - -import argparse -import base64 -import binascii -import gzip -import hashlib -import zlib -from pathlib import Path - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad, unpad - -_B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") - - -def _derive_map_key(serial: str, model: str) -> bytes: - model_suffix = model.split(".")[-1] - model_key = (model_suffix + "0" * 16)[:16].encode() - material = f"{serial}+{model_suffix}+{serial}".encode() - encrypted = AES.new(model_key, AES.MODE_ECB).encrypt(pad(material, AES.block_size)) - md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest() - return md5[8:24].encode() - - -def _decode_base64_payload(raw_payload: bytes) -> bytes: - blob = raw_payload.strip() - if len(blob) < 32 or any(b not in _B64_CHARS for b in blob): - raise ValueError("Unexpected B01 map payload format") - - padded = blob + b"=" * (-len(blob) % 4) - try: - return base64.b64decode(padded, validate=True) - except binascii.Error as err: - raise ValueError("Failed to decode B01 map payload") from err - - -def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes: - encrypted_payload = _decode_base64_payload(raw_payload) - if len(encrypted_payload) % AES.block_size != 0: - raise ValueError("Unexpected encrypted B01 map payload length") - - map_key = _derive_map_key(serial, model) - decrypted_hex = AES.new(map_key, AES.MODE_ECB).decrypt(encrypted_payload) - - try: - compressed_hex = unpad(decrypted_hex, AES.block_size).decode("ascii") - compressed_payload = bytes.fromhex(compressed_hex) - return zlib.decompress(compressed_payload) - except (ValueError, UnicodeDecodeError, zlib.error) as err: - raise ValueError("Failed to decode B01 map payload") from err - - -def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: - value = 0 - shift = 0 - while True: - if idx >= len(buf): - raise ValueError("Truncated varint") - byte = buf[idx] - idx += 1 - value |= (byte & 0x7F) << shift - if not byte & 0x80: - return value, idx - shift += 7 - if shift > 63: - raise ValueError("Invalid varint") - - -def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: - length, idx = _read_varint(buf, idx) - end = idx + length - if end > len(buf): - raise ValueError("Invalid length-delimited field") - return buf[idx:end], end - - -def _parse_map_data_info(blob: bytes) -> bytes: - idx = 0 - while idx < len(blob): - key, idx = _read_varint(blob, idx) - field_no = key >> 3 - wire = key & 0x07 - if wire == 0: - _, idx = _read_varint(blob, idx) - elif wire == 2: - value, idx = _read_len_delimited(blob, idx) - if field_no == 1: - try: - return zlib.decompress(value) - except zlib.error: - return value - elif wire == 5: - idx += 4 - else: - raise ValueError(f"Unsupported wire type {wire} in mapDataInfo") - raise ValueError("SCMap missing mapData") - - -def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: - room_id: int | None = None - room_name: str | None = None - idx = 0 - while idx < len(blob): - key, idx = _read_varint(blob, idx) - field_no = key >> 3 - wire = key & 0x07 - if wire == 0: - value, idx = _read_varint(blob, idx) - if field_no == 1: - room_id = int(value) - elif wire == 2: - value, idx = _read_len_delimited(blob, idx) - if field_no == 2: - room_name = value.decode("utf-8", errors="replace") - elif wire == 5: - idx += 4 - else: - raise ValueError(f"Unsupported wire type {wire} in roomDataInfo") - return room_id, room_name - - -def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]: - size_x = 0 - size_y = 0 - grid = b"" - room_names: dict[int, str] = {} - - idx = 0 - while idx < len(payload): - key, idx = _read_varint(payload, idx) - field_no = key >> 3 - wire = key & 0x07 - - if wire == 0: - _, idx = _read_varint(payload, idx) - continue - - if wire != 2: - if wire == 5: - idx += 4 - continue - raise ValueError(f"Unsupported wire type {wire} in SCMap payload") - - value, idx = _read_len_delimited(payload, idx) - if field_no == 3: - hidx = 0 - while hidx < len(value): - hkey, hidx = _read_varint(value, hidx) - hfield = hkey >> 3 - hwire = hkey & 0x07 - if hwire == 0: - hvalue, hidx = _read_varint(value, hidx) - if hfield == 2: - size_x = int(hvalue) - elif hfield == 3: - size_y = int(hvalue) - elif hwire == 5: - hidx += 4 - elif hwire == 2: - _, hidx = _read_len_delimited(value, hidx) - else: - raise ValueError(f"Unsupported wire type {hwire} in map header") - elif field_no == 4: - grid = _parse_map_data_info(value) - elif field_no == 12: - room_id, room_name = _parse_room_data_info(value) - if room_id is not None: - room_names[room_id] = room_name or f"Room {room_id}" - - return size_x, size_y, grid, room_names - - -def _looks_like_message(blob: bytes) -> bool: - if not blob or len(blob) > 4096: - return False - - idx = 0 - seen = 0 - try: - while idx < len(blob): - key, idx = _read_varint(blob, idx) - wire = key & 0x07 - seen += 1 - if wire == 0: - _, idx = _read_varint(blob, idx) - elif wire == 1: - idx += 8 - elif wire == 2: - _, idx = _read_len_delimited(blob, idx) - elif wire == 5: - idx += 4 - else: - return False - return seen > 0 and idx == len(blob) - except Exception: - return False - - -def _preview(blob: bytes, limit: int = 24) -> str: - text = blob[:limit].hex() - if len(blob) > limit: - return f"{text}... ({len(blob)} bytes)" - return f"{text} ({len(blob)} bytes)" - - -def _dump_message(blob: bytes, *, indent: str = "", max_depth: int = 2, depth: int = 0) -> None: - idx = 0 - while idx < len(blob): - start = idx - key, idx = _read_varint(blob, idx) - field_no = key >> 3 - wire = key & 0x07 - - if wire == 0: - value, idx = _read_varint(blob, idx) - print(f"{indent}field {field_no} @ {start}: varint {value}") - elif wire == 1: - value = blob[idx : idx + 8] - idx += 8 - print(f"{indent}field {field_no} @ {start}: fixed64 {_preview(value, 8)}") - elif wire == 2: - value, idx = _read_len_delimited(blob, idx) - print(f"{indent}field {field_no} @ {start}: len-delimited {_preview(value)}") - if depth < max_depth and _looks_like_message(value): - _dump_message(value, indent=indent + " ", max_depth=max_depth, depth=depth + 1) - elif wire == 5: - value = blob[idx : idx + 4] - idx += 4 - print(f"{indent}field {field_no} @ {start}: fixed32 {_preview(value, 4)}") - else: - print(f"{indent}field {field_no} @ {start}: unsupported wire type {wire}") - return - - -def _load_payload(args: argparse.Namespace) -> bytes: - if args.inflated_gzip is not None: - return gzip.decompress(args.inflated_gzip.read_bytes()) - if args.inflated_bin is not None: - return args.inflated_bin.read_bytes() - if args.raw_map_response is not None: - if not args.serial or not args.model: - raise SystemExit("--raw-map-response requires --serial and --model") - return _decode_b01_map_payload( - args.raw_map_response.read_bytes(), - serial=args.serial, - model=args.model, - ) - raise SystemExit("one of --inflated-gzip, --inflated-bin, or --raw-map-response is required") - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--inflated-gzip", type=Path, help="Path to gzipped inflated SCMap payload") - group.add_argument("--inflated-bin", type=Path, help="Path to raw inflated SCMap payload") - group.add_argument("--raw-map-response", type=Path, help="Path to raw MAP_RESPONSE payload bytes") - parser.add_argument("--serial", help="Device serial number (required for --raw-map-response)") - parser.add_argument("--model", help="Device model, e.g. roborock.vacuum.sc05 (required for --raw-map-response)") - parser.add_argument( - "--max-depth", - type=int, - default=2, - help="Maximum recursive dump depth for protobuf-like messages", - ) - return parser - - -def main() -> None: - args = _build_parser().parse_args() - payload = _load_payload(args) - - size_x, size_y, grid, room_names = _parse_scmap_payload(payload) - print(f"Inflated payload: {len(payload)} bytes") - print(f"Map size: {size_x} x {size_y}") - print(f"Grid bytes: {len(grid)}") - print(f"Room names: {room_names}") - print("\nTop-level field dump:") - _dump_message(payload, max_depth=args.max_depth) - - -if __name__ == "__main__": - main() diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index c6e6c8c7..3642b9a9 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -2,7 +2,6 @@ import gzip import hashlib import io -import struct import zlib from pathlib import Path @@ -46,10 +45,6 @@ def _field_len(field_no: int, value: bytes) -> bytes: return _encode_varint((field_no << 3) | 2) + _encode_varint(len(value)) + value -def _field_float32(field_no: int, value: float) -> bytes: - return _encode_varint((field_no << 3) | 5) + struct.pack(" None: serial = "testsn012345" model = "roborock.vacuum.sc05" @@ -86,55 +81,27 @@ def test_b01_map_parser_decodes_and_renders_fixture() -> None: assert img.size == (340 * 4, 300 * 4) -def test_b01_scmap_parser_maps_reverse_engineered_schema_fields() -> None: - room_name_post = _field_float32(1, 11.25) + _field_float32(2, 22.5) - room_one = b"".join( - [ - _field_varint(1, 42), - _field_len(2, b"Kitchen"), - _field_varint(5, 1), - _field_len(8, room_name_post), - _field_varint(10, 7), - _field_varint(12, 9), - ] - ) - room_two = b"".join([_field_varint(1, 99), _field_varint(5, 0)]) - - boundary_info = b"".join( - [ - _field_len(1, b"md5"), - _field_varint(2, 10), - _field_varint(3, 20), - _field_varint(4, 30), - _field_varint(5, 40), - ] - ) - map_ext_info = b"".join( - [ - _field_varint(1, 100), - _field_varint(2, 200), - _field_varint(3, 1), - _field_varint(8, 3), - _field_len(7, boundary_info), - ] - ) - map_head = b"".join( - [ - _field_varint(1, 7), - _field_varint(2, 2), - _field_varint(3, 2), - _field_float32(4, 1.5), - _field_float32(5, 2.5), - _field_float32(6, 3.5), - _field_float32(7, 4.5), - _field_float32(8, 0.05), - ] - ) +def test_b01_scmap_parser_maps_selected_reverse_engineered_fields() -> None: + room_one = b"".join([ + _field_varint(1, 42), + _field_len(2, b"Kitchen"), + _field_varint(5, 1), + ]) + room_two = b"".join([ + _field_varint(1, 99), + ]) + + map_head = b"".join([ + _field_varint(1, 7), + _field_varint(2, 2), + _field_varint(3, 2), + _field_varint(9, 999), + ]) map_data = _field_len(1, zlib.compress(bytes([0, 127, 128, 128]))) payload = b"".join( [ _field_varint(1, 1), - _field_len(2, map_ext_info), + _field_len(2, b"ignored map ext info"), _field_len(3, map_head), _field_len(4, map_data), _field_len(12, room_one), @@ -142,29 +109,15 @@ def test_b01_scmap_parser_maps_reverse_engineered_schema_fields() -> None: ] ) - parsed = _parse_scmap_payload(payload) - - assert parsed.map_type == 1 - assert parsed.map_ext_info is not None - assert parsed.map_ext_info.task_begin_date == 100 - assert parsed.map_ext_info.map_upload_date == 200 - assert parsed.map_ext_info.boundary_info is not None - assert parsed.map_ext_info.boundary_info.v_max_y == 40 - assert parsed.map_head is not None - assert parsed.map_head.map_head_id == 7 - assert parsed.map_head.size_x == 2 - assert parsed.map_head.size_y == 2 - assert parsed.map_head.resolution == pytest.approx(0.05) - assert parsed.map_data == bytes([0, 127, 128, 128]) - assert parsed.room_data_info[0].room_id == 42 - assert parsed.room_data_info[0].room_name == "Kitchen" - assert parsed.room_data_info[0].room_name_post is not None - assert parsed.room_data_info[0].room_name_post.x == pytest.approx(11.25) - assert parsed.room_data_info[0].room_name_post.y == pytest.approx(22.5) - assert parsed.room_data_info[0].color_id == 7 - assert parsed.room_data_info[0].global_seq == 9 - assert parsed.room_data_info[1].room_id == 99 - assert parsed.room_data_info[1].room_name is None + size_x, size_y, grid, room_names = _parse_scmap_payload(payload) + + assert size_x == 2 + assert size_y == 2 + assert grid == bytes([0, 127, 128, 128]) + assert room_names == { + 42: "Kitchen", + 99: "Room 99", + } def test_b01_map_parser_rejects_invalid_payload() -> None: From 2d73fd158c0dd43288dc5ae491e164ba0bcbecf0 Mon Sep 17 00:00:00 2001 From: arduano Date: Sun, 15 Mar 2026 16:28:51 +1100 Subject: [PATCH 04/14] refactor: restore declarative q7 scmap fields --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 + roborock/map/b01_map_parser.py | 466 +++++++++++++++++++++---------- tests/map/test_b01_map_parser.py | 99 +++++-- 4 files changed, 400 insertions(+), 169 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 755f4f90..129fe835 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: hooks: - id: mypy exclude: cli.py - additional_dependencies: [ "types-paho-mqtt" ] + additional_dependencies: [ "types-paho-mqtt", "types-protobuf" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index c7c8d17f..bcf4c908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pycryptodomex~=3.18 ; sys_platform == 'darwin'", "paho-mqtt>=1.6.1,<3.0.0", "construct>=2.10.57,<3", + "protobuf>=5,<7", "vacuum-map-parser-roborock", "pyrate-limiter>=4.0.0,<5", "aiomqtt>=2.5.0,<3", @@ -55,6 +56,7 @@ dev = [ "pyyaml>=6.0.3", "pyshark>=0.6", "pytest-cov>=7.0.0", + "types-protobuf", ] [tool.hatch.build.targets.sdist] diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 2e29b1e9..5d6adf49 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -1,14 +1,13 @@ """Module for parsing B01/Q7 map content. -Observed Q7 `MAP_RESPONSE` payloads follow the mobile app's decode pipeline: +Observed Q7 `MAP_RESPONSE` payloads follow this decode pipeline: - base64-encoded ASCII - AES-ECB encrypted with the derived map key - PKCS7 padded - ASCII hex for a zlib-compressed SCMap payload -The inner SCMap blob uses protobuf wire encoding. We keep the runtime parser -minimal and declarative: only the fields needed to render the occupancy image -and expose room names are described here. +The inner SCMap blob is parsed with the official protobuf runtime using a small +runtime descriptor for the message fields this parser needs. """ from __future__ import annotations @@ -18,11 +17,15 @@ import hashlib import io import zlib -from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad +from google.protobuf import descriptor_pool +from google.protobuf.descriptor_pb2 import DescriptorProto, FieldDescriptorProto, FileDescriptorProto +from google.protobuf.message import DecodeError, Message +from google.protobuf.message_factory import GetMessageClass from PIL import Image from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.map_data import ImageData, MapData @@ -33,20 +36,71 @@ _B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") _MAP_FILE_FORMAT = "PNG" -_WIRE_VARINT = 0 -_WIRE_FIXED64 = 1 -_WIRE_LEN = 2 -_WIRE_FIXED32 = 5 +_PROTO_PACKAGE = "b01.scmap" @dataclass(frozen=True) -class _ProtoField: - """Declarative description of a protobuf-wire field we care about.""" +class _ScPoint: + x: float | None = None + y: float | None = None - name: str - wire_type: int - repeated: bool = False - parser: Callable[[object], object] | None = None + +@dataclass(frozen=True) +class _ScMapBoundaryInfo: + map_md5: str | None = None + v_min_x: int | None = None + v_max_x: int | None = None + v_min_y: int | None = None + v_max_y: int | None = None + + +@dataclass(frozen=True) +class _ScMapExtInfo: + task_begin_date: int | None = None + map_upload_date: int | None = None + map_valid: int | None = None + radian: int | None = None + force: int | None = None + clean_path: int | None = None + boundary_info: _ScMapBoundaryInfo | None = None + map_version: int | None = None + map_value_type: int | None = None + + +@dataclass(frozen=True) +class _ScMapHead: + map_head_id: int | None = None + size_x: int | None = None + size_y: int | None = None + min_x: float | None = None + min_y: float | None = None + max_x: float | None = None + max_y: float | None = None + resolution: float | None = None + + +@dataclass(frozen=True) +class _ScRoomData: + room_id: int | None = None + room_name: str | None = None + room_type_id: int | None = None + material_id: int | None = None + clean_state: int | None = None + room_clean: int | None = None + room_clean_index: int | None = None + room_name_post: _ScPoint | None = None + color_id: int | None = None + floor_direction: int | None = None + global_seq: int | None = None + + +@dataclass(frozen=True) +class _ScMapPayload: + map_type: int | None = None + map_ext_info: _ScMapExtInfo | None = None + map_head: _ScMapHead | None = None + map_data: bytes | None = None + room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple) @dataclass @@ -66,7 +120,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None: def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData: """Parse a raw MAP_RESPONSE payload and return a PNG + MapData.""" inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model) - size_x, size_y, grid, room_names = _parse_scmap_payload(inflated) + scmap = _parse_scmap_payload(inflated) + size_x, size_y, grid = _extract_grid(scmap) + room_names = _extract_room_names(scmap.room_data_info) image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) @@ -132,50 +188,151 @@ def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> b raise RoborockException("Failed to decode B01 map payload") from err -def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: - value = 0 - shift = 0 - while True: - if idx >= len(buf): - raise RoborockException("Truncated varint in B01 map payload") - byte = buf[idx] - idx += 1 - value |= (byte & 0x7F) << shift - if not byte & 0x80: - return value, idx - shift += 7 - if shift > 63: - raise RoborockException("Invalid varint in B01 map payload") - - -def _read_fixed32(buf: bytes, idx: int, *, context: str) -> tuple[bytes, int]: - end = idx + 4 - if end > len(buf): - raise RoborockException(f"Truncated fixed32 in {context}") - return buf[idx:end], end - - -def _read_fixed64(buf: bytes, idx: int, *, context: str) -> tuple[bytes, int]: - end = idx + 8 - if end > len(buf): - raise RoborockException(f"Truncated fixed64 in {context}") - return buf[idx:end], end - - -def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: - length, idx = _read_varint(buf, idx) - end = idx + length - if end > len(buf): - raise RoborockException("Invalid length-delimited field in B01 map payload") - return buf[idx:end], end - - -def _decode_uint32(value: int) -> int: - return int(value) - - -def _decode_utf8(value: bytes) -> str: - return value.decode("utf-8", errors="replace") +def _message_descriptor(name: str, fields: list[dict[str, object]]) -> DescriptorProto: + descriptor = DescriptorProto(name=name) + for field_def in fields: + descriptor.field.add( + name=field_def["name"], + number=field_def["number"], + label=field_def.get("label", FieldDescriptorProto.LABEL_OPTIONAL), + type=field_def["type"], + type_name=field_def.get("type_name"), + ) + return descriptor + + +_FILE_DESCRIPTOR = FileDescriptorProto(name="b01_scmap.proto", package=_PROTO_PACKAGE, syntax="proto2") +_FILE_DESCRIPTOR.message_type.extend( + [ + _message_descriptor( + "DevicePointInfo", + [ + {"name": "x", "number": 1, "type": FieldDescriptorProto.TYPE_FLOAT}, + {"name": "y", "number": 2, "type": FieldDescriptorProto.TYPE_FLOAT}, + ], + ), + _message_descriptor( + "MapBoundaryInfo", + [ + {"name": "mapMd5", "number": 1, "type": FieldDescriptorProto.TYPE_STRING}, + {"name": "vMinX", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "vMaxX", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "vMinY", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "vMaxY", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, + ], + ), + _message_descriptor( + "MapExtInfo", + [ + {"name": "taskBeginDate", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "mapUploadDate", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "mapValid", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "radian", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "force", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "cleanPath", "number": 6, "type": FieldDescriptorProto.TYPE_UINT32}, + { + "name": "boudaryInfo", + "number": 7, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.MapBoundaryInfo", + }, + {"name": "mapVersion", "number": 8, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "mapValueType", "number": 9, "type": FieldDescriptorProto.TYPE_UINT32}, + ], + ), + _message_descriptor( + "MapHeadInfo", + [ + {"name": "mapHeadId", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "sizeX", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "sizeY", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "minX", "number": 4, "type": FieldDescriptorProto.TYPE_FLOAT}, + {"name": "minY", "number": 5, "type": FieldDescriptorProto.TYPE_FLOAT}, + {"name": "maxX", "number": 6, "type": FieldDescriptorProto.TYPE_FLOAT}, + {"name": "maxY", "number": 7, "type": FieldDescriptorProto.TYPE_FLOAT}, + {"name": "resolution", "number": 8, "type": FieldDescriptorProto.TYPE_FLOAT}, + ], + ), + _message_descriptor( + "MapDataInfo", + [{"name": "mapData", "number": 1, "type": FieldDescriptorProto.TYPE_BYTES}], + ), + _message_descriptor( + "RoomDataInfo", + [ + {"name": "roomId", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "roomName", "number": 2, "type": FieldDescriptorProto.TYPE_STRING}, + {"name": "roomTypeId", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "meterialId", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "cleanState", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "roomClean", "number": 6, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "roomCleanIndex", "number": 7, "type": FieldDescriptorProto.TYPE_UINT32}, + { + "name": "roomNamePost", + "number": 8, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.DevicePointInfo", + }, + {"name": "colorId", "number": 10, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "floor_direction", "number": 11, "type": FieldDescriptorProto.TYPE_UINT32}, + {"name": "global_seq", "number": 12, "type": FieldDescriptorProto.TYPE_UINT32}, + ], + ), + _message_descriptor( + "RobotMap", + [ + {"name": "mapType", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, + { + "name": "mapExtInfo", + "number": 2, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.MapExtInfo", + }, + { + "name": "mapHead", + "number": 3, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.MapHeadInfo", + }, + { + "name": "mapData", + "number": 4, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.MapDataInfo", + }, + { + "name": "roomDataInfo", + "number": 12, + "label": FieldDescriptorProto.LABEL_REPEATED, + "type": FieldDescriptorProto.TYPE_MESSAGE, + "type_name": f".{_PROTO_PACKAGE}.RoomDataInfo", + }, + ], + ), + ] +) + +_SC_MAP_FILE_DESCRIPTOR = descriptor_pool.Default().AddSerializedFile(_FILE_DESCRIPTOR.SerializeToString()) +_DEVICE_POINT_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["DevicePointInfo"]) +_MAP_BOUNDARY_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapBoundaryInfo"]) +_MAP_EXT_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapExtInfo"]) +_MAP_HEAD_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapHeadInfo"]) +_MAP_DATA_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapDataInfo"]) +_ROOM_DATA_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["RoomDataInfo"]) +_ROBOT_MAP = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["RobotMap"]) + + +def _has_field(message: Any, field_name: str) -> bool: + return message.HasField(field_name) + + +def _parse_proto(blob: bytes, message_class: type[Message], *, context: str) -> Any: + message = message_class() + try: + message.ParseFromString(blob) + except DecodeError as err: + raise RoborockException(f"Failed to parse {context}") from err + return message def _decode_map_data_bytes(value: bytes) -> bytes: @@ -185,96 +342,121 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_proto_message(blob: bytes, schema: dict[int, _ProtoField], *, context: str) -> dict[str, object]: - parsed: dict[str, object] = {} - idx = 0 - while idx < len(blob): - key, idx = _read_varint(blob, idx) - wire = key & 0x07 - field_no = key >> 3 - - if wire == _WIRE_VARINT: - raw_value, idx = _read_varint(blob, idx) - elif wire == _WIRE_FIXED64: - raw_value, idx = _read_fixed64(blob, idx, context=context) - elif wire == _WIRE_LEN: - raw_value, idx = _read_len_delimited(blob, idx) - elif wire == _WIRE_FIXED32: - raw_value, idx = _read_fixed32(blob, idx, context=context) - else: - raise RoborockException(f"Unsupported wire type {wire} in {context}") - - if (field_def := schema.get(field_no)) is None: - continue - if wire != field_def.wire_type: - raise RoborockException(f"Unexpected wire type {wire} for field {field_no} in {context}") - - value = field_def.parser(raw_value) if field_def.parser is not None else raw_value - if field_def.repeated: - parsed.setdefault(field_def.name, []).append(value) - else: - parsed[field_def.name] = value - - return parsed - - -def _parse_map_head_info(blob: bytes) -> tuple[int | None, int | None]: - parsed = _parse_proto_message(blob, _MAP_HEAD_INFO_SCHEMA, context="B01 MapHeadInfo") - return parsed.get("size_x"), parsed.get("size_y") - - -def _parse_map_data_info(blob: bytes) -> bytes: - parsed = _parse_proto_message(blob, _MAP_DATA_INFO_SCHEMA, context="B01 MapDataInfo") - if (map_data := parsed.get("map_data")) is None: +def _parse_sc_point(blob: bytes) -> _ScPoint: + parsed = _parse_proto(blob, _DEVICE_POINT_INFO, context="B01 DevicePointInfo") + return _ScPoint( + x=parsed.x if _has_field(parsed, "x") else None, + y=parsed.y if _has_field(parsed, "y") else None, + ) + + +def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo: + parsed = _parse_proto(blob, _MAP_BOUNDARY_INFO, context="B01 MapBoundaryInfo") + return _ScMapBoundaryInfo( + map_md5=parsed.mapMd5 if _has_field(parsed, "mapMd5") else None, + v_min_x=parsed.vMinX if _has_field(parsed, "vMinX") else None, + v_max_x=parsed.vMaxX if _has_field(parsed, "vMaxX") else None, + v_min_y=parsed.vMinY if _has_field(parsed, "vMinY") else None, + v_max_y=parsed.vMaxY if _has_field(parsed, "vMaxY") else None, + ) + + +def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo: + parsed = _parse_proto(blob, _MAP_EXT_INFO, context="B01 MapExtInfo") + return _ScMapExtInfo( + task_begin_date=parsed.taskBeginDate if _has_field(parsed, "taskBeginDate") else None, + map_upload_date=parsed.mapUploadDate if _has_field(parsed, "mapUploadDate") else None, + map_valid=parsed.mapValid if _has_field(parsed, "mapValid") else None, + radian=parsed.radian if _has_field(parsed, "radian") else None, + force=parsed.force if _has_field(parsed, "force") else None, + clean_path=parsed.cleanPath if _has_field(parsed, "cleanPath") else None, + boundary_info=( + _parse_sc_map_boundary_info(parsed.boudaryInfo.SerializeToString()) + if _has_field(parsed, "boudaryInfo") + else None + ), + map_version=parsed.mapVersion if _has_field(parsed, "mapVersion") else None, + map_value_type=parsed.mapValueType if _has_field(parsed, "mapValueType") else None, + ) + + +def _parse_sc_map_head(blob: bytes) -> _ScMapHead: + parsed = _parse_proto(blob, _MAP_HEAD_INFO, context="B01 MapHeadInfo") + return _ScMapHead( + map_head_id=parsed.mapHeadId if _has_field(parsed, "mapHeadId") else None, + size_x=parsed.sizeX if _has_field(parsed, "sizeX") else None, + size_y=parsed.sizeY if _has_field(parsed, "sizeY") else None, + min_x=parsed.minX if _has_field(parsed, "minX") else None, + min_y=parsed.minY if _has_field(parsed, "minY") else None, + max_x=parsed.maxX if _has_field(parsed, "maxX") else None, + max_y=parsed.maxY if _has_field(parsed, "maxY") else None, + resolution=parsed.resolution if _has_field(parsed, "resolution") else None, + ) + + +def _parse_sc_map_data_info(blob: bytes) -> bytes: + parsed = _parse_proto(blob, _MAP_DATA_INFO, context="B01 MapDataInfo") + if not _has_field(parsed, "mapData"): raise RoborockException("B01 map payload missing mapData") - return map_data - - -def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: - parsed = _parse_proto_message(blob, _ROOM_DATA_INFO_SCHEMA, context="B01 RoomDataInfo") - return parsed.get("room_id"), parsed.get("room_name") - - -def _parse_scmap_payload(payload: bytes) -> tuple[int, int, bytes, dict[int, str]]: - """Parse inflated SCMap bytes using the reverse-engineered field layout we need.""" - parsed = _parse_proto_message(payload, _ROBOT_MAP_SCHEMA, context="B01 SCMap") + return _decode_map_data_bytes(parsed.mapData) + + +def _parse_sc_room_data(blob: bytes) -> _ScRoomData: + parsed = _parse_proto(blob, _ROOM_DATA_INFO, context="B01 RoomDataInfo") + return _ScRoomData( + room_id=parsed.roomId if _has_field(parsed, "roomId") else None, + room_name=parsed.roomName if _has_field(parsed, "roomName") else None, + room_type_id=parsed.roomTypeId if _has_field(parsed, "roomTypeId") else None, + material_id=parsed.meterialId if _has_field(parsed, "meterialId") else None, + clean_state=parsed.cleanState if _has_field(parsed, "cleanState") else None, + room_clean=parsed.roomClean if _has_field(parsed, "roomClean") else None, + room_clean_index=parsed.roomCleanIndex if _has_field(parsed, "roomCleanIndex") else None, + room_name_post=( + _parse_sc_point(parsed.roomNamePost.SerializeToString()) if _has_field(parsed, "roomNamePost") else None + ), + color_id=parsed.colorId if _has_field(parsed, "colorId") else None, + floor_direction=parsed.floor_direction if _has_field(parsed, "floor_direction") else None, + global_seq=parsed.global_seq if _has_field(parsed, "global_seq") else None, + ) + + +def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: + """Parse inflated SCMap bytes into typed map metadata.""" + parsed = _parse_proto(payload, _ROBOT_MAP, context="B01 SCMap") + return _ScMapPayload( + map_type=parsed.mapType if _has_field(parsed, "mapType") else None, + map_ext_info=( + _parse_sc_map_ext_info(parsed.mapExtInfo.SerializeToString()) if _has_field(parsed, "mapExtInfo") else None + ), + map_head=_parse_sc_map_head(parsed.mapHead.SerializeToString()) if _has_field(parsed, "mapHead") else None, + map_data=_parse_sc_map_data_info(parsed.mapData.SerializeToString()) if _has_field(parsed, "mapData") else None, + room_data_info=tuple(_parse_sc_room_data(room.SerializeToString()) for room in parsed.roomDataInfo), + ) + + +def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]: + if scmap.map_head is None or scmap.map_data is None: + raise RoborockException("Failed to parse B01 map header/grid") - size_x, size_y = parsed.get("map_head", (None, None)) - grid = parsed.get("map_data") - if not size_x or not size_y or not grid: + size_x = scmap.map_head.size_x or 0 + size_y = scmap.map_head.size_y or 0 + if not size_x or not size_y or not scmap.map_data: raise RoborockException("Failed to parse B01 map header/grid") expected_len = size_x * size_y - if len(grid) < expected_len: + if len(scmap.map_data) < expected_len: raise RoborockException("B01 map data shorter than expected dimensions") - room_names: dict[int, str] = {} - for room_id, room_name in parsed.get("room_data_info", []): - if room_id is not None: - room_names[room_id] = room_name or f"Room {room_id}" + return size_x, size_y, scmap.map_data[:expected_len] - return size_x, size_y, grid[:expected_len], room_names - -_MAP_HEAD_INFO_SCHEMA = { - 2: _ProtoField("size_x", _WIRE_VARINT, parser=_decode_uint32), - 3: _ProtoField("size_y", _WIRE_VARINT, parser=_decode_uint32), -} - -_MAP_DATA_INFO_SCHEMA = { - 1: _ProtoField("map_data", _WIRE_LEN, parser=_decode_map_data_bytes), -} - -_ROOM_DATA_INFO_SCHEMA = { - 1: _ProtoField("room_id", _WIRE_VARINT, parser=_decode_uint32), - 2: _ProtoField("room_name", _WIRE_LEN, parser=_decode_utf8), -} - -_ROBOT_MAP_SCHEMA = { - 3: _ProtoField("map_head", _WIRE_LEN, parser=_parse_map_head_info), - 4: _ProtoField("map_data", _WIRE_LEN, parser=_parse_map_data_info), - 12: _ProtoField("room_data_info", _WIRE_LEN, repeated=True, parser=_parse_room_data_info), -} +def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]: + # Expose room id/name mapping without inventing room geometry/polygons. + room_names: dict[int, str] = {} + for room in rooms: + if room.room_id is not None: + room_names[room.room_id] = room.room_name or f"Room {room.room_id}" + return room_names def _render_occupancy_image(grid: bytes, *, size_x: int, size_y: int, scale: int) -> Image.Image: diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 3642b9a9..8b02ad4f 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -2,6 +2,7 @@ import gzip import hashlib import io +import struct import zlib from pathlib import Path @@ -45,6 +46,10 @@ def _field_len(field_no: int, value: bytes) -> bytes: return _encode_varint((field_no << 3) | 2) + _encode_varint(len(value)) + value +def _field_float32(field_no: int, value: float) -> bytes: + return _encode_varint((field_no << 3) | 5) + struct.pack(" None: serial = "testsn012345" model = "roborock.vacuum.sc05" @@ -81,27 +86,55 @@ def test_b01_map_parser_decodes_and_renders_fixture() -> None: assert img.size == (340 * 4, 300 * 4) -def test_b01_scmap_parser_maps_selected_reverse_engineered_fields() -> None: - room_one = b"".join([ - _field_varint(1, 42), - _field_len(2, b"Kitchen"), - _field_varint(5, 1), - ]) - room_two = b"".join([ - _field_varint(1, 99), - ]) - - map_head = b"".join([ - _field_varint(1, 7), - _field_varint(2, 2), - _field_varint(3, 2), - _field_varint(9, 999), - ]) +def test_b01_scmap_parser_maps_observed_schema_fields() -> None: + room_name_post = _field_float32(1, 11.25) + _field_float32(2, 22.5) + room_one = b"".join( + [ + _field_varint(1, 42), + _field_len(2, b"Kitchen"), + _field_varint(5, 1), + _field_len(8, room_name_post), + _field_varint(10, 7), + _field_varint(12, 9), + ] + ) + room_two = b"".join([_field_varint(1, 99), _field_varint(5, 0)]) + + boundary_info = b"".join( + [ + _field_len(1, b"md5"), + _field_varint(2, 10), + _field_varint(3, 20), + _field_varint(4, 30), + _field_varint(5, 40), + ] + ) + map_ext_info = b"".join( + [ + _field_varint(1, 100), + _field_varint(2, 200), + _field_varint(3, 1), + _field_varint(8, 3), + _field_len(7, boundary_info), + ] + ) + map_head = b"".join( + [ + _field_varint(1, 7), + _field_varint(2, 2), + _field_varint(3, 2), + _field_float32(4, 1.5), + _field_float32(5, 2.5), + _field_float32(6, 3.5), + _field_float32(7, 4.5), + _field_float32(8, 0.05), + ] + ) map_data = _field_len(1, zlib.compress(bytes([0, 127, 128, 128]))) payload = b"".join( [ _field_varint(1, 1), - _field_len(2, b"ignored map ext info"), + _field_len(2, map_ext_info), _field_len(3, map_head), _field_len(4, map_data), _field_len(12, room_one), @@ -109,15 +142,29 @@ def test_b01_scmap_parser_maps_selected_reverse_engineered_fields() -> None: ] ) - size_x, size_y, grid, room_names = _parse_scmap_payload(payload) - - assert size_x == 2 - assert size_y == 2 - assert grid == bytes([0, 127, 128, 128]) - assert room_names == { - 42: "Kitchen", - 99: "Room 99", - } + parsed = _parse_scmap_payload(payload) + + assert parsed.map_type == 1 + assert parsed.map_ext_info is not None + assert parsed.map_ext_info.task_begin_date == 100 + assert parsed.map_ext_info.map_upload_date == 200 + assert parsed.map_ext_info.boundary_info is not None + assert parsed.map_ext_info.boundary_info.v_max_y == 40 + assert parsed.map_head is not None + assert parsed.map_head.map_head_id == 7 + assert parsed.map_head.size_x == 2 + assert parsed.map_head.size_y == 2 + assert parsed.map_head.resolution == pytest.approx(0.05) + assert parsed.map_data == bytes([0, 127, 128, 128]) + assert parsed.room_data_info[0].room_id == 42 + assert parsed.room_data_info[0].room_name == "Kitchen" + assert parsed.room_data_info[0].room_name_post is not None + assert parsed.room_data_info[0].room_name_post.x == pytest.approx(11.25) + assert parsed.room_data_info[0].room_name_post.y == pytest.approx(22.5) + assert parsed.room_data_info[0].color_id == 7 + assert parsed.room_data_info[0].global_seq == 9 + assert parsed.room_data_info[1].room_id == 99 + assert parsed.room_data_info[1].room_name is None def test_b01_map_parser_rejects_invalid_payload() -> None: From bedf3797570927d25eb1be634df52c96edd25044 Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 16 Mar 2026 16:21:46 +1100 Subject: [PATCH 05/14] feat: define checked-in proto for q7 scmap --- pyproject.toml | 1 + roborock/map/b01_map_parser.py | 274 ++++++---------------------- roborock/map/proto/__init__.py | 1 + roborock/map/proto/b01_scmap.proto | 72 ++++++++ roborock/map/proto/b01_scmap_pb2.py | 48 +++++ 5 files changed, 181 insertions(+), 215 deletions(-) create mode 100644 roborock/map/proto/__init__.py create mode 100644 roborock/map/proto/b01_scmap.proto create mode 100644 roborock/map/proto/b01_scmap_pb2.py diff --git a/pyproject.toml b/pyproject.toml index bcf4c908..b9f782e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ line-length = 120 [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] +"roborock/map/proto/*_pb2.py" = ["E501", "I001", "UP009"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 5d6adf49..6bd7c2ca 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -6,8 +6,8 @@ - PKCS7 padded - ASCII hex for a zlib-compressed SCMap payload -The inner SCMap blob is parsed with the official protobuf runtime using a small -runtime descriptor for the message fields this parser needs. +The inner SCMap blob is parsed with protobuf messages generated from +`roborock/map/proto/b01_scmap.proto`. """ from __future__ import annotations @@ -18,25 +18,21 @@ import io import zlib from dataclasses import dataclass, field -from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -from google.protobuf import descriptor_pool -from google.protobuf.descriptor_pb2 import DescriptorProto, FieldDescriptorProto, FileDescriptorProto -from google.protobuf.message import DecodeError, Message -from google.protobuf.message_factory import GetMessageClass +from google.protobuf.message import DecodeError from PIL import Image from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.map_data import ImageData, MapData from roborock.exceptions import RoborockException +from roborock.map.proto import b01_scmap_pb2 from .map_parser import ParsedMapData _B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") _MAP_FILE_FORMAT = "PNG" -_PROTO_PACKAGE = "b01.scmap" @dataclass(frozen=True) @@ -188,151 +184,11 @@ def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> b raise RoborockException("Failed to decode B01 map payload") from err -def _message_descriptor(name: str, fields: list[dict[str, object]]) -> DescriptorProto: - descriptor = DescriptorProto(name=name) - for field_def in fields: - descriptor.field.add( - name=field_def["name"], - number=field_def["number"], - label=field_def.get("label", FieldDescriptorProto.LABEL_OPTIONAL), - type=field_def["type"], - type_name=field_def.get("type_name"), - ) - return descriptor - - -_FILE_DESCRIPTOR = FileDescriptorProto(name="b01_scmap.proto", package=_PROTO_PACKAGE, syntax="proto2") -_FILE_DESCRIPTOR.message_type.extend( - [ - _message_descriptor( - "DevicePointInfo", - [ - {"name": "x", "number": 1, "type": FieldDescriptorProto.TYPE_FLOAT}, - {"name": "y", "number": 2, "type": FieldDescriptorProto.TYPE_FLOAT}, - ], - ), - _message_descriptor( - "MapBoundaryInfo", - [ - {"name": "mapMd5", "number": 1, "type": FieldDescriptorProto.TYPE_STRING}, - {"name": "vMinX", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "vMaxX", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "vMinY", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "vMaxY", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, - ], - ), - _message_descriptor( - "MapExtInfo", - [ - {"name": "taskBeginDate", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "mapUploadDate", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "mapValid", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "radian", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "force", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "cleanPath", "number": 6, "type": FieldDescriptorProto.TYPE_UINT32}, - { - "name": "boudaryInfo", - "number": 7, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.MapBoundaryInfo", - }, - {"name": "mapVersion", "number": 8, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "mapValueType", "number": 9, "type": FieldDescriptorProto.TYPE_UINT32}, - ], - ), - _message_descriptor( - "MapHeadInfo", - [ - {"name": "mapHeadId", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "sizeX", "number": 2, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "sizeY", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "minX", "number": 4, "type": FieldDescriptorProto.TYPE_FLOAT}, - {"name": "minY", "number": 5, "type": FieldDescriptorProto.TYPE_FLOAT}, - {"name": "maxX", "number": 6, "type": FieldDescriptorProto.TYPE_FLOAT}, - {"name": "maxY", "number": 7, "type": FieldDescriptorProto.TYPE_FLOAT}, - {"name": "resolution", "number": 8, "type": FieldDescriptorProto.TYPE_FLOAT}, - ], - ), - _message_descriptor( - "MapDataInfo", - [{"name": "mapData", "number": 1, "type": FieldDescriptorProto.TYPE_BYTES}], - ), - _message_descriptor( - "RoomDataInfo", - [ - {"name": "roomId", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "roomName", "number": 2, "type": FieldDescriptorProto.TYPE_STRING}, - {"name": "roomTypeId", "number": 3, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "meterialId", "number": 4, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "cleanState", "number": 5, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "roomClean", "number": 6, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "roomCleanIndex", "number": 7, "type": FieldDescriptorProto.TYPE_UINT32}, - { - "name": "roomNamePost", - "number": 8, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.DevicePointInfo", - }, - {"name": "colorId", "number": 10, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "floor_direction", "number": 11, "type": FieldDescriptorProto.TYPE_UINT32}, - {"name": "global_seq", "number": 12, "type": FieldDescriptorProto.TYPE_UINT32}, - ], - ), - _message_descriptor( - "RobotMap", - [ - {"name": "mapType", "number": 1, "type": FieldDescriptorProto.TYPE_UINT32}, - { - "name": "mapExtInfo", - "number": 2, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.MapExtInfo", - }, - { - "name": "mapHead", - "number": 3, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.MapHeadInfo", - }, - { - "name": "mapData", - "number": 4, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.MapDataInfo", - }, - { - "name": "roomDataInfo", - "number": 12, - "label": FieldDescriptorProto.LABEL_REPEATED, - "type": FieldDescriptorProto.TYPE_MESSAGE, - "type_name": f".{_PROTO_PACKAGE}.RoomDataInfo", - }, - ], - ), - ] -) - -_SC_MAP_FILE_DESCRIPTOR = descriptor_pool.Default().AddSerializedFile(_FILE_DESCRIPTOR.SerializeToString()) -_DEVICE_POINT_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["DevicePointInfo"]) -_MAP_BOUNDARY_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapBoundaryInfo"]) -_MAP_EXT_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapExtInfo"]) -_MAP_HEAD_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapHeadInfo"]) -_MAP_DATA_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["MapDataInfo"]) -_ROOM_DATA_INFO = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["RoomDataInfo"]) -_ROBOT_MAP = GetMessageClass(_SC_MAP_FILE_DESCRIPTOR.message_types_by_name["RobotMap"]) - - -def _has_field(message: Any, field_name: str) -> bool: - return message.HasField(field_name) - - -def _parse_proto(blob: bytes, message_class: type[Message], *, context: str) -> Any: - message = message_class() +def _parse_proto(blob: bytes, message: object, *, context: str) -> None: try: message.ParseFromString(blob) except DecodeError as err: raise RoborockException(f"Failed to parse {context}") from err - return message def _decode_map_data_bytes(value: bytes) -> bytes: @@ -342,95 +198,83 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_sc_point(blob: bytes) -> _ScPoint: - parsed = _parse_proto(blob, _DEVICE_POINT_INFO, context="B01 DevicePointInfo") +def _parse_sc_point(parsed: b01_scmap_pb2.DevicePointInfo) -> _ScPoint: return _ScPoint( - x=parsed.x if _has_field(parsed, "x") else None, - y=parsed.y if _has_field(parsed, "y") else None, + x=parsed.x if parsed.HasField("x") else None, + y=parsed.y if parsed.HasField("y") else None, ) -def _parse_sc_map_boundary_info(blob: bytes) -> _ScMapBoundaryInfo: - parsed = _parse_proto(blob, _MAP_BOUNDARY_INFO, context="B01 MapBoundaryInfo") +def _parse_sc_map_boundary_info(parsed: b01_scmap_pb2.MapBoundaryInfo) -> _ScMapBoundaryInfo: return _ScMapBoundaryInfo( - map_md5=parsed.mapMd5 if _has_field(parsed, "mapMd5") else None, - v_min_x=parsed.vMinX if _has_field(parsed, "vMinX") else None, - v_max_x=parsed.vMaxX if _has_field(parsed, "vMaxX") else None, - v_min_y=parsed.vMinY if _has_field(parsed, "vMinY") else None, - v_max_y=parsed.vMaxY if _has_field(parsed, "vMaxY") else None, + map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None, + v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None, + v_max_x=parsed.vMaxX if parsed.HasField("vMaxX") else None, + v_min_y=parsed.vMinY if parsed.HasField("vMinY") else None, + v_max_y=parsed.vMaxY if parsed.HasField("vMaxY") else None, ) -def _parse_sc_map_ext_info(blob: bytes) -> _ScMapExtInfo: - parsed = _parse_proto(blob, _MAP_EXT_INFO, context="B01 MapExtInfo") +def _parse_sc_map_ext_info(parsed: b01_scmap_pb2.MapExtInfo) -> _ScMapExtInfo: return _ScMapExtInfo( - task_begin_date=parsed.taskBeginDate if _has_field(parsed, "taskBeginDate") else None, - map_upload_date=parsed.mapUploadDate if _has_field(parsed, "mapUploadDate") else None, - map_valid=parsed.mapValid if _has_field(parsed, "mapValid") else None, - radian=parsed.radian if _has_field(parsed, "radian") else None, - force=parsed.force if _has_field(parsed, "force") else None, - clean_path=parsed.cleanPath if _has_field(parsed, "cleanPath") else None, - boundary_info=( - _parse_sc_map_boundary_info(parsed.boudaryInfo.SerializeToString()) - if _has_field(parsed, "boudaryInfo") - else None - ), - map_version=parsed.mapVersion if _has_field(parsed, "mapVersion") else None, - map_value_type=parsed.mapValueType if _has_field(parsed, "mapValueType") else None, + task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None, + map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None, + map_valid=parsed.mapValid if parsed.HasField("mapValid") else None, + radian=parsed.radian if parsed.HasField("radian") else None, + force=parsed.force if parsed.HasField("force") else None, + clean_path=parsed.cleanPath if parsed.HasField("cleanPath") else None, + boundary_info=_parse_sc_map_boundary_info(parsed.boudaryInfo) if parsed.HasField("boudaryInfo") else None, + map_version=parsed.mapVersion if parsed.HasField("mapVersion") else None, + map_value_type=parsed.mapValueType if parsed.HasField("mapValueType") else None, ) -def _parse_sc_map_head(blob: bytes) -> _ScMapHead: - parsed = _parse_proto(blob, _MAP_HEAD_INFO, context="B01 MapHeadInfo") +def _parse_sc_map_head(parsed: b01_scmap_pb2.MapHeadInfo) -> _ScMapHead: return _ScMapHead( - map_head_id=parsed.mapHeadId if _has_field(parsed, "mapHeadId") else None, - size_x=parsed.sizeX if _has_field(parsed, "sizeX") else None, - size_y=parsed.sizeY if _has_field(parsed, "sizeY") else None, - min_x=parsed.minX if _has_field(parsed, "minX") else None, - min_y=parsed.minY if _has_field(parsed, "minY") else None, - max_x=parsed.maxX if _has_field(parsed, "maxX") else None, - max_y=parsed.maxY if _has_field(parsed, "maxY") else None, - resolution=parsed.resolution if _has_field(parsed, "resolution") else None, + map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None, + size_x=parsed.sizeX if parsed.HasField("sizeX") else None, + size_y=parsed.sizeY if parsed.HasField("sizeY") else None, + min_x=parsed.minX if parsed.HasField("minX") else None, + min_y=parsed.minY if parsed.HasField("minY") else None, + max_x=parsed.maxX if parsed.HasField("maxX") else None, + max_y=parsed.maxY if parsed.HasField("maxY") else None, + resolution=parsed.resolution if parsed.HasField("resolution") else None, ) -def _parse_sc_map_data_info(blob: bytes) -> bytes: - parsed = _parse_proto(blob, _MAP_DATA_INFO, context="B01 MapDataInfo") - if not _has_field(parsed, "mapData"): - raise RoborockException("B01 map payload missing mapData") - return _decode_map_data_bytes(parsed.mapData) - - -def _parse_sc_room_data(blob: bytes) -> _ScRoomData: - parsed = _parse_proto(blob, _ROOM_DATA_INFO, context="B01 RoomDataInfo") +def _parse_sc_room_data(parsed: b01_scmap_pb2.RoomDataInfo) -> _ScRoomData: return _ScRoomData( - room_id=parsed.roomId if _has_field(parsed, "roomId") else None, - room_name=parsed.roomName if _has_field(parsed, "roomName") else None, - room_type_id=parsed.roomTypeId if _has_field(parsed, "roomTypeId") else None, - material_id=parsed.meterialId if _has_field(parsed, "meterialId") else None, - clean_state=parsed.cleanState if _has_field(parsed, "cleanState") else None, - room_clean=parsed.roomClean if _has_field(parsed, "roomClean") else None, - room_clean_index=parsed.roomCleanIndex if _has_field(parsed, "roomCleanIndex") else None, - room_name_post=( - _parse_sc_point(parsed.roomNamePost.SerializeToString()) if _has_field(parsed, "roomNamePost") else None - ), - color_id=parsed.colorId if _has_field(parsed, "colorId") else None, - floor_direction=parsed.floor_direction if _has_field(parsed, "floor_direction") else None, - global_seq=parsed.global_seq if _has_field(parsed, "global_seq") else None, + room_id=parsed.roomId if parsed.HasField("roomId") else None, + room_name=parsed.roomName if parsed.HasField("roomName") else None, + room_type_id=parsed.roomTypeId if parsed.HasField("roomTypeId") else None, + material_id=parsed.meterialId if parsed.HasField("meterialId") else None, + clean_state=parsed.cleanState if parsed.HasField("cleanState") else None, + room_clean=parsed.roomClean if parsed.HasField("roomClean") else None, + room_clean_index=parsed.roomCleanIndex if parsed.HasField("roomCleanIndex") else None, + room_name_post=_parse_sc_point(parsed.roomNamePost) if parsed.HasField("roomNamePost") else None, + color_id=parsed.colorId if parsed.HasField("colorId") else None, + floor_direction=parsed.floor_direction if parsed.HasField("floor_direction") else None, + global_seq=parsed.global_seq if parsed.HasField("global_seq") else None, ) def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: """Parse inflated SCMap bytes into typed map metadata.""" - parsed = _parse_proto(payload, _ROBOT_MAP, context="B01 SCMap") + parsed = b01_scmap_pb2.RobotMap() + _parse_proto(payload, parsed, context="B01 SCMap") + + map_data = None + if parsed.HasField("mapData"): + if not parsed.mapData.HasField("mapData"): + raise RoborockException("B01 map payload missing mapData") + map_data = _decode_map_data_bytes(parsed.mapData.mapData) + return _ScMapPayload( - map_type=parsed.mapType if _has_field(parsed, "mapType") else None, - map_ext_info=( - _parse_sc_map_ext_info(parsed.mapExtInfo.SerializeToString()) if _has_field(parsed, "mapExtInfo") else None - ), - map_head=_parse_sc_map_head(parsed.mapHead.SerializeToString()) if _has_field(parsed, "mapHead") else None, - map_data=_parse_sc_map_data_info(parsed.mapData.SerializeToString()) if _has_field(parsed, "mapData") else None, - room_data_info=tuple(_parse_sc_room_data(room.SerializeToString()) for room in parsed.roomDataInfo), + map_type=parsed.mapType if parsed.HasField("mapType") else None, + map_ext_info=_parse_sc_map_ext_info(parsed.mapExtInfo) if parsed.HasField("mapExtInfo") else None, + map_head=_parse_sc_map_head(parsed.mapHead) if parsed.HasField("mapHead") else None, + map_data=map_data, + room_data_info=tuple(_parse_sc_room_data(room) for room in parsed.roomDataInfo), ) diff --git a/roborock/map/proto/__init__.py b/roborock/map/proto/__init__.py new file mode 100644 index 00000000..81a4f8b1 --- /dev/null +++ b/roborock/map/proto/__init__.py @@ -0,0 +1 @@ +"""Generated protobuf modules for Roborock map payloads.""" diff --git a/roborock/map/proto/b01_scmap.proto b/roborock/map/proto/b01_scmap.proto new file mode 100644 index 00000000..a022ba92 --- /dev/null +++ b/roborock/map/proto/b01_scmap.proto @@ -0,0 +1,72 @@ +// Source of truth for the B01/Q7 SCMap schema. +// +// Regenerate the checked-in Python module after edits with: +// python -m grpc_tools.protoc -I. --python_out=. roborock/map/proto/b01_scmap.proto +// +// The generated file `b01_scmap_pb2.py` is checked in for runtime use and should +// not be edited by hand. +syntax = "proto2"; + +package b01.scmap; + +message DevicePointInfo { + optional float x = 1; + optional float y = 2; +} + +message MapBoundaryInfo { + optional string mapMd5 = 1; + optional uint32 vMinX = 2; + optional uint32 vMaxX = 3; + optional uint32 vMinY = 4; + optional uint32 vMaxY = 5; +} + +message MapExtInfo { + optional uint32 taskBeginDate = 1; + optional uint32 mapUploadDate = 2; + optional uint32 mapValid = 3; + optional uint32 radian = 4; + optional uint32 force = 5; + optional uint32 cleanPath = 6; + optional MapBoundaryInfo boudaryInfo = 7; + optional uint32 mapVersion = 8; + optional uint32 mapValueType = 9; +} + +message MapHeadInfo { + optional uint32 mapHeadId = 1; + optional uint32 sizeX = 2; + optional uint32 sizeY = 3; + optional float minX = 4; + optional float minY = 5; + optional float maxX = 6; + optional float maxY = 7; + optional float resolution = 8; +} + +message MapDataInfo { + optional bytes mapData = 1; +} + +message RoomDataInfo { + optional uint32 roomId = 1; + optional string roomName = 2; + optional uint32 roomTypeId = 3; + optional uint32 meterialId = 4; + optional uint32 cleanState = 5; + optional uint32 roomClean = 6; + optional uint32 roomCleanIndex = 7; + optional DevicePointInfo roomNamePost = 8; + optional uint32 colorId = 10; + optional uint32 floor_direction = 11; + optional uint32 global_seq = 12; +} + +message RobotMap { + optional uint32 mapType = 1; + optional MapExtInfo mapExtInfo = 2; + optional MapHeadInfo mapHead = 3; + optional MapDataInfo mapData = 4; + repeated RoomDataInfo roomDataInfo = 12; +} diff --git a/roborock/map/proto/b01_scmap_pb2.py b/roborock/map/proto/b01_scmap_pb2.py new file mode 100644 index 00000000..66cc0843 --- /dev/null +++ b/roborock/map/proto/b01_scmap_pb2.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: roborock/map/proto/b01_scmap.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'roborock/map/proto/b01_scmap.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"roborock/map/proto/b01_scmap.proto\x12\tb01.scmap\"\'\n\x0f\x44\x65vicePointInfo\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"]\n\x0fMapBoundaryInfo\x12\x0e\n\x06mapMd5\x18\x01 \x01(\t\x12\r\n\x05vMinX\x18\x02 \x01(\r\x12\r\n\x05vMaxX\x18\x03 \x01(\r\x12\r\n\x05vMinY\x18\x04 \x01(\r\x12\r\n\x05vMaxY\x18\x05 \x01(\r\"\xd9\x01\n\nMapExtInfo\x12\x15\n\rtaskBeginDate\x18\x01 \x01(\r\x12\x15\n\rmapUploadDate\x18\x02 \x01(\r\x12\x10\n\x08mapValid\x18\x03 \x01(\r\x12\x0e\n\x06radian\x18\x04 \x01(\r\x12\r\n\x05\x66orce\x18\x05 \x01(\r\x12\x11\n\tcleanPath\x18\x06 \x01(\r\x12/\n\x0b\x62oudaryInfo\x18\x07 \x01(\x0b\x32\x1a.b01.scmap.MapBoundaryInfo\x12\x12\n\nmapVersion\x18\x08 \x01(\r\x12\x14\n\x0cmapValueType\x18\t \x01(\r\"\x8a\x01\n\x0bMapHeadInfo\x12\x11\n\tmapHeadId\x18\x01 \x01(\r\x12\r\n\x05sizeX\x18\x02 \x01(\r\x12\r\n\x05sizeY\x18\x03 \x01(\r\x12\x0c\n\x04minX\x18\x04 \x01(\x02\x12\x0c\n\x04minY\x18\x05 \x01(\x02\x12\x0c\n\x04maxX\x18\x06 \x01(\x02\x12\x0c\n\x04maxY\x18\x07 \x01(\x02\x12\x12\n\nresolution\x18\x08 \x01(\x02\"\x1e\n\x0bMapDataInfo\x12\x0f\n\x07mapData\x18\x01 \x01(\x0c\"\x87\x02\n\x0cRoomDataInfo\x12\x0e\n\x06roomId\x18\x01 \x01(\r\x12\x10\n\x08roomName\x18\x02 \x01(\t\x12\x12\n\nroomTypeId\x18\x03 \x01(\r\x12\x12\n\nmeterialId\x18\x04 \x01(\r\x12\x12\n\ncleanState\x18\x05 \x01(\r\x12\x11\n\troomClean\x18\x06 \x01(\r\x12\x16\n\x0eroomCleanIndex\x18\x07 \x01(\r\x12\x30\n\x0croomNamePost\x18\x08 \x01(\x0b\x32\x1a.b01.scmap.DevicePointInfo\x12\x0f\n\x07\x63olorId\x18\n \x01(\r\x12\x17\n\x0f\x66loor_direction\x18\x0b \x01(\r\x12\x12\n\nglobal_seq\x18\x0c \x01(\r\"\xc7\x01\n\x08RobotMap\x12\x0f\n\x07mapType\x18\x01 \x01(\r\x12)\n\nmapExtInfo\x18\x02 \x01(\x0b\x32\x15.b01.scmap.MapExtInfo\x12\'\n\x07mapHead\x18\x03 \x01(\x0b\x32\x16.b01.scmap.MapHeadInfo\x12\'\n\x07mapData\x18\x04 \x01(\x0b\x32\x16.b01.scmap.MapDataInfo\x12-\n\x0croomDataInfo\x18\x0c \x03(\x0b\x32\x17.b01.scmap.RoomDataInfo') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'roborock.map.proto.b01_scmap_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_DEVICEPOINTINFO']._serialized_start=49 + _globals['_DEVICEPOINTINFO']._serialized_end=88 + _globals['_MAPBOUNDARYINFO']._serialized_start=90 + _globals['_MAPBOUNDARYINFO']._serialized_end=183 + _globals['_MAPEXTINFO']._serialized_start=186 + _globals['_MAPEXTINFO']._serialized_end=403 + _globals['_MAPHEADINFO']._serialized_start=406 + _globals['_MAPHEADINFO']._serialized_end=544 + _globals['_MAPDATAINFO']._serialized_start=546 + _globals['_MAPDATAINFO']._serialized_end=576 + _globals['_ROOMDATAINFO']._serialized_start=579 + _globals['_ROOMDATAINFO']._serialized_end=842 + _globals['_ROBOTMAP']._serialized_start=845 + _globals['_ROBOTMAP']._serialized_end=1044 +# @@protoc_insertion_point(module_scope) From d638f6e82090213f745e066aa99ec25bbdd18e3e Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 16 Mar 2026 16:26:24 +1100 Subject: [PATCH 06/14] fix: pass q7 scmap lint checks --- .pre-commit-config.yaml | 14 ++++++++++++-- roborock/map/b01_map_parser.py | 15 ++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 129fe835..8c50e5f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,10 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks -exclude: "CHANGELOG.md" +exclude: > + (?x)^( + CHANGELOG\.md| + roborock/map/proto/.*_pb2\.py + )$ default_stages: [ pre-commit ] repos: @@ -34,14 +38,20 @@ repos: rev: v0.13.2 hooks: - id: ruff-format + exclude: ^roborock/map/proto/.*_pb2\.py$ - id: ruff + exclude: ^roborock/map/proto/.*_pb2\.py$ args: - --fix - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.1 hooks: - id: mypy - exclude: cli.py + exclude: > + (?x)^( + cli\.py| + roborock/map/proto/.*_pb2\.py + )$ additional_dependencies: [ "types-paho-mqtt", "types-protobuf" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 6bd7c2ca..7df90776 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -18,6 +18,7 @@ import io import zlib from dataclasses import dataclass, field +from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -184,7 +185,7 @@ def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> b raise RoborockException("Failed to decode B01 map payload") from err -def _parse_proto(blob: bytes, message: object, *, context: str) -> None: +def _parse_proto(blob: bytes, message: Any, *, context: str) -> None: try: message.ParseFromString(blob) except DecodeError as err: @@ -198,14 +199,14 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_sc_point(parsed: b01_scmap_pb2.DevicePointInfo) -> _ScPoint: +def _parse_sc_point(parsed: Any) -> _ScPoint: return _ScPoint( x=parsed.x if parsed.HasField("x") else None, y=parsed.y if parsed.HasField("y") else None, ) -def _parse_sc_map_boundary_info(parsed: b01_scmap_pb2.MapBoundaryInfo) -> _ScMapBoundaryInfo: +def _parse_sc_map_boundary_info(parsed: Any) -> _ScMapBoundaryInfo: return _ScMapBoundaryInfo( map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None, v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None, @@ -215,7 +216,7 @@ def _parse_sc_map_boundary_info(parsed: b01_scmap_pb2.MapBoundaryInfo) -> _ScMap ) -def _parse_sc_map_ext_info(parsed: b01_scmap_pb2.MapExtInfo) -> _ScMapExtInfo: +def _parse_sc_map_ext_info(parsed: Any) -> _ScMapExtInfo: return _ScMapExtInfo( task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None, map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None, @@ -229,7 +230,7 @@ def _parse_sc_map_ext_info(parsed: b01_scmap_pb2.MapExtInfo) -> _ScMapExtInfo: ) -def _parse_sc_map_head(parsed: b01_scmap_pb2.MapHeadInfo) -> _ScMapHead: +def _parse_sc_map_head(parsed: Any) -> _ScMapHead: return _ScMapHead( map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None, size_x=parsed.sizeX if parsed.HasField("sizeX") else None, @@ -242,7 +243,7 @@ def _parse_sc_map_head(parsed: b01_scmap_pb2.MapHeadInfo) -> _ScMapHead: ) -def _parse_sc_room_data(parsed: b01_scmap_pb2.RoomDataInfo) -> _ScRoomData: +def _parse_sc_room_data(parsed: Any) -> _ScRoomData: return _ScRoomData( room_id=parsed.roomId if parsed.HasField("roomId") else None, room_name=parsed.roomName if parsed.HasField("roomName") else None, @@ -260,7 +261,7 @@ def _parse_sc_room_data(parsed: b01_scmap_pb2.RoomDataInfo) -> _ScRoomData: def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: """Parse inflated SCMap bytes into typed map metadata.""" - parsed = b01_scmap_pb2.RobotMap() + parsed: Any = getattr(b01_scmap_pb2, "RobotMap")() _parse_proto(payload, parsed, context="B01 SCMap") map_data = None From 34cebbd53f8d937a25632f0edd4d5485d988aecf Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 16 Mar 2026 16:29:29 +1100 Subject: [PATCH 07/14] fix: avoid extra mypy surface from protobuf stubs --- .pre-commit-config.yaml | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c50e5f3..f565538b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: cli\.py| roborock/map/proto/.*_pb2\.py )$ - additional_dependencies: [ "types-paho-mqtt", "types-protobuf" ] + additional_dependencies: [ "types-paho-mqtt" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index b9f782e1..36a3f0cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dev = [ "pyyaml>=6.0.3", "pyshark>=0.6", "pytest-cov>=7.0.0", - "types-protobuf", ] [tool.hatch.build.targets.sdist] From 96c42aba1c430528671f15165c00660f7ebb57e0 Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 16 Mar 2026 16:45:17 +1100 Subject: [PATCH 08/14] fix: scope mypy protobuf ignore to generated module --- .pre-commit-config.yaml | 6 +----- pyproject.toml | 4 ++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f565538b..a6964263 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,11 +47,7 @@ repos: rev: v1.7.1 hooks: - id: mypy - exclude: > - (?x)^( - cli\.py| - roborock/map/proto/.*_pb2\.py - )$ + exclude: cli.py additional_dependencies: [ "types-paho-mqtt" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 diff --git a/pyproject.toml b/pyproject.toml index 36a3f0cc..7f379b50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,10 @@ line-length = 120 "*/__init__.py" = ["F401"] "roborock/map/proto/*_pb2.py" = ["E501", "I001", "UP009"] +[[tool.mypy.overrides]] +module = ["roborock.map.proto.*"] +ignore_errors = true + [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" From 1010ddbc4d8c5ae23d7cb14a7ec2ca67a2bc5e30 Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 16 Mar 2026 17:12:11 +1100 Subject: [PATCH 09/14] fix: add protobuf stubs to mypy hook --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6964263..3bf9dbc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,7 @@ repos: hooks: - id: mypy exclude: cli.py - additional_dependencies: [ "types-paho-mqtt" ] + additional_dependencies: [ "types-paho-mqtt", "types-protobuf" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 hooks: From 741fca6a403ca2055d1973af6b15d5a954beaa21 Mon Sep 17 00:00:00 2001 From: arduano Date: Thu, 19 Mar 2026 14:38:36 +1100 Subject: [PATCH 10/14] refactor(q7): address maintainer review follow-ups --- .pre-commit-config.yaml | 5 +- pyproject.toml | 1 + roborock/devices/traits/b01/q7/__init__.py | 3 + roborock/devices/traits/b01/q7/map_content.py | 9 +- roborock/map/b01_map_parser.py | 86 ++++++++++++++++--- roborock/map/proto/b01_scmap.proto | 6 +- .../devices/traits/b01/q7/test_map_content.py | 38 ++++---- 7 files changed, 104 insertions(+), 44 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3bf9dbc0..3eb9d998 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,10 +38,11 @@ repos: rev: v0.13.2 hooks: - id: ruff-format - exclude: ^roborock/map/proto/.*_pb2\.py$ + args: + - --force-exclude - id: ruff - exclude: ^roborock/map/proto/.*_pb2\.py$ args: + - --force-exclude - --fix - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.1 diff --git a/pyproject.toml b/pyproject.toml index 2df24045..b8c9a708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ major_tags= ["refactor"] lint.ignore = ["F403", "E741"] lint.select=["E", "F", "UP", "I"] line-length = 120 +extend-exclude = ["roborock/map/proto/*_pb2.py"] [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 51d4ef75..f29f287b 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -57,6 +57,9 @@ def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: Hom self._device = device self._product = product + if not device.sn or not product.model: + raise ValueError("B01 Q7 map content requires device serial number and product model metadata") + self.clean_summary = CleanSummaryTrait(channel) self.map = MapTrait(channel) self.map_content = MapContentTrait( diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index caced24e..951bffff 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -55,8 +55,8 @@ def __init__( self, map_trait: MapTrait, *, - serial: str | None, - model: str | None, + serial: str, + model: str, map_parser_config: B01MapParserConfig | None = None, ) -> None: super().__init__() @@ -79,11 +79,6 @@ def parse_map_content(self, response: bytes) -> MapContent: This mirrors the v1 trait behavior so cached map payload bytes can be reparsed without going back to the device. """ - if not self._serial or not self._model: - raise RoborockException( - "B01 map parsing requires device serial number and model metadata, but they were missing" - ) - try: parsed_data = self._map_parser.parse( response, diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 7df90776..1d9592b4 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -18,11 +18,11 @@ import io import zlib from dataclasses import dataclass, field -from typing import Any +from typing import Protocol, cast from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -from google.protobuf.message import DecodeError +from google.protobuf.message import DecodeError, Message from PIL import Image from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.map_data import ImageData, MapData @@ -36,6 +36,72 @@ _MAP_FILE_FORMAT = "PNG" +class _ProtoMessage(Protocol): + def HasField(self, field_name: str) -> bool: ... + + +class _ScPointMessage(_ProtoMessage, Protocol): + x: float + y: float + + +class _ScMapBoundaryInfoMessage(_ProtoMessage, Protocol): + mapMd5: str + vMinX: int + vMaxX: int + vMinY: int + vMaxY: int + + +class _ScMapExtInfoMessage(_ProtoMessage, Protocol): + taskBeginDate: int + mapUploadDate: int + mapValid: int + radian: int + force: int + cleanPath: int + boudaryInfo: _ScMapBoundaryInfoMessage + mapVersion: int + mapValueType: int + + +class _ScMapHeadMessage(_ProtoMessage, Protocol): + mapHeadId: int + sizeX: int + sizeY: int + minX: float + minY: float + maxX: float + maxY: float + resolution: float + + +class _ScRoomDataMessage(_ProtoMessage, Protocol): + roomId: int + roomName: str + roomTypeId: int + meterialId: int + cleanState: int + roomClean: int + roomCleanIndex: int + roomNamePost: _ScPointMessage + colorId: int + floor_direction: int + global_seq: int + + +class _ScMapDataContainerMessage(_ProtoMessage, Protocol): + mapData: bytes + + +class _ScMapMessage(_ProtoMessage, Protocol): + mapType: int + mapExtInfo: _ScMapExtInfoMessage + mapHead: _ScMapHeadMessage + mapData: _ScMapDataContainerMessage + roomDataInfo: list[_ScRoomDataMessage] + + @dataclass(frozen=True) class _ScPoint: x: float | None = None @@ -185,7 +251,7 @@ def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> b raise RoborockException("Failed to decode B01 map payload") from err -def _parse_proto(blob: bytes, message: Any, *, context: str) -> None: +def _parse_proto(blob: bytes, message: Message, *, context: str) -> None: try: message.ParseFromString(blob) except DecodeError as err: @@ -199,14 +265,14 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_sc_point(parsed: Any) -> _ScPoint: +def _parse_sc_point(parsed: _ScPointMessage) -> _ScPoint: return _ScPoint( x=parsed.x if parsed.HasField("x") else None, y=parsed.y if parsed.HasField("y") else None, ) -def _parse_sc_map_boundary_info(parsed: Any) -> _ScMapBoundaryInfo: +def _parse_sc_map_boundary_info(parsed: _ScMapBoundaryInfoMessage) -> _ScMapBoundaryInfo: return _ScMapBoundaryInfo( map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None, v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None, @@ -216,7 +282,7 @@ def _parse_sc_map_boundary_info(parsed: Any) -> _ScMapBoundaryInfo: ) -def _parse_sc_map_ext_info(parsed: Any) -> _ScMapExtInfo: +def _parse_sc_map_ext_info(parsed: _ScMapExtInfoMessage) -> _ScMapExtInfo: return _ScMapExtInfo( task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None, map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None, @@ -230,7 +296,7 @@ def _parse_sc_map_ext_info(parsed: Any) -> _ScMapExtInfo: ) -def _parse_sc_map_head(parsed: Any) -> _ScMapHead: +def _parse_sc_map_head(parsed: _ScMapHeadMessage) -> _ScMapHead: return _ScMapHead( map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None, size_x=parsed.sizeX if parsed.HasField("sizeX") else None, @@ -243,7 +309,7 @@ def _parse_sc_map_head(parsed: Any) -> _ScMapHead: ) -def _parse_sc_room_data(parsed: Any) -> _ScRoomData: +def _parse_sc_room_data(parsed: _ScRoomDataMessage) -> _ScRoomData: return _ScRoomData( room_id=parsed.roomId if parsed.HasField("roomId") else None, room_name=parsed.roomName if parsed.HasField("roomName") else None, @@ -261,8 +327,8 @@ def _parse_sc_room_data(parsed: Any) -> _ScRoomData: def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: """Parse inflated SCMap bytes into typed map metadata.""" - parsed: Any = getattr(b01_scmap_pb2, "RobotMap")() - _parse_proto(payload, parsed, context="B01 SCMap") + parsed = cast(_ScMapMessage, getattr(b01_scmap_pb2, "RobotMap")()) + _parse_proto(payload, cast(Message, parsed), context="B01 SCMap") map_data = None if parsed.HasField("mapData"): diff --git a/roborock/map/proto/b01_scmap.proto b/roborock/map/proto/b01_scmap.proto index a022ba92..e2086304 100644 --- a/roborock/map/proto/b01_scmap.proto +++ b/roborock/map/proto/b01_scmap.proto @@ -1,8 +1,4 @@ -// Source of truth for the B01/Q7 SCMap schema. -// -// Regenerate the checked-in Python module after edits with: -// python -m grpc_tools.protoc -I. --python_out=. roborock/map/proto/b01_scmap.proto -// +// Checked-in B01/Q7 SCMap schema for the generated runtime protobuf module. // The generated file `b01_scmap_pb2.py` is checked in for runtime use and should // not be edited by hand. syntax = "proto2"; diff --git a/tests/devices/traits/b01/q7/test_map_content.py b/tests/devices/traits/b01/q7/test_map_content.py index 32e6e916..c0b62a4f 100644 --- a/tests/devices/traits/b01/q7/test_map_content.py +++ b/tests/devices/traits/b01/q7/test_map_content.py @@ -57,25 +57,23 @@ def test_q7_map_content_preserves_specific_roborock_errors(q7_api: Q7PropertiesA q7_api.map_content.parse_map_content(b"raw") -def test_q7_map_content_missing_metadata_fails_lazily(fake_channel: FakeChannel): +def test_q7_map_content_requires_metadata_at_init(fake_channel: FakeChannel): from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory - q7_api = Q7PropertiesApi( - cast(MqttChannel, fake_channel), - device=HomeDataDevice( - duid="abc123", - name="Q7", - local_key="key123key123key1", - product_id="product-id-q7", - sn=None, - ), - product=HomeDataProduct( - id="product-id-q7", - name="Roborock Q7", - model="roborock.vacuum.sc05", - category=RoborockCategory.VACUUM, - ), - ) - - with pytest.raises(RoborockException, match="requires device serial number and model metadata"): - q7_api.map_content.parse_map_content(b"raw") + with pytest.raises(ValueError, match="requires device serial number and product model metadata"): + Q7PropertiesApi( + cast(MqttChannel, fake_channel), + device=HomeDataDevice( + duid="abc123", + name="Q7", + local_key="key123key123key1", + product_id="product-id-q7", + sn=None, + ), + product=HomeDataProduct( + id="product-id-q7", + name="Roborock Q7", + model="roborock.vacuum.sc05", + category=RoborockCategory.VACUUM, + ), + ) From 2e5ede7244a6e2eb0622435fad7c19d7c5809067 Mon Sep 17 00:00:00 2001 From: arduano Date: Thu, 19 Mar 2026 14:40:37 +1100 Subject: [PATCH 11/14] docs(q7): refresh protobuf regeneration note --- roborock/map/proto/b01_scmap.proto | 2 ++ 1 file changed, 2 insertions(+) diff --git a/roborock/map/proto/b01_scmap.proto b/roborock/map/proto/b01_scmap.proto index e2086304..b3659813 100644 --- a/roborock/map/proto/b01_scmap.proto +++ b/roborock/map/proto/b01_scmap.proto @@ -1,4 +1,6 @@ // Checked-in B01/Q7 SCMap schema for the generated runtime protobuf module. +// Regenerate the checked-in Python module after edits with: +// python -m grpc_tools.protoc -I./roborock/map/proto --python_out=./roborock/map/proto roborock/map/proto/b01_scmap.proto // The generated file `b01_scmap_pb2.py` is checked in for runtime use and should // not be edited by hand. syntax = "proto2"; From 1bfc839fb0ab8db0bc2aae2ec721850492806db3 Mon Sep 17 00:00:00 2001 From: arduano Date: Thu, 19 Mar 2026 14:53:54 +1100 Subject: [PATCH 12/14] fix(ci): stop passing duplicate ruff exclude flag --- .pre-commit-config.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3eb9d998..57e8d890 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,11 +38,8 @@ repos: rev: v0.13.2 hooks: - id: ruff-format - args: - - --force-exclude - id: ruff args: - - --force-exclude - --fix - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.1 From 99a63e60847583cfbe5326af16074cea8343b089 Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 23 Mar 2026 13:53:53 +1100 Subject: [PATCH 13/14] refactor(q7): use generated protobuf message types --- roborock/devices/traits/b01/q7/map_content.py | 2 - roborock/map/b01_map_parser.py | 92 +++---------------- 2 files changed, 15 insertions(+), 79 deletions(-) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index 951bffff..db00119b 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -8,8 +8,6 @@ For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`. """ -from __future__ import annotations - from dataclasses import dataclass from vacuum_map_parser_base.map_data import MapData diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 1d9592b4..5229d27a 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -10,15 +10,12 @@ `roborock/map/proto/b01_scmap.proto`. """ -from __future__ import annotations - import base64 import binascii import hashlib import io import zlib from dataclasses import dataclass, field -from typing import Protocol, cast from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -28,7 +25,14 @@ from vacuum_map_parser_base.map_data import ImageData, MapData from roborock.exceptions import RoborockException -from roborock.map.proto import b01_scmap_pb2 +from roborock.map.proto.b01_scmap_pb2 import ( # type: ignore[attr-defined] + DevicePointInfo, + MapBoundaryInfo, + MapExtInfo, + MapHeadInfo, + RobotMap, + RoomDataInfo, +) from .map_parser import ParsedMapData @@ -36,72 +40,6 @@ _MAP_FILE_FORMAT = "PNG" -class _ProtoMessage(Protocol): - def HasField(self, field_name: str) -> bool: ... - - -class _ScPointMessage(_ProtoMessage, Protocol): - x: float - y: float - - -class _ScMapBoundaryInfoMessage(_ProtoMessage, Protocol): - mapMd5: str - vMinX: int - vMaxX: int - vMinY: int - vMaxY: int - - -class _ScMapExtInfoMessage(_ProtoMessage, Protocol): - taskBeginDate: int - mapUploadDate: int - mapValid: int - radian: int - force: int - cleanPath: int - boudaryInfo: _ScMapBoundaryInfoMessage - mapVersion: int - mapValueType: int - - -class _ScMapHeadMessage(_ProtoMessage, Protocol): - mapHeadId: int - sizeX: int - sizeY: int - minX: float - minY: float - maxX: float - maxY: float - resolution: float - - -class _ScRoomDataMessage(_ProtoMessage, Protocol): - roomId: int - roomName: str - roomTypeId: int - meterialId: int - cleanState: int - roomClean: int - roomCleanIndex: int - roomNamePost: _ScPointMessage - colorId: int - floor_direction: int - global_seq: int - - -class _ScMapDataContainerMessage(_ProtoMessage, Protocol): - mapData: bytes - - -class _ScMapMessage(_ProtoMessage, Protocol): - mapType: int - mapExtInfo: _ScMapExtInfoMessage - mapHead: _ScMapHeadMessage - mapData: _ScMapDataContainerMessage - roomDataInfo: list[_ScRoomDataMessage] - - @dataclass(frozen=True) class _ScPoint: x: float | None = None @@ -265,14 +203,14 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_sc_point(parsed: _ScPointMessage) -> _ScPoint: +def _parse_sc_point(parsed: DevicePointInfo) -> _ScPoint: return _ScPoint( x=parsed.x if parsed.HasField("x") else None, y=parsed.y if parsed.HasField("y") else None, ) -def _parse_sc_map_boundary_info(parsed: _ScMapBoundaryInfoMessage) -> _ScMapBoundaryInfo: +def _parse_sc_map_boundary_info(parsed: MapBoundaryInfo) -> _ScMapBoundaryInfo: return _ScMapBoundaryInfo( map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None, v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None, @@ -282,7 +220,7 @@ def _parse_sc_map_boundary_info(parsed: _ScMapBoundaryInfoMessage) -> _ScMapBoun ) -def _parse_sc_map_ext_info(parsed: _ScMapExtInfoMessage) -> _ScMapExtInfo: +def _parse_sc_map_ext_info(parsed: MapExtInfo) -> _ScMapExtInfo: return _ScMapExtInfo( task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None, map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None, @@ -296,7 +234,7 @@ def _parse_sc_map_ext_info(parsed: _ScMapExtInfoMessage) -> _ScMapExtInfo: ) -def _parse_sc_map_head(parsed: _ScMapHeadMessage) -> _ScMapHead: +def _parse_sc_map_head(parsed: MapHeadInfo) -> _ScMapHead: return _ScMapHead( map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None, size_x=parsed.sizeX if parsed.HasField("sizeX") else None, @@ -309,7 +247,7 @@ def _parse_sc_map_head(parsed: _ScMapHeadMessage) -> _ScMapHead: ) -def _parse_sc_room_data(parsed: _ScRoomDataMessage) -> _ScRoomData: +def _parse_sc_room_data(parsed: RoomDataInfo) -> _ScRoomData: return _ScRoomData( room_id=parsed.roomId if parsed.HasField("roomId") else None, room_name=parsed.roomName if parsed.HasField("roomName") else None, @@ -327,8 +265,8 @@ def _parse_sc_room_data(parsed: _ScRoomDataMessage) -> _ScRoomData: def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: """Parse inflated SCMap bytes into typed map metadata.""" - parsed = cast(_ScMapMessage, getattr(b01_scmap_pb2, "RobotMap")()) - _parse_proto(payload, cast(Message, parsed), context="B01 SCMap") + parsed = RobotMap() + _parse_proto(payload, parsed, context="B01 SCMap") map_data = None if parsed.HasField("mapData"): From f9efa6835505532262a8a07f4e43ad2ac4a7714e Mon Sep 17 00:00:00 2001 From: arduano Date: Tue, 24 Mar 2026 17:43:13 +1100 Subject: [PATCH 14/14] refactor(q7): remove intermediate SCMap mapping layer --- roborock/map/b01_map_parser.py | 184 ++++--------------------------- tests/map/test_b01_map_parser.py | 44 ++++---- 2 files changed, 44 insertions(+), 184 deletions(-) diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index 5229d27a..57b5534a 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -15,7 +15,7 @@ import hashlib import io import zlib -from dataclasses import dataclass, field +from dataclasses import dataclass from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -25,14 +25,7 @@ from vacuum_map_parser_base.map_data import ImageData, MapData from roborock.exceptions import RoborockException -from roborock.map.proto.b01_scmap_pb2 import ( # type: ignore[attr-defined] - DevicePointInfo, - MapBoundaryInfo, - MapExtInfo, - MapHeadInfo, - RobotMap, - RoomDataInfo, -) +from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined] from .map_parser import ParsedMapData @@ -40,70 +33,6 @@ _MAP_FILE_FORMAT = "PNG" -@dataclass(frozen=True) -class _ScPoint: - x: float | None = None - y: float | None = None - - -@dataclass(frozen=True) -class _ScMapBoundaryInfo: - map_md5: str | None = None - v_min_x: int | None = None - v_max_x: int | None = None - v_min_y: int | None = None - v_max_y: int | None = None - - -@dataclass(frozen=True) -class _ScMapExtInfo: - task_begin_date: int | None = None - map_upload_date: int | None = None - map_valid: int | None = None - radian: int | None = None - force: int | None = None - clean_path: int | None = None - boundary_info: _ScMapBoundaryInfo | None = None - map_version: int | None = None - map_value_type: int | None = None - - -@dataclass(frozen=True) -class _ScMapHead: - map_head_id: int | None = None - size_x: int | None = None - size_y: int | None = None - min_x: float | None = None - min_y: float | None = None - max_x: float | None = None - max_y: float | None = None - resolution: float | None = None - - -@dataclass(frozen=True) -class _ScRoomData: - room_id: int | None = None - room_name: str | None = None - room_type_id: int | None = None - material_id: int | None = None - clean_state: int | None = None - room_clean: int | None = None - room_clean_index: int | None = None - room_name_post: _ScPoint | None = None - color_id: int | None = None - floor_direction: int | None = None - global_seq: int | None = None - - -@dataclass(frozen=True) -class _ScMapPayload: - map_type: int | None = None - map_ext_info: _ScMapExtInfo | None = None - map_head: _ScMapHead | None = None - map_data: bytes | None = None - room_data_info: tuple[_ScRoomData, ...] = field(default_factory=tuple) - - @dataclass class B01MapParserConfig: """Configuration for the B01/Q7 map parser.""" @@ -121,9 +50,9 @@ def __init__(self, config: B01MapParserConfig | None = None) -> None: def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData: """Parse a raw MAP_RESPONSE payload and return a PNG + MapData.""" inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model) - scmap = _parse_scmap_payload(inflated) - size_x, size_y, grid = _extract_grid(scmap) - room_names = _extract_room_names(scmap.room_data_info) + parsed = _parse_scmap_payload(inflated) + size_x, size_y, grid = _extract_grid(parsed) + room_names = _extract_room_names(parsed) image = _render_occupancy_image(grid, size_x=size_x, size_y=size_y, scale=self._config.map_scale) @@ -203,108 +132,37 @@ def _decode_map_data_bytes(value: bytes) -> bytes: return value -def _parse_sc_point(parsed: DevicePointInfo) -> _ScPoint: - return _ScPoint( - x=parsed.x if parsed.HasField("x") else None, - y=parsed.y if parsed.HasField("y") else None, - ) - - -def _parse_sc_map_boundary_info(parsed: MapBoundaryInfo) -> _ScMapBoundaryInfo: - return _ScMapBoundaryInfo( - map_md5=parsed.mapMd5 if parsed.HasField("mapMd5") else None, - v_min_x=parsed.vMinX if parsed.HasField("vMinX") else None, - v_max_x=parsed.vMaxX if parsed.HasField("vMaxX") else None, - v_min_y=parsed.vMinY if parsed.HasField("vMinY") else None, - v_max_y=parsed.vMaxY if parsed.HasField("vMaxY") else None, - ) - - -def _parse_sc_map_ext_info(parsed: MapExtInfo) -> _ScMapExtInfo: - return _ScMapExtInfo( - task_begin_date=parsed.taskBeginDate if parsed.HasField("taskBeginDate") else None, - map_upload_date=parsed.mapUploadDate if parsed.HasField("mapUploadDate") else None, - map_valid=parsed.mapValid if parsed.HasField("mapValid") else None, - radian=parsed.radian if parsed.HasField("radian") else None, - force=parsed.force if parsed.HasField("force") else None, - clean_path=parsed.cleanPath if parsed.HasField("cleanPath") else None, - boundary_info=_parse_sc_map_boundary_info(parsed.boudaryInfo) if parsed.HasField("boudaryInfo") else None, - map_version=parsed.mapVersion if parsed.HasField("mapVersion") else None, - map_value_type=parsed.mapValueType if parsed.HasField("mapValueType") else None, - ) - - -def _parse_sc_map_head(parsed: MapHeadInfo) -> _ScMapHead: - return _ScMapHead( - map_head_id=parsed.mapHeadId if parsed.HasField("mapHeadId") else None, - size_x=parsed.sizeX if parsed.HasField("sizeX") else None, - size_y=parsed.sizeY if parsed.HasField("sizeY") else None, - min_x=parsed.minX if parsed.HasField("minX") else None, - min_y=parsed.minY if parsed.HasField("minY") else None, - max_x=parsed.maxX if parsed.HasField("maxX") else None, - max_y=parsed.maxY if parsed.HasField("maxY") else None, - resolution=parsed.resolution if parsed.HasField("resolution") else None, - ) - - -def _parse_sc_room_data(parsed: RoomDataInfo) -> _ScRoomData: - return _ScRoomData( - room_id=parsed.roomId if parsed.HasField("roomId") else None, - room_name=parsed.roomName if parsed.HasField("roomName") else None, - room_type_id=parsed.roomTypeId if parsed.HasField("roomTypeId") else None, - material_id=parsed.meterialId if parsed.HasField("meterialId") else None, - clean_state=parsed.cleanState if parsed.HasField("cleanState") else None, - room_clean=parsed.roomClean if parsed.HasField("roomClean") else None, - room_clean_index=parsed.roomCleanIndex if parsed.HasField("roomCleanIndex") else None, - room_name_post=_parse_sc_point(parsed.roomNamePost) if parsed.HasField("roomNamePost") else None, - color_id=parsed.colorId if parsed.HasField("colorId") else None, - floor_direction=parsed.floor_direction if parsed.HasField("floor_direction") else None, - global_seq=parsed.global_seq if parsed.HasField("global_seq") else None, - ) - - -def _parse_scmap_payload(payload: bytes) -> _ScMapPayload: - """Parse inflated SCMap bytes into typed map metadata.""" +def _parse_scmap_payload(payload: bytes) -> RobotMap: + """Parse inflated SCMap bytes into a generated protobuf message.""" parsed = RobotMap() _parse_proto(payload, parsed, context="B01 SCMap") - - map_data = None - if parsed.HasField("mapData"): - if not parsed.mapData.HasField("mapData"): - raise RoborockException("B01 map payload missing mapData") - map_data = _decode_map_data_bytes(parsed.mapData.mapData) - - return _ScMapPayload( - map_type=parsed.mapType if parsed.HasField("mapType") else None, - map_ext_info=_parse_sc_map_ext_info(parsed.mapExtInfo) if parsed.HasField("mapExtInfo") else None, - map_head=_parse_sc_map_head(parsed.mapHead) if parsed.HasField("mapHead") else None, - map_data=map_data, - room_data_info=tuple(_parse_sc_room_data(room) for room in parsed.roomDataInfo), - ) + return parsed -def _extract_grid(scmap: _ScMapPayload) -> tuple[int, int, bytes]: - if scmap.map_head is None or scmap.map_data is None: +def _extract_grid(parsed: RobotMap) -> tuple[int, int, bytes]: + if not parsed.HasField("mapHead") or not parsed.HasField("mapData"): raise RoborockException("Failed to parse B01 map header/grid") - size_x = scmap.map_head.size_x or 0 - size_y = scmap.map_head.size_y or 0 - if not size_x or not size_y or not scmap.map_data: + size_x = parsed.mapHead.sizeX if parsed.mapHead.HasField("sizeX") else 0 + size_y = parsed.mapHead.sizeY if parsed.mapHead.HasField("sizeY") else 0 + if not size_x or not size_y or not parsed.mapData.HasField("mapData"): raise RoborockException("Failed to parse B01 map header/grid") + map_data = _decode_map_data_bytes(parsed.mapData.mapData) expected_len = size_x * size_y - if len(scmap.map_data) < expected_len: + if len(map_data) < expected_len: raise RoborockException("B01 map data shorter than expected dimensions") - return size_x, size_y, scmap.map_data[:expected_len] + return size_x, size_y, map_data[:expected_len] -def _extract_room_names(rooms: tuple[_ScRoomData, ...]) -> dict[int, str]: +def _extract_room_names(parsed: RobotMap) -> dict[int, str]: # Expose room id/name mapping without inventing room geometry/polygons. room_names: dict[int, str] = {} - for room in rooms: - if room.room_id is not None: - room_names[room.room_id] = room.room_name or f"Room {room.room_id}" + for room in parsed.roomDataInfo: + if room.HasField("roomId"): + room_id = room.roomId + room_names[room_id] = room.roomName if room.HasField("roomName") else f"Room {room_id}" return room_names diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index 8b02ad4f..6c91dba0 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -144,27 +144,29 @@ def test_b01_scmap_parser_maps_observed_schema_fields() -> None: parsed = _parse_scmap_payload(payload) - assert parsed.map_type == 1 - assert parsed.map_ext_info is not None - assert parsed.map_ext_info.task_begin_date == 100 - assert parsed.map_ext_info.map_upload_date == 200 - assert parsed.map_ext_info.boundary_info is not None - assert parsed.map_ext_info.boundary_info.v_max_y == 40 - assert parsed.map_head is not None - assert parsed.map_head.map_head_id == 7 - assert parsed.map_head.size_x == 2 - assert parsed.map_head.size_y == 2 - assert parsed.map_head.resolution == pytest.approx(0.05) - assert parsed.map_data == bytes([0, 127, 128, 128]) - assert parsed.room_data_info[0].room_id == 42 - assert parsed.room_data_info[0].room_name == "Kitchen" - assert parsed.room_data_info[0].room_name_post is not None - assert parsed.room_data_info[0].room_name_post.x == pytest.approx(11.25) - assert parsed.room_data_info[0].room_name_post.y == pytest.approx(22.5) - assert parsed.room_data_info[0].color_id == 7 - assert parsed.room_data_info[0].global_seq == 9 - assert parsed.room_data_info[1].room_id == 99 - assert parsed.room_data_info[1].room_name is None + assert parsed.mapType == 1 + assert parsed.HasField("mapExtInfo") + assert parsed.mapExtInfo.taskBeginDate == 100 + assert parsed.mapExtInfo.mapUploadDate == 200 + assert parsed.mapExtInfo.HasField("boudaryInfo") + assert parsed.mapExtInfo.boudaryInfo.vMaxY == 40 + assert parsed.HasField("mapHead") + assert parsed.mapHead.mapHeadId == 7 + assert parsed.mapHead.sizeX == 2 + assert parsed.mapHead.sizeY == 2 + assert parsed.mapHead.resolution == pytest.approx(0.05) + assert parsed.HasField("mapData") + assert parsed.mapData.HasField("mapData") + assert zlib.decompress(parsed.mapData.mapData) == bytes([0, 127, 128, 128]) + assert parsed.roomDataInfo[0].roomId == 42 + assert parsed.roomDataInfo[0].roomName == "Kitchen" + assert parsed.roomDataInfo[0].HasField("roomNamePost") + assert parsed.roomDataInfo[0].roomNamePost.x == pytest.approx(11.25) + assert parsed.roomDataInfo[0].roomNamePost.y == pytest.approx(22.5) + assert parsed.roomDataInfo[0].colorId == 7 + assert parsed.roomDataInfo[0].global_seq == 9 + assert parsed.roomDataInfo[1].roomId == 99 + assert not parsed.roomDataInfo[1].HasField("roomName") def test_b01_map_parser_rejects_invalid_payload() -> None: