From 487778a2727438fbfe5e9be9ffd8d962d1297b94 Mon Sep 17 00:00:00 2001 From: arduano Date: Sun, 15 Mar 2026 13:11:49 +1100 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 9f9c1b4b9271a6a63a0dbe6afd21216b13a15648 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 16 Mar 2026 07:04:10 -0700 Subject: [PATCH 10/34] feat: Separate trait response handling logic from refresh logic and merge (#783) * feat: Simplify V1 trait handling * Draft to set trait values * refactor: remove trait update listeners and centralize data conversion into dedicated converter classes * chore: Remove unused `typing.Self` import. * refactor: Standardize trait data merging to `merge_trait_values` and remove direct `_parse_response` methods from traits. * docs: clarify internal usage of V1TraitDataConverter and V1TraitMixin attributes. * style: Remove trailing whitespace from a blank line in `common.py` docstring. * refactor: Make V1TraitDataConverter an abstract base class, use a dedicated LedStatusConverter, and fix a typo in Rooms. * chore: Remove duplicate V1TraitDataConverter --- roborock/devices/traits/v1/child_lock.py | 1 + roborock/devices/traits/v1/clean_summary.py | 67 ++++----- roborock/devices/traits/v1/common.py | 127 +++++++++--------- roborock/devices/traits/v1/consumeable.py | 1 + roborock/devices/traits/v1/device_features.py | 38 ++++-- roborock/devices/traits/v1/do_not_disturb.py | 1 + .../devices/traits/v1/dust_collection_mode.py | 1 + roborock/devices/traits/v1/flow_led_status.py | 1 + roborock/devices/traits/v1/home.py | 8 +- roborock/devices/traits/v1/led_status.py | 32 +++-- roborock/devices/traits/v1/map_content.py | 31 +++-- roborock/devices/traits/v1/maps.py | 34 ++--- roborock/devices/traits/v1/network_info.py | 19 ++- roborock/devices/traits/v1/rooms.py | 98 ++++++++------ .../devices/traits/v1/smart_wash_params.py | 1 + roborock/devices/traits/v1/status.py | 10 +- .../traits/v1/valley_electricity_timer.py | 1 + roborock/devices/traits/v1/volume.py | 12 +- roborock/devices/traits/v1/wash_towel_mode.py | 1 + .../devices/__snapshots__/test_v1_device.ambr | 4 +- tests/devices/traits/v1/test_status.py | 2 +- 21 files changed, 270 insertions(+), 220 deletions(-) diff --git a/roborock/devices/traits/v1/child_lock.py b/roborock/devices/traits/v1/child_lock.py index 11306608..2295026e 100644 --- a/roborock/devices/traits/v1/child_lock.py +++ b/roborock/devices/traits/v1/child_lock.py @@ -9,6 +9,7 @@ class ChildLockTrait(ChildLockStatus, common.V1TraitMixin, common.RoborockSwitch """Trait for controlling the child lock of a Roborock device.""" command = RoborockCommand.GET_CHILD_LOCK_STATUS + converter = common.DefaultConverter(ChildLockStatus) requires_feature = "is_set_child_supported" @property diff --git a/roborock/devices/traits/v1/clean_summary.py b/roborock/devices/traits/v1/clean_summary.py index 6a2fdb7c..61fa4033 100644 --- a/roborock/devices/traits/v1/clean_summary.py +++ b/roborock/devices/traits/v1/clean_summary.py @@ -1,7 +1,6 @@ import logging -from typing import Self -from roborock.data import CleanRecord, CleanSummaryWithDetail +from roborock.data import CleanRecord, CleanSummaryWithDetail, RoborockBase from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand from roborock.util import unpack_list @@ -9,48 +8,30 @@ _LOGGER = logging.getLogger(__name__) -class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin): - """Trait for managing the clean summary of Roborock devices.""" - - command = RoborockCommand.GET_CLEAN_SUMMARY - - async def refresh(self) -> None: - """Refresh the clean summary data and last clean record. - - Assumes that the clean summary has already been fetched. - """ - await super().refresh() - if not self.records: - _LOGGER.debug("No clean records available in clean summary.") - self.last_clean_record = None - return - last_record_id = self.records[0] - self.last_clean_record = await self.get_clean_record(last_record_id) +class CleanSummaryConverter(common.V1TraitDataConverter): + """Converter for CleanSummaryWithDetail objects.""" - @classmethod - def _parse_type_response(cls, response: common.V1ResponseData) -> Self: + def convert(self, response: common.V1ResponseData) -> RoborockBase: """Parse the response from the device into a CleanSummary.""" if isinstance(response, dict): - return cls.from_dict(response) + return CleanSummaryWithDetail.from_dict(response) elif isinstance(response, list): clean_time, clean_area, clean_count, records = unpack_list(response, 4) - return cls( + return CleanSummaryWithDetail( clean_time=clean_time, clean_area=clean_area, clean_count=clean_count, records=records, ) elif isinstance(response, int): - return cls(clean_time=response) + return CleanSummaryWithDetail(clean_time=response) raise ValueError(f"Unexpected clean summary format: {response!r}") - async def get_clean_record(self, record_id: int) -> CleanRecord: - """Load a specific clean record by ID.""" - response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id]) - return self._parse_clean_record_response(response) - @classmethod - def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord: +class CleanRecordConverter(common.V1TraitDataConverter): + """Convert server responses to a CleanRecord.""" + + def convert(self, response: common.V1ResponseData) -> CleanRecord: """Parse the response from the device into a CleanRecord.""" if isinstance(response, list) and len(response) == 1: response = response[0] @@ -81,3 +62,29 @@ def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanR begin, end, duration, area = unpack_list(response, 4) return CleanRecord(begin=begin, end=end, duration=duration, area=area) raise ValueError(f"Unexpected clean record format: {response!r}") + + +class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin): + """Trait for managing the clean summary of Roborock devices.""" + + command = RoborockCommand.GET_CLEAN_SUMMARY + converter = CleanSummaryConverter() + clean_record_converter = CleanRecordConverter() + + async def refresh(self) -> None: + """Refresh the clean summary data and last clean record. + + Assumes that the clean summary has already been fetched. + """ + await super().refresh() + if not self.records: + _LOGGER.debug("No clean records available in clean summary.") + self.last_clean_record = None + return + last_record_id = self.records[0] + self.last_clean_record = await self.get_clean_record(last_record_id) + + async def get_clean_record(self, record_id: int) -> CleanRecord: + """Load a specific clean record by ID.""" + response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id]) + return self.clean_record_converter.convert(response) diff --git a/roborock/devices/traits/v1/common.py b/roborock/devices/traits/v1/common.py index 63ae2e20..ce2c899e 100644 --- a/roborock/devices/traits/v1/common.py +++ b/roborock/devices/traits/v1/common.py @@ -5,8 +5,8 @@ import logging from abc import ABC, abstractmethod -from dataclasses import dataclass, fields -from typing import ClassVar, Self +from dataclasses import fields +from typing import ClassVar from roborock.data import RoborockBase from roborock.protocols.v1_protocol import V1RpcChannel @@ -14,10 +14,24 @@ _LOGGER = logging.getLogger(__name__) + V1ResponseData = dict | list | int | str -@dataclass +class V1TraitDataConverter(ABC): + """Converts responses to RoborockBase objects. + + This is an internal class and should not be used directly by consumers. + """ + + @abstractmethod + def convert(self, response: V1ResponseData) -> RoborockBase: + """Convert the values to a dict that can be parsed as a RoborockBase.""" + + def __repr__(self) -> str: + return self.__class__.__name__ + + class V1TraitMixin(ABC): """Base model that supports v1 traits. @@ -42,37 +56,13 @@ class V1TraitMixin(ABC): """ command: ClassVar[RoborockCommand] + """The RoborockCommand used to fetch the trait data from the device (internal only).""" - @classmethod - def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase: - """Parse the response from the device into a a RoborockBase. - - Subclasses should override this method to implement custom parsing - logic as needed. - """ - if not issubclass(cls, RoborockBase): - raise NotImplementedError(f"Trait {cls} does not implement RoborockBase") - # Subclasses can override to implement custom parsing logic - if isinstance(response, list): - response = response[0] - if not isinstance(response, dict): - raise ValueError(f"Unexpected {cls} response format: {response!r}") - return cls.from_dict(response) - - def _parse_response(self, response: V1ResponseData) -> RoborockBase: - """Parse the response from the device into a a RoborockBase. - - This is used by subclasses that want to override the class - behavior with instance-specific data. - """ - return self._parse_type_response(response) - - def __post_init__(self) -> None: - """Post-initialization to set up the RPC channel. + converter: V1TraitDataConverter + """The converter used to parse the response from the device (internal only).""" - This is called automatically after the dataclass is initialized by the - device setup code. - """ + def __init__(self) -> None: + """Initialize the V1TraitMixin.""" self._rpc_channel = None @property @@ -85,32 +75,42 @@ def rpc_channel(self) -> V1RpcChannel: async def refresh(self) -> None: """Refresh the contents of this trait.""" response = await self.rpc_channel.send_command(self.command) - new_data = self._parse_response(response) - if not isinstance(new_data, RoborockBase): - raise ValueError(f"Internal error, unexpected response type: {new_data!r}") - _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data) - self._update_trait_values(new_data) - - def _update_trait_values(self, new_data: RoborockBase) -> None: - """Update the values of this trait from another instance.""" - for field in fields(new_data): - new_value = getattr(new_data, field.name, None) - setattr(self, field.name, new_value) - - -def _get_value_field(clazz: type[V1TraitMixin]) -> str: - """Get the name of the field marked as the main value of the RoborockValueBase.""" - value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)] - if len(value_fields) != 1: - raise ValueError( - f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, " - f" but found: {value_fields}" - ) - return value_fields[0] - - -@dataclass(init=False, kw_only=True) -class RoborockValueBase(V1TraitMixin, RoborockBase): + new_data = self.converter.convert(response) + merge_trait_values(self, new_data) # type: ignore[arg-type] + + +def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool: + """Update the target object with set fields in new_object.""" + updated = False + for field in fields(new_object): + old_value = getattr(target, field.name, None) + new_value = getattr(new_object, field.name, None) + if new_value != old_value: + setattr(target, field.name, new_value) + updated = True + return updated + + +class DefaultConverter(V1TraitDataConverter): + """Converts responses to RoborockBase objects.""" + + def __init__(self, dataclass_type: type[RoborockBase]) -> None: + """Initialize the converter.""" + self._dataclass_type = dataclass_type + + def convert(self, response: V1ResponseData) -> RoborockBase: + """Convert the values to a dict that can be parsed as a RoborockBase. + + Subclasses can override to implement custom parsing logic + """ + if isinstance(response, list): + response = response[0] + if not isinstance(response, dict): + raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}") + return self._dataclass_type.from_dict(response) + + +class SingleValueConverter(DefaultConverter): """Base class for traits that represent a single value. This class is intended to be subclassed by traits that represent a single @@ -119,15 +119,18 @@ class RoborockValueBase(V1TraitMixin, RoborockBase): represents the main value of the trait. """ - @classmethod - def _parse_response(cls, response: V1ResponseData) -> Self: + def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None: + """Initialize the converter.""" + super().__init__(dataclass_type) + self._value_field = value_field + + def convert(self, response: V1ResponseData) -> RoborockBase: """Parse the response from the device into a RoborockValueBase.""" if isinstance(response, list): response = response[0] if not isinstance(response, int): raise ValueError(f"Unexpected response format: {response!r}") - value_field = _get_value_field(cls) - return cls(**{value_field: response}) + return super().convert({self._value_field: response}) class RoborockSwitchBase(ABC): diff --git a/roborock/devices/traits/v1/consumeable.py b/roborock/devices/traits/v1/consumeable.py index 262f47ca..0e59432f 100644 --- a/roborock/devices/traits/v1/consumeable.py +++ b/roborock/devices/traits/v1/consumeable.py @@ -41,6 +41,7 @@ class ConsumableTrait(Consumable, common.V1TraitMixin): """ command = RoborockCommand.GET_CONSUMABLE + converter = common.DefaultConverter(Consumable) async def reset_consumable(self, consumable: ConsumableAttribute) -> None: """Reset a specific consumable attribute on the device.""" diff --git a/roborock/devices/traits/v1/device_features.py b/roborock/devices/traits/v1/device_features.py index bc619b00..ed6e186e 100644 --- a/roborock/devices/traits/v1/device_features.py +++ b/roborock/devices/traits/v1/device_features.py @@ -8,15 +8,37 @@ from roborock.roborock_typing import RoborockCommand +class DeviceTraitsConverter(common.V1TraitDataConverter): + """Converter for APP_GET_INIT_STATUS responses into DeviceFeatures.""" + + def __init__(self, product: HomeDataProduct) -> None: + """Initialize DeviceTraitsConverter.""" + self._product = product + + def convert(self, response: common.V1ResponseData) -> DeviceFeatures: + """Parse an APP_GET_INIT_STATUS response into a DeviceFeatures instance.""" + if not isinstance(response, list): + raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}: {response!r}") + app_status = AppInitStatus.from_dict(response[0]) + return DeviceFeatures.from_feature_flags( + new_feature_info=app_status.new_feature_info, + new_feature_info_str=app_status.new_feature_info_str, + feature_info=app_status.feature_info, + product_nickname=self._product.product_nickname, + ) + + class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin): """Trait for managing supported features on Roborock devices.""" command = RoborockCommand.APP_GET_INIT_STATUS + converter: DeviceTraitsConverter def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called """Initialize DeviceFeaturesTrait.""" + common.V1TraitMixin.__init__(self) + self.converter = DeviceTraitsConverter(product) self._product = product - self._nickname = product.product_nickname self._device_cache = device_cache # All fields of DeviceFeatures are required. Initialize them to False # so we have some known state. @@ -54,21 +76,9 @@ async def refresh(self) -> None: """ cache_data = await self._device_cache.get() if cache_data.device_features is not None: - self._update_trait_values(cache_data.device_features) + common.merge_trait_values(self, cache_data.device_features) return # Save cached device features await super().refresh() cache_data.device_features = self await self._device_cache.set(cache_data) - - def _parse_response(self, response: common.V1ResponseData) -> DeviceFeatures: - """Parse the response from the device into a MapContentTrait instance.""" - if not isinstance(response, list): - raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}") - app_status = AppInitStatus.from_dict(response[0]) - return DeviceFeatures.from_feature_flags( - new_feature_info=app_status.new_feature_info, - new_feature_info_str=app_status.new_feature_info_str, - feature_info=app_status.feature_info, - product_nickname=self._nickname, - ) diff --git a/roborock/devices/traits/v1/do_not_disturb.py b/roborock/devices/traits/v1/do_not_disturb.py index 43de8ea5..4865484f 100644 --- a/roborock/devices/traits/v1/do_not_disturb.py +++ b/roborock/devices/traits/v1/do_not_disturb.py @@ -9,6 +9,7 @@ class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin, common.RoborockSwitchBase """Trait for managing Do Not Disturb (DND) settings on Roborock devices.""" command = RoborockCommand.GET_DND_TIMER + converter = common.DefaultConverter(DnDTimer) @property def is_on(self) -> bool: diff --git a/roborock/devices/traits/v1/dust_collection_mode.py b/roborock/devices/traits/v1/dust_collection_mode.py index 00337fff..afda53c1 100644 --- a/roborock/devices/traits/v1/dust_collection_mode.py +++ b/roborock/devices/traits/v1/dust_collection_mode.py @@ -10,4 +10,5 @@ class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin): """Trait for dust collection mode.""" command = RoborockCommand.GET_DUST_COLLECTION_MODE + converter = common.DefaultConverter(DustCollectionMode) requires_dock_type = is_valid_dock diff --git a/roborock/devices/traits/v1/flow_led_status.py b/roborock/devices/traits/v1/flow_led_status.py index 5a406000..3c1ae15d 100644 --- a/roborock/devices/traits/v1/flow_led_status.py +++ b/roborock/devices/traits/v1/flow_led_status.py @@ -9,6 +9,7 @@ class FlowLedStatusTrait(FlowLedStatus, common.V1TraitMixin, common.RoborockSwit """Trait for controlling the Flow LED status of a Roborock device.""" command = RoborockCommand.GET_FLOW_LED_STATUS + converter = common.DefaultConverter(FlowLedStatus) requires_feature = "is_flow_led_setting_supported" @property diff --git a/roborock/devices/traits/v1/home.py b/roborock/devices/traits/v1/home.py index cbe1fc1e..5034d33c 100644 --- a/roborock/devices/traits/v1/home.py +++ b/roborock/devices/traits/v1/home.py @@ -18,7 +18,6 @@ import asyncio import base64 import logging -from typing import Self from roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockStateCode @@ -41,6 +40,7 @@ class HomeTrait(RoborockBase, common.V1TraitMixin): """Trait that represents a full view of the home layout.""" command = RoborockCommand.GET_MAP_V1 # This is not used + converter = common.DefaultConverter(RoborockBase) # Not used def __init__( self, @@ -93,7 +93,7 @@ async def discover_home(self) -> None: self._discovery_completed = True try: self._home_map_content = { - k: self._map_content.parse_map_content(base64.b64decode(v)) + k: self._map_content.converter.parse_map_content(base64.b64decode(v)) for k, v in (device_cache_data.home_map_content_base64 or {}).items() } except (ValueError, RoborockException) as ex: @@ -233,10 +233,6 @@ def home_map_content(self) -> dict[int, MapContent] | None: """Returns the map content for all cached maps.""" return self._home_map_content - def _parse_response(self, response: common.V1ResponseData) -> Self: - """This trait does not parse responses directly.""" - raise NotImplementedError("HomeTrait does not support direct command responses") - async def _update_home_cache( self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent] ) -> None: diff --git a/roborock/devices/traits/v1/led_status.py b/roborock/devices/traits/v1/led_status.py index 41d15d53..98cd1cbf 100644 --- a/roborock/devices/traits/v1/led_status.py +++ b/roborock/devices/traits/v1/led_status.py @@ -5,10 +5,28 @@ from .common import V1ResponseData +class LedStatusConverter(common.V1TraitDataConverter): + """Converter for LedStatus.""" + + def convert(self, response: V1ResponseData) -> LedStatus: + """Parse the response from the device into a a RoborockBase. + + Subclasses should override this method to implement custom parsing + logic as needed. + """ + if not isinstance(response, list): + raise ValueError(f"Unexpected LedStatus response format: {response!r}") + response = response[0] + if not isinstance(response, int): + raise ValueError(f"Unexpected LedStatus response format: {response!r}") + return LedStatus.from_dict({"status": response}) + + class LedStatusTrait(LedStatus, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for controlling the LED status of a Roborock device.""" command = RoborockCommand.GET_LED_STATUS + converter = LedStatusConverter() requires_feature = "is_led_status_switch_supported" @property @@ -27,17 +45,3 @@ async def disable(self) -> None: await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[0]) # Optimistic update to avoid an extra refresh self.status = 0 - - @classmethod - def _parse_type_response(cls, response: V1ResponseData) -> LedStatus: - """Parse the response from the device into a a RoborockBase. - - Subclasses should override this method to implement custom parsing - logic as needed. - """ - if not isinstance(response, list): - raise ValueError(f"Unexpected {cls} response format: {response!r}") - response = response[0] - if not isinstance(response, int): - raise ValueError(f"Unexpected {cls} response format: {response!r}") - return cls.from_dict({"status": response}) diff --git a/roborock/devices/traits/v1/map_content.py b/roborock/devices/traits/v1/map_content.py index e0cc3e47..35b015d6 100644 --- a/roborock/devices/traits/v1/map_content.py +++ b/roborock/devices/traits/v1/map_content.py @@ -40,19 +40,15 @@ def __repr__(self) -> str: return f"MapContent(image_content={img!r}, map_data={self.map_data!r})" -@common.map_rpc_channel -class MapContentTrait(MapContent, common.V1TraitMixin): - """Trait for fetching the map content.""" - - command = RoborockCommand.GET_MAP_V1 +class MapContentConverter(common.V1TraitDataConverter): + """Convert map response data to MapContent.""" - def __init__(self, map_parser_config: MapParserConfig | None = None) -> None: - """Initialize MapContentTrait.""" - super().__init__() - self._map_parser = MapParser(map_parser_config or MapParserConfig()) + def __init__(self, map_parser: MapParser) -> None: + """Initialize MapContentConverter.""" + self._map_parser = map_parser - def _parse_response(self, response: common.V1ResponseData) -> MapContent: - """Parse the response from the device into a MapContentTrait instance.""" + def convert(self, response: common.V1ResponseData) -> MapContent: + """Parse the response from the device into a MapContent instance.""" if not isinstance(response, bytes): raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}") return self.parse_map_content(response) @@ -81,3 +77,16 @@ def parse_map_content(self, response: bytes) -> MapContent: map_data=parsed_data.map_data, raw_api_response=response, ) + + +@common.map_rpc_channel +class MapContentTrait(MapContent, common.V1TraitMixin): + """Trait for fetching the map content.""" + + command = RoborockCommand.GET_MAP_V1 + converter: MapContentConverter + + def __init__(self, map_parser_config: MapParserConfig | None = None) -> None: + """Initialize MapContentTrait.""" + super().__init__() + self.converter = MapContentConverter(MapParser(map_parser_config or MapParserConfig())) diff --git a/roborock/devices/traits/v1/maps.py b/roborock/devices/traits/v1/maps.py index 0f20b58a..4349bd8d 100644 --- a/roborock/devices/traits/v1/maps.py +++ b/roborock/devices/traits/v1/maps.py @@ -6,7 +6,6 @@ """ import logging -from typing import Self from roborock.data import MultiMapsList, MultiMapsListMapInfo from roborock.devices.traits.v1 import common @@ -17,6 +16,24 @@ _LOGGER = logging.getLogger(__name__) +class MultiMapsListConverter(common.V1TraitDataConverter): + """Converters responses to MultiMapsList.""" + + def convert(self, response: common.V1ResponseData) -> MultiMapsList: + """Parse the response from the device into a MapsTrait instance. + + This overrides the base implementation to handle the specific + response format for the multi maps list. This is needed because we have + a custom constructor that requires the StatusTrait. + """ + if not isinstance(response, list): + raise ValueError(f"Unexpected MapsTrait response format: {response!r}") + response = response[0] + if not isinstance(response, dict): + raise ValueError(f"Unexpected MapsTrait response format: {response!r}") + return MultiMapsList.from_dict(response) + + @common.mqtt_rpc_channel class MapsTrait(MultiMapsList, common.V1TraitMixin): """Trait for managing the maps of Roborock devices. @@ -34,6 +51,7 @@ class MapsTrait(MultiMapsList, common.V1TraitMixin): """ command = RoborockCommand.GET_MULTI_MAPS_LIST + converter = MultiMapsListConverter() def __init__(self, status_trait: StatusTrait) -> None: """Initialize the MapsTrait. @@ -64,17 +82,3 @@ async def set_current_map(self, map_flag: int) -> None: await self.rpc_channel.send_command(RoborockCommand.LOAD_MULTI_MAP, params=[map_flag]) # Refresh our status to make sure it reflects the new map await self._status_trait.refresh() - - def _parse_response(self, response: common.V1ResponseData) -> Self: - """Parse the response from the device into a MapsTrait instance. - - This overrides the base implementation to handle the specific - response format for the multi maps list. This is needed because we have - a custom constructor that requires the StatusTrait. - """ - if not isinstance(response, list): - raise ValueError(f"Unexpected MapsTrait response format: {response!r}") - response = response[0] - if not isinstance(response, dict): - raise ValueError(f"Unexpected MapsTrait response format: {response!r}") - return MultiMapsList.from_dict(response) diff --git a/roborock/devices/traits/v1/network_info.py b/roborock/devices/traits/v1/network_info.py index a88394cd..bba8bc65 100644 --- a/roborock/devices/traits/v1/network_info.py +++ b/roborock/devices/traits/v1/network_info.py @@ -12,6 +12,16 @@ _LOGGER = logging.getLogger(__name__) +class NetworkInfoConverter(common.V1TraitDataConverter): + """Converter for NetworkInfo objects.""" + + def convert(self, response: common.V1ResponseData) -> NetworkInfo: + """Parse the response from the device into a NetworkInfoConverter instance.""" + if not isinstance(response, dict): + raise ValueError(f"Unexpected NetworkInfoTrait response format: {response!r}") + return NetworkInfo.from_dict(response) + + class NetworkInfoTrait(NetworkInfo, common.V1TraitMixin): """Trait for device network information. @@ -23,6 +33,7 @@ class NetworkInfoTrait(NetworkInfo, common.V1TraitMixin): """ command = RoborockCommand.GET_NETWORK_INFO + converter = NetworkInfoConverter() def __init__(self, device_uid: str, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called """Initialize the trait.""" @@ -36,7 +47,7 @@ async def refresh(self) -> None: device_cache_data = await self._device_cache.get() if device_cache_data.network_info: _LOGGER.debug("Using cached network info for device %s", self._device_uid) - self._update_trait_values(device_cache_data.network_info) + common.merge_trait_values(self, device_cache_data.network_info) return # Load from device if not in cache @@ -47,9 +58,3 @@ async def refresh(self) -> None: device_cache_data = await self._device_cache.get() device_cache_data.network_info = self await self._device_cache.set(device_cache_data) - - def _parse_response(self, response: common.V1ResponseData) -> NetworkInfo: - """Parse the response from the device into a NetworkInfo.""" - if not isinstance(response, dict): - raise ValueError(f"Unexpected NetworkInfoTrait response format: {response!r}") - return NetworkInfo.from_dict(response) diff --git a/roborock/devices/traits/v1/rooms.py b/roborock/devices/traits/v1/rooms.py index 8d90c41c..6c6e35c7 100644 --- a/roborock/devices/traits/v1/rooms.py +++ b/roborock/devices/traits/v1/rooms.py @@ -25,11 +25,63 @@ def room_map(self) -> dict[int, NamedRoomMapping]: return {} return {room.segment_id: room for room in self.rooms} + def with_room_names(self, name_map: dict[str, str]) -> "Rooms": + """Create a new Rooms object with updated room names.""" + return Rooms( + rooms=[ + NamedRoomMapping( + segment_id=room.segment_id, + iot_id=room.iot_id, + raw_name=name_map.get(room.iot_id), + ) + for room in self.rooms or [] + ] + ) + + +class RoomsConverter(common.V1TraitDataConverter): + """Converts response objects to Rooms.""" + + def convert(self, response: common.V1ResponseData) -> Rooms: + """Parse the response from the device into a list of NamedRoomMapping.""" + if not isinstance(response, list): + raise ValueError(f"Unexpected RoomsTrait response format: {response!r}") + segment_map = self.extract_segment_map(response) + return Rooms( + rooms=[NamedRoomMapping(segment_id=segment_id, iot_id=iot_id) for segment_id, iot_id in segment_map.items()] + ) + + @staticmethod + def extract_segment_map(response: list) -> dict[int, str]: + """Extract a segment_id -> iot_id mapping from the response. + + The response format can be either a flat list of [segment_id, iot_id] or a + list of lists, where each inner list is a pair of [segment_id, iot_id]. This + function normalizes the response into a dict of segment_id to iot_id. + + NOTE: We currently only partial samples of the room mapping formats, so + improving test coverage with samples from a real device with this format + would be helpful. + """ + if len(response) == 2 and not isinstance(response[0], list): + segment_id, iot_id = response[0], response[1] + return {segment_id: str(iot_id)} + + segment_map: dict[int, str] = {} + for part in response: + if not isinstance(part, list) or len(part) < 2: + _LOGGER.warning("Unexpected room mapping entry format: %r", part) + continue + segment_id, iot_id = part[0], part[1] + segment_map[segment_id] = str(iot_id) + return segment_map + class RoomsTrait(Rooms, common.V1TraitMixin): """Trait for managing the room mappings of Roborock devices.""" command = RoborockCommand.GET_ROOM_MAPPING + converter = RoomsConverter() def __init__(self, home_data: HomeData, web_api: UserWebApiClient) -> None: """Initialize the RoomsTrait.""" @@ -44,7 +96,7 @@ async def refresh(self) -> None: if not isinstance(response, list): raise ValueError(f"Unexpected RoomsTrait response format: {response!r}") - segment_map = _extract_segment_map(response) + segment_map = RoomsConverter.extract_segment_map(response) # Track all iot ids seen before. Refresh the room list when new ids are found. new_iot_ids = set(segment_map.values()) - set(self._home_data.rooms_map.keys()) if new_iot_ids - self._discovered_iot_ids: @@ -54,22 +106,9 @@ async def refresh(self) -> None: self._home_data.rooms = updated_rooms self._discovered_iot_ids.update(new_iot_ids) - new_data = self._parse_rooms(segment_map, self._home_data.rooms_name_map) - self._update_trait_values(new_data) - _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data) - - @staticmethod - def _parse_rooms( - segment_map: dict[int, str], - name_map: dict[str, str], - ) -> Rooms: - """Parse the response from the device into a list of NamedRoomMapping.""" - return Rooms( - rooms=[ - NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, raw_name=name_map.get(iot_id)) - for segment_id, iot_id in segment_map.items() - ] - ) + rooms = self.converter.convert(response) + rooms = rooms.with_room_names(self._home_data.rooms_name_map) + common.merge_trait_values(self, rooms) async def _refresh_rooms(self) -> list[HomeDataRoom]: """Fetch the latest rooms from the web API.""" @@ -78,28 +117,3 @@ async def _refresh_rooms(self) -> list[HomeDataRoom]: except Exception: _LOGGER.debug("Failed to fetch rooms from web API", exc_info=True) return [] - - -def _extract_segment_map(response: list) -> dict[int, str]: - """Extract a segment_id -> iot_id mapping from the response. - - The response format can be either a flat list of [segment_id, iot_id] or a - list of lists, where each inner list is a pair of [segment_id, iot_id]. This - function normalizes the response into a dict of segment_id to iot_id. - - NOTE: We currently only partial samples of the room mapping formats, so - improving test coverage with samples from a real device with this format - would be helpful. - """ - if len(response) == 2 and not isinstance(response[0], list): - segment_id, iot_id = response[0], response[1] - return {segment_id: str(iot_id)} - - segment_map: dict[int, str] = {} - for part in response: - if not isinstance(part, list) or len(part) < 2: - _LOGGER.warning("Unexpected room mapping entry format: %r", part) - continue - segment_id, iot_id = part[0], part[1] - segment_map[segment_id] = str(iot_id) - return segment_map diff --git a/roborock/devices/traits/v1/smart_wash_params.py b/roborock/devices/traits/v1/smart_wash_params.py index cb71c070..9df0a53d 100644 --- a/roborock/devices/traits/v1/smart_wash_params.py +++ b/roborock/devices/traits/v1/smart_wash_params.py @@ -10,4 +10,5 @@ class SmartWashParamsTrait(SmartWashParams, common.V1TraitMixin): """Trait for smart wash parameters.""" command = RoborockCommand.GET_SMART_WASH_PARAMS + converter = common.DefaultConverter(SmartWashParams) requires_dock_type = is_wash_n_fill_dock diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 961d2110..82371c15 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -1,5 +1,4 @@ from functools import cached_property -from typing import Self from roborock import ( CleanRoutes, @@ -43,6 +42,7 @@ class StatusTrait(StatusV2, common.V1TraitMixin): """ command = RoborockCommand.GET_STATUS + converter = common.DefaultConverter(StatusV2) def __init__(self, device_feature_trait: DeviceFeaturesTrait, region: str | None = None) -> None: """Initialize the StatusTrait.""" @@ -91,11 +91,3 @@ def mop_route_name(self) -> str | None: if self.mop_mode is None: return None return self.mop_route_mapping.get(self.mop_mode) - - def _parse_response(self, response: common.V1ResponseData) -> Self: - """Parse the response from the device into a StatusV2-based status object.""" - if isinstance(response, list): - response = response[0] - if isinstance(response, dict): - return StatusV2.from_dict(response) - raise ValueError(f"Unexpected status format: {response!r}") diff --git a/roborock/devices/traits/v1/valley_electricity_timer.py b/roborock/devices/traits/v1/valley_electricity_timer.py index d21a229d..07623d4c 100644 --- a/roborock/devices/traits/v1/valley_electricity_timer.py +++ b/roborock/devices/traits/v1/valley_electricity_timer.py @@ -9,6 +9,7 @@ class ValleyElectricityTimerTrait(ValleyElectricityTimer, common.V1TraitMixin, c """Trait for managing Valley Electricity Timer settings on Roborock devices.""" command = RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER + converter = common.DefaultConverter(ValleyElectricityTimer) requires_feature = "is_supported_valley_electricity" @property diff --git a/roborock/devices/traits/v1/volume.py b/roborock/devices/traits/v1/volume.py index 865a4b3b..0655b821 100644 --- a/roborock/devices/traits/v1/volume.py +++ b/roborock/devices/traits/v1/volume.py @@ -1,18 +1,15 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass +from roborock.data.containers import RoborockBase from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand -# TODO: This is currently the pattern for holding all the commands that hold a -# single value, but it still seems too verbose. Maybe we can generate these -# dynamically or somehow make them less code. - @dataclass -class SoundVolume(common.RoborockValueBase): +class SoundVolume(RoborockBase): """Dataclass for sound volume.""" - volume: int | None = field(default=None, metadata={"roborock_value": True}) + volume: int | None = None """Sound volume level (0-100).""" @@ -20,6 +17,7 @@ class SoundVolumeTrait(SoundVolume, common.V1TraitMixin): """Trait for controlling the sound volume of a Roborock device.""" command = RoborockCommand.GET_SOUND_VOLUME + converter = common.SingleValueConverter(SoundVolume, "volume") async def set_volume(self, volume: int) -> None: """Set the sound volume of the device.""" diff --git a/roborock/devices/traits/v1/wash_towel_mode.py b/roborock/devices/traits/v1/wash_towel_mode.py index 9dcbf63f..de607bc7 100644 --- a/roborock/devices/traits/v1/wash_towel_mode.py +++ b/roborock/devices/traits/v1/wash_towel_mode.py @@ -14,6 +14,7 @@ class WashTowelModeTrait(WashTowelMode, common.V1TraitMixin): """Trait for wash towel mode.""" command = RoborockCommand.GET_WASH_TOWEL_MODE + converter = common.DefaultConverter(WashTowelMode) requires_dock_type = is_wash_n_fill_dock def __init__( diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 2ad1cb7e..8da5d44c 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -1,6 +1,6 @@ # serializer version: 1 # name: test_device_trait_command_parsing[clean_summary] - CleanSummaryTrait(clean_area=24258125000, clean_count=296, clean_time=1442559, command=, dust_collection_count=None, last_clean_record=CleanRecord(area=81122500, avoid_count=None, begin=1738864366, begin_datetime=datetime.datetime(2025, 2, 6, 17, 52, 46, tzinfo=datetime.timezone.utc), clean_type=None, complete=None, duration=4358, dust_collection_status=None, end=1738868964, end_datetime=datetime.datetime(2025, 2, 6, 19, 9, 24, tzinfo=datetime.timezone.utc), error=None, finish_reason=None, map_flag=None, square_meter_area=81.1, start_type=None, wash_count=None), last_clean_t=None, records=[1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038], square_meter_clean_area=24258.1) + CleanSummaryTrait(clean_area=24258125000, clean_count=296, clean_record_converter=CleanRecordConverter, clean_time=1442559, command=, converter=CleanSummaryConverter, dust_collection_count=None, last_clean_record=CleanRecord(area=81122500, avoid_count=None, begin=1738864366, begin_datetime=datetime.datetime(2025, 2, 6, 17, 52, 46, tzinfo=datetime.timezone.utc), clean_type=None, complete=None, duration=4358, dust_collection_status=None, end=1738868964, end_datetime=datetime.datetime(2025, 2, 6, 19, 9, 24, tzinfo=datetime.timezone.utc), error=None, finish_reason=None, map_flag=None, square_meter_area=81.1, start_type=None, wash_count=None), last_clean_t=None, records=[1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038], square_meter_clean_area=24258.1) # --- # name: test_device_trait_command_parsing[clean_summary].1 dict({ @@ -870,7 +870,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) + StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 21cd5844..a308dbca 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -63,7 +63,7 @@ async def test_refresh_status_invalid_format(status_trait: StatusTrait, mock_rpc """Test that invalid response format raises ValueError.""" mock_rpc_channel.send_command.return_value = "invalid" - with pytest.raises(ValueError, match="Unexpected status format"): + with pytest.raises(ValueError, match="Unexpected StatusV2 response format"): await status_trait.refresh() From b0fc1c7fd9229ee61c6ca53e2f4c9364520d9c46 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 16 Mar 2026 14:06:11 +0000 Subject: [PATCH 11/34] 4.23.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 190b49ed..5bf476ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ +## v4.23.0 (2026-03-16) + +### Chores + +- Remove duplicate V1TraitDataConverter + ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +- Remove unused `typing.Self` import. + ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +### Documentation + +- Clarify internal usage of V1TraitDataConverter and V1TraitMixin attributes. + ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +### Features + +- Separate trait response handling logic from refresh logic and merge + ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +- Simplify V1 trait handling ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +### Refactoring + +- Make V1TraitDataConverter an abstract base class, use a dedicated LedStatusConverter, and fix a + typo in Rooms. ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +- Remove trait update listeners and centralize data conversion into dedicated converter classes + ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + +- Standardize trait data merging to `merge_trait_values` and remove direct `_parse_response` methods + from traits. ([#783](https://github.com/Python-roborock/python-roborock/pull/783), + [`9f9c1b4`](https://github.com/Python-roborock/python-roborock/commit/9f9c1b4b9271a6a63a0dbe6afd21216b13a15648)) + + ## v4.22.0 (2026-03-14) ### Features diff --git a/pyproject.toml b/pyproject.toml index c7c8d17f..0a54d226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.22.0" +version = "4.23.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 57b98421..e5e0af7d 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.22.0" +version = "4.23.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 08ca9aa2f9dfb85f41e427d62c3ef189d3a48727 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 16 Mar 2026 07:23:07 -0700 Subject: [PATCH 12/34] feat: Rename and reorder `YXFanLevel` enum members (#787) * chore: Rename and reorder `YXFanLevel` enum members * chore: Fix MAX_PLUS enum value * docs: Add docstring and alias comments to the YXFanLevel enum. * chore: fix lint. --- roborock/data/b01_q10/b01_q10_code_mappings.py | 14 ++++++++++---- tests/devices/traits/b01/q10/test_status.py | 2 +- tests/devices/traits/b01/q10/test_vacuum.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index abadb9de..aef65c5a 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -119,13 +119,19 @@ class B01_Q10_DP(RoborockModeEnum): class YXFanLevel(RoborockModeEnum): + """The fan or vacuum level of the robot. + + Note: The names used here are the v1 names, though the values + have different aliases in the app bundles. + """ + UNKNOWN = "unknown", -1 - CLOSE = "close", 0 + OFF = "off", 0 # close QUIET = "quiet", 1 - NORMAL = "normal", 2 - STRONG = "strong", 3 + BALANCED = "balanced", 2 # normal + TURBO = "turbo", 3 # strong MAX = "max", 4 - SUPER = "super", 8 + MAX_PLUS = "max_plus", 8 # super class YXWaterLevel(RoborockModeEnum): diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index e295e890..8fbbd19a 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -139,7 +139,7 @@ async def test_status_trait_refresh( # Verify trait attributes are updated assert q10_api.status.battery == 100 assert q10_api.status.status == YXDeviceState.CHARGING_STATE - assert q10_api.status.fan_level == YXFanLevel.NORMAL + assert q10_api.status.fan_level == YXFanLevel.BALANCED def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index c8bdb3a4..8e271c7c 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -35,7 +35,7 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: (lambda x: x.return_to_dock(), {"203": {}}), (lambda x: x.empty_dustbin(), {"203": 2}), (lambda x: x.set_clean_mode(YXCleanType.BOTH_WORK), {"137": 1}), - (lambda x: x.set_fan_level(YXFanLevel.NORMAL), {"123": 2}), + (lambda x: x.set_fan_level(YXFanLevel.BALANCED), {"123": 2}), ], ) async def test_vacuum_commands( From 312b7acc3419bf4b5fb040bfd67a65ca07cb51ed Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 16 Mar 2026 14:25:12 +0000 Subject: [PATCH 13/34] 4.24.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf476ba..73bfc409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ +## v4.24.0 (2026-03-16) + +### Chores + +- Fix lint. ([#787](https://github.com/Python-roborock/python-roborock/pull/787), + [`08ca9aa`](https://github.com/Python-roborock/python-roborock/commit/08ca9aa2f9dfb85f41e427d62c3ef189d3a48727)) + +- Fix MAX_PLUS enum value ([#787](https://github.com/Python-roborock/python-roborock/pull/787), + [`08ca9aa`](https://github.com/Python-roborock/python-roborock/commit/08ca9aa2f9dfb85f41e427d62c3ef189d3a48727)) + +- Rename and reorder `YXFanLevel` enum members + ([#787](https://github.com/Python-roborock/python-roborock/pull/787), + [`08ca9aa`](https://github.com/Python-roborock/python-roborock/commit/08ca9aa2f9dfb85f41e427d62c3ef189d3a48727)) + +### Documentation + +- Add docstring and alias comments to the YXFanLevel enum. + ([#787](https://github.com/Python-roborock/python-roborock/pull/787), + [`08ca9aa`](https://github.com/Python-roborock/python-roborock/commit/08ca9aa2f9dfb85f41e427d62c3ef189d3a48727)) + +### Features + +- Rename and reorder `YXFanLevel` enum members + ([#787](https://github.com/Python-roborock/python-roborock/pull/787), + [`08ca9aa`](https://github.com/Python-roborock/python-roborock/commit/08ca9aa2f9dfb85f41e427d62c3ef189d3a48727)) + + ## v4.23.0 (2026-03-16) ### Chores diff --git a/pyproject.toml b/pyproject.toml index 0a54d226..0d97581f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.23.0" +version = "4.24.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index e5e0af7d..1c41e24a 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.23.0" +version = "4.24.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 19d7674cbf98dcf1ba591d1bf71f87b370a90a55 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 16 Mar 2026 07:31:36 -0700 Subject: [PATCH 14/34] feat: Add `from_any_optional` method to `RoborockModeEnum` (#788) * feat: Add `from_any_optional` method to `CodeMapping` for flexible enum resolution with corresponding tests. * refactor: simplify B01_Q10 command parsing by removing a helper function and utilizing `from_any_optional`. * chore: apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Luke Lashley Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- roborock/cli.py | 18 ++---------------- roborock/data/code_mappings.py | 24 ++++++++++++++++++++++++ tests/data/test_code_mappings.py | 24 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index 8e53f5d1..6eb11bf5 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -764,21 +764,6 @@ async def network_info(ctx, device_id: str): await _display_v1_trait(context, device_id, lambda v1: v1.network_info) -def _parse_b01_q10_command(cmd: str) -> B01_Q10_DP: - """Parse B01_Q10 command from either enum name or value.""" - try: - return B01_Q10_DP(int(cmd)) - except ValueError: - try: - return B01_Q10_DP.from_name(cmd) - except ValueError: - try: - return B01_Q10_DP.from_value(cmd) - except ValueError: - pass - raise RoborockException(f"Invalid command {cmd} for B01_Q10 device") - - @click.command() @click.option("--device_id", required=True) @click.option("--cmd", required=True) @@ -795,7 +780,8 @@ async def command(ctx, cmd, device_id, params): if result: click.echo(dump_json(result)) elif device.b01_q10_properties is not None: - cmd_value = _parse_b01_q10_command(cmd) + if cmd_value := B01_Q10_DP.from_any_optional(cmd) is None: + raise RoborockException(f"Invalid command {cmd} for B01_Q10 device") command_trait: Trait = device.b01_q10_properties.command await command_trait.send(cmd_value, json.loads(params) if params is not None else None) click.echo("Command sent successfully; Enable debug logging (-d) to see responses.") diff --git a/roborock/data/code_mappings.py b/roborock/data/code_mappings.py index fc34b387..47cab2b8 100644 --- a/roborock/data/code_mappings.py +++ b/roborock/data/code_mappings.py @@ -100,6 +100,30 @@ def from_name(cls, name: str) -> Self: return member raise ValueError(f"{name} is not a valid name for {cls.__name__}") + @classmethod + def from_any_optional(cls, value: str | int) -> Self | None: + """Resolve a string or int to an enum member. + + Tries to look up by enum name, string value, or integer code + and returns None if no match is found. + """ + # Try enum name lookup (e.g. "SEEK") + try: + return cls.from_name(str(value)) + except ValueError: + pass + # Try DP string value lookup (e.g. "dpSeek") + try: + return cls.from_value(str(value)) + except ValueError: + pass + # Try integer code lookup (e.g. "11") + try: + return cls.from_code(int(value)) + except (ValueError, TypeError): + pass + return None + @classmethod def keys(cls) -> list[str]: """Returns a list of all member values.""" diff --git a/tests/data/test_code_mappings.py b/tests/data/test_code_mappings.py index 5cd48fa2..3c25f105 100644 --- a/tests/data/test_code_mappings.py +++ b/tests/data/test_code_mappings.py @@ -52,6 +52,30 @@ def test_invalid_from_value() -> None: B01_Q10_DP.from_value("invalid_value") +@pytest.mark.parametrize( + "input, expected", + [ + ("START_CLEAN", B01_Q10_DP.START_CLEAN), + ("start_clean", B01_Q10_DP.START_CLEAN), + ("dpStartClean", B01_Q10_DP.START_CLEAN), + (201, B01_Q10_DP.START_CLEAN), + ("PAUSE", B01_Q10_DP.PAUSE), + ("pause", B01_Q10_DP.PAUSE), + ("dpPause", B01_Q10_DP.PAUSE), + (204, B01_Q10_DP.PAUSE), + ("STOP", B01_Q10_DP.STOP), + ("stop", B01_Q10_DP.STOP), + ("dpStop", B01_Q10_DP.STOP), + (206, B01_Q10_DP.STOP), + ("invalid_value", None), + (999999, None), + ], +) +def test_from_any_optional(input: str | int, expected: B01_Q10_DP | None) -> None: + """Test from_any_optional method.""" + assert B01_Q10_DP.from_any_optional(input) == expected + + def test_homedata_product_unknown_category(): """Test that HomeDataProduct can handle unknown categories.""" data = { From a36a95624cd2607bea6c0a5df2799753885733a8 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 16 Mar 2026 14:33:41 +0000 Subject: [PATCH 15/34] 4.25.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bfc409..62a265cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## v4.25.0 (2026-03-16) + +### Chores + +- Apply suggestions from code review + ([#788](https://github.com/Python-roborock/python-roborock/pull/788), + [`19d7674`](https://github.com/Python-roborock/python-roborock/commit/19d7674cbf98dcf1ba591d1bf71f87b370a90a55)) + +### Features + +- Add `from_any_optional` method to `CodeMapping` for flexible enum resolution with corresponding + tests. ([#788](https://github.com/Python-roborock/python-roborock/pull/788), + [`19d7674`](https://github.com/Python-roborock/python-roborock/commit/19d7674cbf98dcf1ba591d1bf71f87b370a90a55)) + +- Add `from_any_optional` method to `RoborockModeEnum` + ([#788](https://github.com/Python-roborock/python-roborock/pull/788), + [`19d7674`](https://github.com/Python-roborock/python-roborock/commit/19d7674cbf98dcf1ba591d1bf71f87b370a90a55)) + +### Refactoring + +- Simplify B01_Q10 command parsing by removing a helper function and utilizing `from_any_optional`. + ([#788](https://github.com/Python-roborock/python-roborock/pull/788), + [`19d7674`](https://github.com/Python-roborock/python-roborock/commit/19d7674cbf98dcf1ba591d1bf71f87b370a90a55)) + + ## v4.24.0 (2026-03-16) ### Chores diff --git a/pyproject.toml b/pyproject.toml index 0d97581f..8cde67ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.24.0" +version = "4.25.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 1c41e24a..007e1cbe 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.24.0" +version = "4.25.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 91efb3b0be3122490c362a8e2ecc1192bb98bee6 Mon Sep 17 00:00:00 2001 From: Radded <66137579+RaddedMC@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:47:28 -0400 Subject: [PATCH 16/34] feat: Q7 Get battery level (#790) * feat: Q7 Get battery level * feat: Q7 Get battery level * Run lint --- roborock/data/b01_q7/b01_q7_containers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index 6ae43099..a75b7185 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -114,7 +114,7 @@ class B01Props(RoborockBase): wind: SCWindMapping | None = None water: WaterLevelMapping | None = None mode: CleanTypeMapping | None = None - quantity: int | None = None + quantity: int | None = None # The Q7 L5 reports its battery level as 'quantity' alarm: int | None = None volume: int | None = None hypa: int | None = None @@ -169,6 +169,13 @@ class B01Props(RoborockBase): recommend: Recommend | None = None add_sweep_status: int | None = None + @property + def battery(self) -> int | None: + """ + Returns device battery level as a percentage. + """ + return self.quantity + @property def main_brush_time_left(self) -> int | None: """ From 1ac59139ad811ee6af006c46d7f6bffb37035033 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 19 Mar 2026 01:49:23 +0000 Subject: [PATCH 17/34] 4.26.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a265cc..583db225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v4.26.0 (2026-03-19) + +### Features + +- Q7 Get battery level ([#790](https://github.com/Python-roborock/python-roborock/pull/790), + [`91efb3b`](https://github.com/Python-roborock/python-roborock/commit/91efb3b0be3122490c362a8e2ecc1192bb98bee6)) + + ## v4.25.0 (2026-03-16) ### Chores diff --git a/pyproject.toml b/pyproject.toml index 8cde67ad..689191f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.25.0" +version = "4.26.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 007e1cbe..80fa4a4f 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.25.0" +version = "4.26.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 741fca6a403ca2055d1973af6b15d5a954beaa21 Mon Sep 17 00:00:00 2001 From: arduano Date: Thu, 19 Mar 2026 14:38:36 +1100 Subject: [PATCH 18/34] 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 19/34] 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 20/34] 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 711f49e9a6e4d7fc964b164c7f23265979aa166b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 20 Mar 2026 07:32:27 -0700 Subject: [PATCH 21/34] chore: increase commit header maximum length to 200 (#789) * chore: increase commit header maximum length to 200 in commitlint configuration. * chore: Disable commitlint rules for header max length and header full stop. --- commitlint.config.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/commitlint.config.mjs b/commitlint.config.mjs index ad7b85d1..b5e01070 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -8,7 +8,11 @@ export default { // Disable the rule that enforces lowercase in subject "subject-case": [0], // 0 = disable, 1 = warn, 2 = error // Disable the rule that enforces a maximum line length in the body - "body-max-line-length": [0, "always"] + "body-max-line-length": [0, "always"], + // Disable header max length for AI-generated commits + "header-max-length": [0], + // Disable the rule that prevents periods at the end of subjects + "header-full-stop": [0] }, }; From 526da01d02f6b52cab3674145273448eb602620e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 19:45:26 +0100 Subject: [PATCH 22/34] fix: add missing DPS fields to Q10Status and fix CLEAN_PROGRESS mapping (#791) * fix: add missing DPS fields to Q10Status and fix CLEAN_PROGRESS mapping * fix: correct comment for fan level in test_status_trait_refresh --- roborock/data/b01_q10/b01_q10_containers.py | 9 ++++++++- tests/devices/traits/b01/q10/test_status.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 562c7062..296d5484 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -96,7 +96,14 @@ class Q10Status(RoborockBase): fan_level: YXFanLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.FAN_LEVEL}) water_level: YXWaterLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.WATER_LEVEL}) clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_COUNT}) + total_clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_AREA}) + total_clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_COUNT}) + total_clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_TIME}) + main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) + side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) + filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE}) + sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE}) clean_mode: YXDeviceWorkMode | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE}) clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE}) back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE}) - cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEANING_PROGRESS}) + cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS}) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8fbbd19a..8a800dfe 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -112,11 +112,14 @@ async def test_status_trait_refresh( assert q10_api.status.battery is None assert q10_api.status.status is None assert q10_api.status.fan_level is None + assert q10_api.status.total_clean_count is None + assert q10_api.status.main_brush_life is None + assert q10_api.status.cleaning_progress is None # Mock the response to refresh # battery (122) = 100 # status (121) = 8 (CHARGING_STATE) - # fun_level (123) = 2 (NORMAL) + # fan_level (123) = 2 (BALANCED) message = build_message(TESTDATA_DP_REQUEST_DPS) # Send a refresh command @@ -140,6 +143,14 @@ async def test_status_trait_refresh( assert q10_api.status.battery == 100 assert q10_api.status.status == YXDeviceState.CHARGING_STATE assert q10_api.status.fan_level == YXFanLevel.BALANCED + assert q10_api.status.total_clean_area == 0 + assert q10_api.status.total_clean_count == 0 + assert q10_api.status.total_clean_time == 0 + assert q10_api.status.main_brush_life == 0 + assert q10_api.status.side_brush_life == 0 + assert q10_api.status.filter_life == 0 + assert q10_api.status.sensor_life == 0 + assert q10_api.status.cleaning_progress == 100 def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: From 042fbed3ce59b42589f0ff99df660dfbb9f6491b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 21 Mar 2026 18:47:26 +0000 Subject: [PATCH 23/34] 4.26.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583db225..e00539fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ +## v4.26.1 (2026-03-21) + +### Bug Fixes + +- Add missing DPS fields to Q10Status and fix CLEAN_PROGRESS mapping + ([#791](https://github.com/Python-roborock/python-roborock/pull/791), + [`526da01`](https://github.com/Python-roborock/python-roborock/commit/526da01d02f6b52cab3674145273448eb602620e)) + +- Correct comment for fan level in test_status_trait_refresh + ([#791](https://github.com/Python-roborock/python-roborock/pull/791), + [`526da01`](https://github.com/Python-roborock/python-roborock/commit/526da01d02f6b52cab3674145273448eb602620e)) + +### Chores + +- Disable commitlint rules for header max length and header full stop. + ([#789](https://github.com/Python-roborock/python-roborock/pull/789), + [`711f49e`](https://github.com/Python-roborock/python-roborock/commit/711f49e9a6e4d7fc964b164c7f23265979aa166b)) + +- Increase commit header maximum length to 200 + ([#789](https://github.com/Python-roborock/python-roborock/pull/789), + [`711f49e`](https://github.com/Python-roborock/python-roborock/commit/711f49e9a6e4d7fc964b164c7f23265979aa166b)) + +- Increase commit header maximum length to 200 in commitlint configuration. + ([#789](https://github.com/Python-roborock/python-roborock/pull/789), + [`711f49e`](https://github.com/Python-roborock/python-roborock/commit/711f49e9a6e4d7fc964b164c7f23265979aa166b)) + + ## v4.26.0 (2026-03-19) ### Features diff --git a/pyproject.toml b/pyproject.toml index 689191f9..245a04cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.26.0" +version = "4.26.1" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 80fa4a4f..7e0eb39e 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.26.0" +version = "4.26.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 66d76fc9b3cd6d6d15f5883bfa8a22c688d9b960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 20:38:07 +0100 Subject: [PATCH 24/34] fix(q10): add missing fault field to Q10Status (#792) --- roborock/data/b01_q10/b01_q10_containers.py | 1 + tests/devices/traits/b01/q10/test_status.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 296d5484..4b3fb3ce 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -107,3 +107,4 @@ class Q10Status(RoborockBase): clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE}) back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE}) cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS}) + fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8a800dfe..192ff0e7 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -115,6 +115,7 @@ async def test_status_trait_refresh( assert q10_api.status.total_clean_count is None assert q10_api.status.main_brush_life is None assert q10_api.status.cleaning_progress is None + assert q10_api.status.fault is None # Mock the response to refresh # battery (122) = 100 @@ -151,6 +152,7 @@ async def test_status_trait_refresh( assert q10_api.status.filter_life == 0 assert q10_api.status.sensor_life == 0 assert q10_api.status.cleaning_progress == 100 + assert q10_api.status.fault == 0 def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: From dca91a059c04ac67acf630e77979590c61c4be74 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 21 Mar 2026 19:40:03 +0000 Subject: [PATCH 25/34] 4.26.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e00539fe..21d562c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v4.26.2 (2026-03-21) + +### Bug Fixes + +- **q10**: Add missing fault field to Q10Status + ([#792](https://github.com/Python-roborock/python-roborock/pull/792), + [`66d76fc`](https://github.com/Python-roborock/python-roborock/commit/66d76fc9b3cd6d6d15f5883bfa8a22c688d9b960)) + + ## v4.26.1 (2026-03-21) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 245a04cc..253f1e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.26.1" +version = "4.26.2" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 7e0eb39e..7716cd37 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.26.1" +version = "4.26.2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From dce00a2499c3976f1cd25239bc4f81d996d51a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 02:47:55 +0100 Subject: [PATCH 26/34] Harmonizes Q10 status names in `python-roborock` to canonical values instead of legacy `*state` strings (#793) * fix(q10): normalize status names to canonical values * fix(q10): normalize YXDeviceState status names to canonical values * fix(q10): refactor test for canonical status names using a dictionary * fix(q10): add tests for Q10 status values and code mappings --- .../data/b01_q10/b01_q10_code_mappings.py | 34 ++++++++--------- .../b01_q10/test_b01_q10_code_mappings.py | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 tests/data/b01_q10/test_b01_q10_code_mappings.py diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index aef65c5a..660a35c5 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -164,24 +164,24 @@ class YXCleanType(RoborockModeEnum): class YXDeviceState(RoborockModeEnum): UNKNOWN = "unknown", -1 - SLEEP_STATE = "sleepstate", 2 - STANDBY_STATE = "standbystate", 3 - CLEANING_STATE = "cleaningstate", 5 - TO_CHARGE_STATE = "tochargestate", 6 - REMOTEING_STATE = "remoteingstate", 7 - CHARGING_STATE = "chargingstate", 8 - PAUSE_STATE = "pausestate", 10 - FAULT_STATE = "faultstate", 12 - UPGRADE_STATE = "upgradestate", 14 + SLEEP_STATE = "sleeping", 2 + STANDBY_STATE = "standby", 3 + CLEANING_STATE = "cleaning", 5 + TO_CHARGE_STATE = "going_to_charge", 6 + REMOTEING_STATE = "remote_control", 7 + CHARGING_STATE = "charging", 8 + PAUSE_STATE = "paused", 10 + FAULT_STATE = "fault", 12 + UPGRADE_STATE = "updating", 14 DUSTING = "dusting", 22 - CREATING_MAP_STATE = "creatingmapstate", 29 - MAP_SAVE_STATE = "mapsavestate", 99 - RE_LOCATION_STATE = "relocationstate", 101 - ROBOT_SWEEPING = "robotsweeping", 102 - ROBOT_MOPING = "robotmoping", 103 - ROBOT_SWEEP_AND_MOPING = "robotsweepandmoping", 104 - ROBOT_TRANSITIONING = "robottransitioning", 105 - ROBOT_WAIT_CHARGE = "robotwaitcharge", 108 + CREATING_MAP_STATE = "creating_map", 29 + MAP_SAVE_STATE = "saving_map", 99 + RE_LOCATION_STATE = "relocating", 101 + ROBOT_SWEEPING = "sweeping", 102 + ROBOT_MOPING = "mopping", 103 + ROBOT_SWEEP_AND_MOPING = "sweep_and_mop", 104 + ROBOT_TRANSITIONING = "transitioning", 105 + ROBOT_WAIT_CHARGE = "waiting_to_charge", 108 class YXBackType(RoborockModeEnum): diff --git a/tests/data/b01_q10/test_b01_q10_code_mappings.py b/tests/data/b01_q10/test_b01_q10_code_mappings.py new file mode 100644 index 00000000..b6703110 --- /dev/null +++ b/tests/data/b01_q10/test_b01_q10_code_mappings.py @@ -0,0 +1,38 @@ +"""Test cases for B01 Q10 code mappings.""" + +from roborock.data.b01_q10 import YXDeviceState + + +def test_q10_status_values_are_canonical() -> None: + """Q10 status enum values should expose canonical names.""" + expected_values = { + YXDeviceState.UNKNOWN: "unknown", + YXDeviceState.SLEEP_STATE: "sleeping", + YXDeviceState.STANDBY_STATE: "standby", + YXDeviceState.CLEANING_STATE: "cleaning", + YXDeviceState.TO_CHARGE_STATE: "going_to_charge", + YXDeviceState.REMOTEING_STATE: "remote_control", + YXDeviceState.CHARGING_STATE: "charging", + YXDeviceState.PAUSE_STATE: "paused", + YXDeviceState.FAULT_STATE: "fault", + YXDeviceState.UPGRADE_STATE: "updating", + YXDeviceState.DUSTING: "dusting", + YXDeviceState.CREATING_MAP_STATE: "creating_map", + YXDeviceState.MAP_SAVE_STATE: "saving_map", + YXDeviceState.RE_LOCATION_STATE: "relocating", + YXDeviceState.ROBOT_SWEEPING: "sweeping", + YXDeviceState.ROBOT_MOPING: "mopping", + YXDeviceState.ROBOT_SWEEP_AND_MOPING: "sweep_and_mop", + YXDeviceState.ROBOT_TRANSITIONING: "transitioning", + YXDeviceState.ROBOT_WAIT_CHARGE: "waiting_to_charge", + } + + assert {state: state.value for state in expected_values} == expected_values + assert all(not value.endswith("state") for value in expected_values.values()) + + +def test_q10_status_codes_map_to_canonical_values() -> None: + """Code-based mapping should return canonical status values.""" + assert YXDeviceState.from_code(5) is YXDeviceState.CLEANING_STATE + assert YXDeviceState.from_code(8) is YXDeviceState.CHARGING_STATE + assert YXDeviceState.from_code(14) is YXDeviceState.UPGRADE_STATE From 0c2c781ce496c9bbbb7c9b167b583a00dbca0fc2 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 22 Mar 2026 01:49:57 +0000 Subject: [PATCH 27/34] 4.26.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d562c2..6fb92650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ +## v4.26.3 (2026-03-22) + +### Bug Fixes + +- **q10**: Add tests for Q10 status values and code mappings + ([#793](https://github.com/Python-roborock/python-roborock/pull/793), + [`dce00a2`](https://github.com/Python-roborock/python-roborock/commit/dce00a2499c3976f1cd25239bc4f81d996d51a79)) + +- **q10**: Normalize status names to canonical values + ([#793](https://github.com/Python-roborock/python-roborock/pull/793), + [`dce00a2`](https://github.com/Python-roborock/python-roborock/commit/dce00a2499c3976f1cd25239bc4f81d996d51a79)) + +- **q10**: Normalize YXDeviceState status names to canonical values + ([#793](https://github.com/Python-roborock/python-roborock/pull/793), + [`dce00a2`](https://github.com/Python-roborock/python-roborock/commit/dce00a2499c3976f1cd25239bc4f81d996d51a79)) + +- **q10**: Refactor test for canonical status names using a dictionary + ([#793](https://github.com/Python-roborock/python-roborock/pull/793), + [`dce00a2`](https://github.com/Python-roborock/python-roborock/commit/dce00a2499c3976f1cd25239bc4f81d996d51a79)) + + ## v4.26.2 (2026-03-21) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 253f1e4e..af0d1159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.26.2" +version = "4.26.3" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 7716cd37..810eea68 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.26.2" +version = "4.26.3" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From c63002264e40beb0f6e51281afd90e10e7b731cc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Mar 2026 11:12:36 -0700 Subject: [PATCH 28/34] feat(api)!: rename `YXWaterLevel` enum values to mirror v1 values (#796) --- roborock/data/b01_q10/b01_q10_code_mappings.py | 4 ++-- tests/protocols/test_b01_q10_protocol.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 660a35c5..a0e33686 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -136,9 +136,9 @@ class YXFanLevel(RoborockModeEnum): class YXWaterLevel(RoborockModeEnum): UNKNOWN = "unknown", -1 - CLOSE = "close", 0 + OFF = "off", 0 # close LOW = "low", 1 - MIDDLE = "middle", 2 + MEDIUM = "medium", 2 # middle HIGH = "high", 3 diff --git a/tests/protocols/test_b01_q10_protocol.py b/tests/protocols/test_b01_q10_protocol.py index 110b20f0..62ee2c27 100644 --- a/tests/protocols/test_b01_q10_protocol.py +++ b/tests/protocols/test_b01_q10_protocol.py @@ -97,7 +97,7 @@ def test_decode_unknown_dps_code() -> None: (B01_Q10_DP.REQUEST_DPS, {}), (B01_Q10_DP.REQUEST_DPS, None), (B01_Q10_DP.START_CLEAN, {"cmd": 1}), - (B01_Q10_DP.WATER_LEVEL, YXWaterLevel.MIDDLE.code), + (B01_Q10_DP.WATER_LEVEL, YXWaterLevel.MEDIUM.code), ], ) def test_encode_mqtt_payload(command: B01_Q10_DP, params: dict[str, Any], snapshot) -> None: From ad3ceeaea6f1b12dc51513c48476bcdf2756392b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Mar 2026 11:12:45 -0700 Subject: [PATCH 29/34] feat(api)!: Again rename `YXDeviceState` enum members to have consistency with V1 state values (#795) The motivation is to not have entirely different state values for the same concept to make it easier to write systems that can handle each device. --- .../data/b01_q10/b01_q10_code_mappings.py | 36 ++++++------ .../b01_q10/test_b01_q10_code_mappings.py | 55 +++++++++---------- tests/devices/traits/b01/q10/test_status.py | 6 +- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index a0e33686..1af9fc77 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -164,24 +164,24 @@ class YXCleanType(RoborockModeEnum): class YXDeviceState(RoborockModeEnum): UNKNOWN = "unknown", -1 - SLEEP_STATE = "sleeping", 2 - STANDBY_STATE = "standby", 3 - CLEANING_STATE = "cleaning", 5 - TO_CHARGE_STATE = "going_to_charge", 6 - REMOTEING_STATE = "remote_control", 7 - CHARGING_STATE = "charging", 8 - PAUSE_STATE = "paused", 10 - FAULT_STATE = "fault", 12 - UPGRADE_STATE = "updating", 14 - DUSTING = "dusting", 22 - CREATING_MAP_STATE = "creating_map", 29 - MAP_SAVE_STATE = "saving_map", 99 - RE_LOCATION_STATE = "relocating", 101 - ROBOT_SWEEPING = "sweeping", 102 - ROBOT_MOPING = "mopping", 103 - ROBOT_SWEEP_AND_MOPING = "sweep_and_mop", 104 - ROBOT_TRANSITIONING = "transitioning", 105 - ROBOT_WAIT_CHARGE = "waiting_to_charge", 108 + SLEEPING = "sleeping", 2 # sleepstate + IDLE = "idle", 3 # standbystate + CLEANING = "cleaning", 5 # cleaningstate + RETURNING_HOME = "returning_home", 6 # tochargestate + REMOTE_CONTROL_ACTIVE = "remote_control_active", 7 # remoteingstate + CHARGING = "charging", 8 # chargingstate + PAUSED = "paused", 10 # pausestate + ERROR = "error", 12 # faultstate + UPDATING = "updating", 14 # upgradestate + EMPTYING_THE_BIN = "emptying_the_bin", 22 # dusting + MAPPING = "mapping", 29 # creatingmapstate + SAVING_MAP = "saving_map", 99 # mapsavestate + RELOCATING = "relocating", 101 # relocationstate + SWEEPING = "sweeping", 102 # robotsweeping + MOPPING = "mopping", 103 # robotmoping + SWEEP_AND_MOP = "sweep_and_mop", 104 # robotsweepandmoping + TRANSITIONING = "transitioning", 105 # robottransitioning + WAITING_TO_CHARGE = "waiting_to_charge", 108 # robotwaitcharge class YXBackType(RoborockModeEnum): diff --git a/tests/data/b01_q10/test_b01_q10_code_mappings.py b/tests/data/b01_q10/test_b01_q10_code_mappings.py index b6703110..63fdd14d 100644 --- a/tests/data/b01_q10/test_b01_q10_code_mappings.py +++ b/tests/data/b01_q10/test_b01_q10_code_mappings.py @@ -1,38 +1,35 @@ """Test cases for B01 Q10 code mappings.""" +import pytest + from roborock.data.b01_q10 import YXDeviceState -def test_q10_status_values_are_canonical() -> None: +@pytest.mark.parametrize( + "state, string", + [ + (YXDeviceState.UNKNOWN, "unknown"), + (YXDeviceState.IDLE, "idle"), + (YXDeviceState.CHARGING, "charging"), + (YXDeviceState.CLEANING, "cleaning"), + (YXDeviceState.SLEEPING, "sleeping"), + (YXDeviceState.UPDATING, "updating"), + (YXDeviceState.RETURNING_HOME, "returning_home"), + ], +) +def test_q10_status_values_are_canonical(state: YXDeviceState, string: str) -> None: """Q10 status enum values should expose canonical names.""" - expected_values = { - YXDeviceState.UNKNOWN: "unknown", - YXDeviceState.SLEEP_STATE: "sleeping", - YXDeviceState.STANDBY_STATE: "standby", - YXDeviceState.CLEANING_STATE: "cleaning", - YXDeviceState.TO_CHARGE_STATE: "going_to_charge", - YXDeviceState.REMOTEING_STATE: "remote_control", - YXDeviceState.CHARGING_STATE: "charging", - YXDeviceState.PAUSE_STATE: "paused", - YXDeviceState.FAULT_STATE: "fault", - YXDeviceState.UPGRADE_STATE: "updating", - YXDeviceState.DUSTING: "dusting", - YXDeviceState.CREATING_MAP_STATE: "creating_map", - YXDeviceState.MAP_SAVE_STATE: "saving_map", - YXDeviceState.RE_LOCATION_STATE: "relocating", - YXDeviceState.ROBOT_SWEEPING: "sweeping", - YXDeviceState.ROBOT_MOPING: "mopping", - YXDeviceState.ROBOT_SWEEP_AND_MOPING: "sweep_and_mop", - YXDeviceState.ROBOT_TRANSITIONING: "transitioning", - YXDeviceState.ROBOT_WAIT_CHARGE: "waiting_to_charge", - } - - assert {state: state.value for state in expected_values} == expected_values - assert all(not value.endswith("state") for value in expected_values.values()) + assert state.value == string -def test_q10_status_codes_map_to_canonical_values() -> None: +@pytest.mark.parametrize( + "code, expected_state", + [ + (5, YXDeviceState.CLEANING), + (8, YXDeviceState.CHARGING), + (14, YXDeviceState.UPDATING), + ], +) +def test_q10_status_codes_map_to_canonical_values(code: int, expected_state: YXDeviceState) -> None: """Code-based mapping should return canonical status values.""" - assert YXDeviceState.from_code(5) is YXDeviceState.CLEANING_STATE - assert YXDeviceState.from_code(8) is YXDeviceState.CHARGING_STATE - assert YXDeviceState.from_code(14) is YXDeviceState.UPGRADE_STATE + assert YXDeviceState.from_code(code) is expected_state diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 192ff0e7..9e526c7e 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -96,10 +96,10 @@ async def test_status_trait_streaming( message_queue.put_nowait(message) # Wait for the update - await wait_for_attribute_value(q10_api.status, "status", YXDeviceState.CHARGING_STATE) + await wait_for_attribute_value(q10_api.status, "status", YXDeviceState.CHARGING) # Verify trait attributes are updated - assert q10_api.status.status == YXDeviceState.CHARGING_STATE + assert q10_api.status.status == YXDeviceState.CHARGING assert q10_api.status.clean_task_type == YXDeviceCleanTask.IDLE @@ -142,7 +142,7 @@ async def test_status_trait_refresh( # Verify trait attributes are updated assert q10_api.status.battery == 100 - assert q10_api.status.status == YXDeviceState.CHARGING_STATE + assert q10_api.status.status == YXDeviceState.CHARGING assert q10_api.status.fan_level == YXFanLevel.BALANCED assert q10_api.status.total_clean_area == 0 assert q10_api.status.total_clean_count == 0 From 7baeb658fd80b40bfb631dc250b899e659e20210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 19:14:36 +0100 Subject: [PATCH 30/34] Q10: use readable YXCleanType values (vac_and_mop / vacuum / mop) (#794) * refactor(q10): use readable YXCleanType values with legacy aliases * fix(cli): make clean mode option case insensitive * refactor(YXCleanType): move legacy values to a separate dictionary and update from_value method * test(YXCleanType): add tests for legacy clean type string aliases * refactor(YXCleanType): simplify clean type definitions and remove legacy alias support * test(YXCleanType): add tests for readable public values and compatibility with aliases * test(YXCleanType): add compatibility test for readable values in from_value method * test(q10): update clean type code mapping tests Co-authored-by: Allen Porter * fix(q10): restrict clean mode cli choices * refactor(YXCleanType): remove legacy test for readable public values * test(YXCleanType): update test for readable values to use canonical names * test(YXCleanType): simplify test for readable values in from_value method --------- Co-authored-by: Allen Porter --- roborock/cli.py | 7 ++++++- roborock/data/b01_q10/b01_q10_code_mappings.py | 6 +++--- tests/data/test_code_mappings.py | 16 +++++++++++++++- tests/devices/traits/b01/q10/test_vacuum.py | 2 +- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index 6eb11bf5..b2b208ff 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1275,7 +1275,12 @@ async def q10_empty_dustbin(ctx: click.Context, device_id: str) -> None: @session.command() @click.option("--device_id", required=True, help="Device ID") -@click.option("--mode", required=True, type=click.Choice(["bothwork", "onlysweep", "onlymop"]), help="Clean mode") +@click.option( + "--mode", + required=True, + type=click.Choice(["vac_and_mop", "vacuum", "mop"], case_sensitive=False), + help='Clean mode (preferred: "vac_and_mop", "vacuum", "mop")', +) @click.pass_context @async_command async def q10_set_clean_mode(ctx: click.Context, device_id: str, mode: str) -> None: diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 1af9fc77..8020894d 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -157,9 +157,9 @@ class YXRoomMaterial(RoborockModeEnum): class YXCleanType(RoborockModeEnum): UNKNOWN = "unknown", -1 - BOTH_WORK = "bothwork", 1 - ONLY_SWEEP = "onlysweep", 2 - ONLY_MOP = "onlymop", 3 + VAC_AND_MOP = "vac_and_mop", 1 # bothwork + VACUUM = "vacuum", 2 # onlysweep + MOP = "mop", 3 # onlymop class YXDeviceState(RoborockModeEnum): diff --git a/tests/data/test_code_mappings.py b/tests/data/test_code_mappings.py index 3c25f105..875d6c8c 100644 --- a/tests/data/test_code_mappings.py +++ b/tests/data/test_code_mappings.py @@ -5,7 +5,7 @@ import pytest from roborock import HomeDataProduct, RoborockCategory -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType def test_from_code() -> None: @@ -89,3 +89,17 @@ def test_homedata_product_unknown_category(): product = HomeDataProduct.from_dict(data) assert product.id == "unknown_cat_id" assert product.category == RoborockCategory.UNKNOWN + + +@pytest.mark.parametrize( + ("readable_value", "expected_clean_type"), + [ + ("vac_and_mop", YXCleanType.VAC_AND_MOP), + ("vacuum", YXCleanType.VACUUM), + ("mop", YXCleanType.MOP), + ], +) +def test_yx_clean_type_from_value_readable_values(readable_value: str, expected_clean_type: YXCleanType) -> None: + """Test YXCleanType accepts canonical readable values.""" + assert YXCleanType.from_value(readable_value) is expected_clean_type + assert expected_clean_type.value == readable_value diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 8e271c7c..499b9bbc 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -34,7 +34,7 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: (lambda x: x.stop_clean(), {"206": {}}), (lambda x: x.return_to_dock(), {"203": {}}), (lambda x: x.empty_dustbin(), {"203": 2}), - (lambda x: x.set_clean_mode(YXCleanType.BOTH_WORK), {"137": 1}), + (lambda x: x.set_clean_mode(YXCleanType.VAC_AND_MOP), {"137": 1}), (lambda x: x.set_fan_level(YXFanLevel.BALANCED), {"123": 2}), ], ) From 44ee75ee8fa9da511bfe9fb8174b4572b774514d Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 22 Mar 2026 18:17:24 +0000 Subject: [PATCH 31/34] 5.0.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb92650..03aa1a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ +## v5.0.0 (2026-03-22) + +### Bug Fixes + +- **cli**: Make clean mode option case insensitive + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + +- **q10**: Restrict clean mode cli choices + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + +### Features + +- **api**: Again rename `YXDeviceState` enum members to have consistency with V1 state values + ([#795](https://github.com/Python-roborock/python-roborock/pull/795), + [`ad3ceea`](https://github.com/Python-roborock/python-roborock/commit/ad3ceeaea6f1b12dc51513c48476bcdf2756392b)) + +- **api**: Rename `YXWaterLevel` enum values to mirror v1 values + ([#796](https://github.com/Python-roborock/python-roborock/pull/796), + [`c630022`](https://github.com/Python-roborock/python-roborock/commit/c63002264e40beb0f6e51281afd90e10e7b731cc)) + +### Refactoring + +- **q10**: Use readable YXCleanType values with legacy aliases + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + +- **YXCleanType**: Move legacy values to a separate dictionary and update from_value method + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + +- **YXCleanType**: Remove legacy test for readable public values + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + +- **YXCleanType**: Simplify clean type definitions and remove legacy alias support + ([#794](https://github.com/Python-roborock/python-roborock/pull/794), + [`7baeb65`](https://github.com/Python-roborock/python-roborock/commit/7baeb658fd80b40bfb631dc250b899e659e20210)) + + ## v4.26.3 (2026-03-22) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index af0d1159..595d6ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "4.26.3" +version = "5.0.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 810eea68..4cf57d0d 100644 --- a/uv.lock +++ b/uv.lock @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "4.26.3" +version = "5.0.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From d7626494149beff993ff74fd3d31725fb9a37138 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Mar 2026 18:10:47 -0700 Subject: [PATCH 32/34] chore: Migrate to typing.Self and remove __future__ annotations. (#798) * chore: Migrate to `typing.Self` and remove `__future__` annotations. * chore: Use `typing.Self` for class-referencing type hints and dynamic instantiation * chore: Update roborock/roborock_message.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- roborock/broadcast_protocol.py | 2 -- roborock/data/code_mappings.py | 14 ++++++-------- roborock/data/v1/v1_code_mappings.py | 4 +++- roborock/device_features.py | 6 ++---- roborock/devices/rpc/b01_q10_channel.py | 2 -- roborock/devices/traits/b01/q7/clean_summary.py | 2 -- roborock/devices/traits/v1/network_info.py | 2 -- roborock/diagnostics.py | 10 ++++------ roborock/exceptions.py | 2 -- roborock/protocol.py | 2 -- roborock/roborock_message.py | 7 +++---- roborock/roborock_typing.py | 5 ++--- roborock/util.py | 2 -- roborock/web_api.py | 2 -- 14 files changed, 20 insertions(+), 42 deletions(-) diff --git a/roborock/broadcast_protocol.py b/roborock/broadcast_protocol.py index a08bca94..e226a9cf 100644 --- a/roborock/broadcast_protocol.py +++ b/roborock/broadcast_protocol.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import hashlib import json diff --git a/roborock/data/code_mappings.py b/roborock/data/code_mappings.py index 47cab2b8..d1f36b83 100644 --- a/roborock/data/code_mappings.py +++ b/roborock/data/code_mappings.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from collections import namedtuple from enum import Enum, IntEnum, StrEnum @@ -17,7 +15,7 @@ def name(self) -> str: return super().name.lower() @classmethod - def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: + def _missing_(cls: type[Self], key) -> Self: if hasattr(cls, "unknown"): warning = f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'" if warning not in completed_warnings: @@ -32,23 +30,23 @@ def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: return default_value @classmethod - def as_dict(cls: type[RoborockEnum]): + def as_dict(cls: type[Self]): return {i.name: i.value for i in cls if i.name != "missing"} @classmethod - def as_enum_dict(cls: type[RoborockEnum]): + def as_enum_dict(cls: type[Self]): return {i.value: i for i in cls if i.name != "missing"} @classmethod - def values(cls: type[RoborockEnum]) -> list[int]: + def values(cls: type[Self]) -> list[int]: return list(cls.as_dict().values()) @classmethod - def keys(cls: type[RoborockEnum]) -> list[str]: + def keys(cls: type[Self]) -> list[str]: return list(cls.as_dict().keys()) @classmethod - def items(cls: type[RoborockEnum]): + def items(cls: type[Self]): return cls.as_dict().items() diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index c0bab14b..2ad3a0b3 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -1,3 +1,5 @@ +from typing import Self + from ..code_mappings import RoborockEnum @@ -91,7 +93,7 @@ class RoborockStartType(RoborockEnum): class RoborockDssCodes(RoborockEnum): @classmethod - def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: + def _missing_(cls: type[Self], key) -> Self: # If the calculated value is not provided, then it should be viewed as okay. # As the math will sometimes result in you getting numbers that don't matter. return cls.okay # type: ignore diff --git a/roborock/device_features.py b/roborock/device_features.py index b8ecf483..ef3ea19f 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -1,8 +1,6 @@ -from __future__ import annotations - from dataclasses import dataclass, field, fields from enum import IntEnum, StrEnum -from typing import Any +from typing import Any, Self from roborock.data.code_mappings import RoborockProductNickname from roborock.data.containers import RoborockBase @@ -566,7 +564,7 @@ def from_feature_flags( new_feature_info_str: str, feature_info: list[int], product_nickname: RoborockProductNickname | None, - ) -> DeviceFeatures: + ) -> Self: """Creates a DeviceFeatures instance from raw feature flags. :param new_feature_info: A int from get_init_status (sometimes can be found in homedata, but it is not always) :param new_feature_info_str: A hex string from get_init_status or home_data. diff --git a/roborock/devices/rpc/b01_q10_channel.py b/roborock/devices/rpc/b01_q10_channel.py index d27b148b..1e0510ba 100644 --- a/roborock/devices/rpc/b01_q10_channel.py +++ b/roborock/devices/rpc/b01_q10_channel.py @@ -1,7 +1,5 @@ """Thin wrapper around the MQTT channel for Roborock B01 Q10 devices.""" -from __future__ import annotations - import logging from collections.abc import AsyncGenerator from typing import Any diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py index b49040f3..65fea0e8 100644 --- a/roborock/devices/traits/b01/q7/clean_summary.py +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -4,8 +4,6 @@ and a `record_list` whose items contain a JSON string in `detail`. """ -from __future__ import annotations - import logging from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary diff --git a/roborock/devices/traits/v1/network_info.py b/roborock/devices/traits/v1/network_info.py index bba8bc65..2bdca57a 100644 --- a/roborock/devices/traits/v1/network_info.py +++ b/roborock/devices/traits/v1/network_info.py @@ -1,7 +1,5 @@ """Trait for device network information.""" -from __future__ import annotations - import logging from roborock.data import NetworkInfo diff --git a/roborock/diagnostics.py b/roborock/diagnostics.py index f455f607..c238209c 100644 --- a/roborock/diagnostics.py +++ b/roborock/diagnostics.py @@ -9,13 +9,11 @@ DeviceManager. """ -from __future__ import annotations - import time from collections import Counter from collections.abc import Generator, Mapping from contextlib import contextmanager -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast class Diagnostics: @@ -28,7 +26,7 @@ class Diagnostics: def __init__(self) -> None: """Initialize Diagnostics.""" self._counter: Counter = Counter() - self._subkeys: dict[str, Diagnostics] = {} + self._subkeys: dict[str, Self] = {} def increment(self, key: str, count: int = 1) -> None: """Increment a counter for the specified key/event.""" @@ -49,7 +47,7 @@ def as_dict(self) -> Mapping[str, Any]: data[k] = v return data - def subkey(self, key: str) -> Diagnostics: + def subkey(self, key: str) -> Self: """Return sub-Diagnostics object with the specified subkey. This will create a new Diagnostics object if one does not already exist @@ -63,7 +61,7 @@ def subkey(self, key: str) -> Diagnostics: The Diagnostics object for the specified subkey. """ if key not in self._subkeys: - self._subkeys[key] = Diagnostics() + self._subkeys[key] = type(self)() return self._subkeys[key] @contextmanager diff --git a/roborock/exceptions.py b/roborock/exceptions.py index 3c5b8295..55e72b60 100644 --- a/roborock/exceptions.py +++ b/roborock/exceptions.py @@ -1,7 +1,5 @@ """Roborock exceptions.""" -from __future__ import annotations - class RoborockException(Exception): """Class for Roborock exceptions.""" diff --git a/roborock/protocol.py b/roborock/protocol.py index 828a432a..fc3af842 100644 --- a/roborock/protocol.py +++ b/roborock/protocol.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import binascii import gzip import hashlib diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index fbe9becf..7c7f65a6 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -1,7 +1,6 @@ -from __future__ import annotations - from dataclasses import dataclass, field from enum import StrEnum +from typing import Self from roborock import RoborockEnum from roborock.util import get_next_int, get_timestamp @@ -37,8 +36,8 @@ class RoborockDataProtocol(RoborockEnum): OFFLINE_STATUS = 135 @classmethod - def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: - raise ValueError("%s not a valid key for Data Protocol", key) + def _missing_(cls: type[Self], key) -> Self: + raise ValueError(f"{key} not a valid key for Data Protocol") class RoborockDyadDataProtocol(RoborockEnum): diff --git a/roborock/roborock_typing.py b/roborock/roborock_typing.py index 418f350c..71251324 100644 --- a/roborock/roborock_typing.py +++ b/roborock/roborock_typing.py @@ -1,7 +1,6 @@ -from __future__ import annotations - from dataclasses import dataclass, field from enum import Enum, StrEnum +from typing import Self from .data import ( CleanRecord, @@ -368,7 +367,7 @@ def __post_init__(self) -> None: ): self.dust_collection_mode_name = self.dock_summary.dust_collection_mode.mode.name - def update(self, device_prop: DeviceProp) -> None: + def update(self, device_prop: Self) -> None: if device_prop.status: self.status = device_prop.status if device_prop.clean_summary: diff --git a/roborock/util.py b/roborock/util.py index 8679511e..481759ec 100644 --- a/roborock/util.py +++ b/roborock/util.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import math import time diff --git a/roborock/web_api.py b/roborock/web_api.py index 141cbeb5..a76d14c5 100644 --- a/roborock/web_api.py +++ b/roborock/web_api.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import base64 import hashlib import hmac From 99a63e60847583cfbe5326af16074cea8343b089 Mon Sep 17 00:00:00 2001 From: arduano Date: Mon, 23 Mar 2026 13:53:53 +1100 Subject: [PATCH 33/34] 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 34/34] 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: