Skip to content

Commit d9211b2

Browse files
committed
feat: implement map-related commands and payload decoding for B01/Q7 devices
1 parent 607ef97 commit d9211b2

11 files changed

Lines changed: 232 additions & 268 deletions

File tree

roborock/devices/rpc/b01_q7_channel.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010

1111
from roborock.devices.transport.mqtt_channel import MqttChannel
1212
from roborock.exceptions import RoborockException
13-
from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage, decode_rpc_response, encode_mqtt_payload
13+
from roborock.protocols.b01_q7_protocol import (
14+
B01_VERSION,
15+
MapKey,
16+
Q7RequestMessage,
17+
decode_map_payload,
18+
decode_rpc_response,
19+
encode_mqtt_payload,
20+
)
1421
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
1522

1623
_LOGGER = logging.getLogger(__name__)
@@ -127,18 +134,29 @@ def find_response(response_message: RoborockMessage) -> DecodedB01Response | Non
127134
raise
128135

129136

130-
async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes:
131-
"""Send map upload command and wait for MAP_RESPONSE payload bytes.
137+
class MapRpcChannel:
138+
"""RPC channel for map-related commands on B01/Q7 devices."""
132139

133-
This stays separate from ``send_decoded_command()`` because map uploads arrive as
134-
raw ``MAP_RESPONSE`` payload bytes instead of a decoded RPC ``data`` payload.
135-
"""
140+
def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None:
141+
self._mqtt_channel = mqtt_channel
142+
self._map_key = map_key
136143

137-
try:
138-
return await _send_command(
139-
mqtt_channel,
140-
request_message,
141-
response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
142-
)
143-
except TimeoutError as ex:
144-
raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
144+
async def send_map_command(self, request_message: Q7RequestMessage) -> bytes:
145+
"""Send map upload command and wait for MAP_RESPONSE payload bytes.
146+
147+
This stays separate from ``send_decoded_command()`` because map uploads arrive as
148+
raw ``MAP_RESPONSE`` payload bytes instead of a decoded RPC ``data`` payload.
149+
150+
The response is a protocol buffer than can be parsed by the map parser library.
151+
"""
152+
153+
try:
154+
raw_payload = await _send_command(
155+
self._mqtt_channel,
156+
request_message,
157+
response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
158+
)
159+
except TimeoutError as ex:
160+
raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
161+
162+
return decode_map_payload(raw_payload, map_key=self._map_key)

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
SCWindMapping,
1919
WaterLevelMapping,
2020
)
21-
from roborock.devices.rpc.b01_q7_channel import send_decoded_command
21+
from roborock.devices.rpc.b01_q7_channel import MapRpcChannel, send_decoded_command
2222
from roborock.devices.traits import Trait
2323
from roborock.devices.transport.mqtt_channel import MqttChannel
24-
from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage
24+
from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage, create_map_key
2525
from roborock.roborock_message import RoborockB01Props
2626
from roborock.roborock_typing import RoborockB01Q7Methods
2727

@@ -51,9 +51,12 @@ class Q7PropertiesApi(Trait):
5151
map_content: MapContentTrait
5252
"""Trait for fetching parsed current map content."""
5353

54-
def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: HomeDataProduct) -> None:
54+
def __init__(
55+
self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct
56+
) -> None:
5557
"""Initialize the Q7 API."""
5658
self._channel = channel
59+
self._map_rpc_channel = map_rpc_channel
5760
self._device = device
5861
self._product = product
5962

@@ -63,9 +66,8 @@ def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: Hom
6366
self.clean_summary = CleanSummaryTrait(channel)
6467
self.map = MapTrait(channel)
6568
self.map_content = MapContentTrait(
69+
self._map_rpc_channel,
6670
self.map,
67-
serial=device.sn,
68-
model=product.model,
6971
)
7072

7173
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
@@ -173,4 +175,5 @@ async def send(self, command: CommandType, params: ParamsType) -> Any:
173175

174176
def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
175177
"""Create traits for B01 Q7 devices."""
176-
return Q7PropertiesApi(channel, device=device, product=product)
178+
map_rpc_channel = MapRpcChannel(channel, map_key=create_map_key(serial=device.sn or "", model=product.model or ""))
179+
return Q7PropertiesApi(channel, device=device, product=product, map_rpc_channel=map_rpc_channel)
Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
"""Map trait for B01 Q7 devices."""
22

3-
import asyncio
4-
53
from roborock.data import Q7MapList
6-
from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command
4+
from roborock.devices.rpc.b01_q7_channel import send_decoded_command
75
from roborock.devices.traits import Trait
86
from roborock.devices.transport.mqtt_channel import MqttChannel
97
from roborock.exceptions import RoborockException
@@ -12,14 +10,15 @@
1210

1311

1412
class MapTrait(Q7MapList, Trait):
15-
"""Map retrieval + map metadata helpers for Q7 devices."""
13+
"""Map trait for B01/Q7 devices, responsible for fetching and caching map list metadata.
14+
15+
The MapContent is fetched from the MapContent trait, which relies on this trait to determine the
16+
current map ID to fetch.
17+
"""
1618

1719
def __init__(self, channel: MqttChannel) -> None:
1820
super().__init__()
1921
self._channel = channel
20-
# Map uploads are serialized per-device to avoid response cross-wiring.
21-
self._map_command_lock = asyncio.Lock()
22-
self._loaded = False
2322

2423
async def refresh(self) -> None:
2524
"""Refresh cached map list metadata from the device."""
@@ -36,24 +35,3 @@ async def refresh(self) -> None:
3635
raise RoborockException(f"Failed to decode map list response: {response!r}")
3736

3837
self.map_list = parsed.map_list
39-
self._loaded = True
40-
41-
async def _get_map_payload(self, *, map_id: int) -> bytes:
42-
"""Fetch raw map payload bytes for the given map id."""
43-
request = Q7RequestMessage(
44-
dps=B01_Q7_DPS,
45-
command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
46-
params={"map_id": map_id},
47-
)
48-
async with self._map_command_lock:
49-
return await send_map_command(self._channel, request)
50-
51-
async def get_current_map_payload(self) -> bytes:
52-
"""Fetch raw map payload bytes for the currently selected map."""
53-
if not self._loaded:
54-
await self.refresh()
55-
56-
map_id = self.current_map_id
57-
if map_id is None:
58-
raise RoborockException(f"Unable to determine map_id from map list response: {self!r}")
59-
return await self._get_map_payload(map_id=map_id)

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

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
99
"""
1010

11+
import asyncio
1112
from dataclasses import dataclass
1213

1314
from vacuum_map_parser_base.map_data import MapData
1415

1516
from roborock.data import RoborockBase
17+
from roborock.devices.rpc.b01_q7_channel import MapRpcChannel
1618
from roborock.devices.traits import Trait
1719
from roborock.exceptions import RoborockException
1820
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
21+
from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
22+
from roborock.roborock_typing import RoborockB01Q7Methods
1923

2024
from .map import MapTrait
2125

@@ -51,48 +55,46 @@ class MapContentTrait(MapContent, Trait):
5155

5256
def __init__(
5357
self,
58+
map_rpc_channel: MapRpcChannel,
5459
map_trait: MapTrait,
5560
*,
56-
serial: str,
57-
model: str,
5861
map_parser_config: B01MapParserConfig | None = None,
5962
) -> None:
6063
super().__init__()
64+
self._map_rpc_channel = map_rpc_channel
6165
self._map_trait = map_trait
62-
self._serial = serial
63-
self._model = model
6466
self._map_parser = B01MapParser(map_parser_config)
67+
# Map uploads are serialized per-device to avoid response cross-wiring.
68+
self._map_command_lock = asyncio.Lock()
6569

6670
async def refresh(self) -> None:
67-
"""Fetch, decode, and parse the current map payload."""
68-
raw_payload = await self._map_trait.get_current_map_payload()
69-
parsed = self.parse_map_content(raw_payload)
70-
self.image_content = parsed.image_content
71-
self.map_data = parsed.map_data
72-
self.raw_api_response = parsed.raw_api_response
73-
74-
def parse_map_content(self, response: bytes) -> MapContent:
75-
"""Parse map content from raw bytes.
76-
77-
This mirrors the v1 trait behavior so cached map payload bytes can be
78-
reparsed without going back to the device.
71+
"""Fetch, decode, and parse the current map payload.
72+
73+
This relies on the Map Trait already having fetched the map list metadata
74+
so it can determine the current map_id.
7975
"""
76+
# Users must call first
77+
if (map_id := self._map_trait.current_map_id) is None:
78+
raise RoborockException("Unable to determine current map ID")
79+
80+
request = Q7RequestMessage(
81+
dps=B01_Q7_DPS,
82+
command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
83+
params={"map_id": map_id},
84+
)
85+
async with self._map_command_lock:
86+
raw_payload = await self._map_rpc_channel.send_map_command(request)
87+
8088
try:
81-
parsed_data = self._map_parser.parse(
82-
response,
83-
serial=self._serial,
84-
model=self._model,
85-
)
89+
parsed_data = self._map_parser.parse(raw_payload)
8690
except RoborockException:
8791
raise
8892
except Exception as ex:
89-
raise RoborockException("Failed to parse B01 map data") from ex
93+
raise RoborockException(f"Uncaught exception parsing B01 map data: {ex}") from ex
9094

9195
if parsed_data.image_content is None:
9296
raise RoborockException("Failed to render B01 map image")
9397

94-
return MapContent(
95-
image_content=parsed_data.image_content,
96-
map_data=parsed_data.map_data,
97-
raw_api_response=response,
98-
)
98+
self.image_content = parsed_data.image_content
99+
self.map_data = parsed_data.map_data
100+
self.raw_api_response = raw_payload

roborock/map/b01_map_parser.py

Lines changed: 8 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,19 @@
11
"""Module for parsing B01/Q7 map content.
22
3-
Observed Q7 `MAP_RESPONSE` payloads follow this decode pipeline:
4-
- base64-encoded ASCII
5-
- AES-ECB encrypted with the derived map key
6-
- PKCS7 padded
7-
- ASCII hex for a zlib-compressed SCMap payload
8-
93
The inner SCMap blob is parsed with protobuf messages generated from
104
`roborock/map/proto/b01_scmap.proto`.
115
"""
126

13-
import base64
14-
import binascii
15-
import hashlib
167
import io
17-
import zlib
188
from dataclasses import dataclass
199

20-
from Crypto.Cipher import AES
21-
from google.protobuf.message import DecodeError, Message
10+
from google.protobuf.message import DecodeError
2211
from PIL import Image
2312
from vacuum_map_parser_base.config.image_config import ImageConfig
2413
from vacuum_map_parser_base.map_data import ImageData, MapData
2514

2615
from roborock.exceptions import RoborockException
2716
from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
28-
from roborock.protocol import Utils
2917

3018
from .map_parser import ParsedMapData
3119

@@ -46,10 +34,9 @@ class B01MapParser:
4634
def __init__(self, config: B01MapParserConfig | None = None) -> None:
4735
self._config = config or B01MapParserConfig()
4836

49-
def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData:
50-
"""Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
51-
inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model)
52-
parsed = _parse_scmap_payload(inflated)
37+
def parse(self, payload: bytes) -> ParsedMapData:
38+
"""Parse an inflated SCMap payload and return a PNG + MapData."""
39+
parsed = _parse_scmap_payload(payload)
5340
size_x, size_y, grid = _extract_grid(parsed)
5441
room_names = _extract_room_names(parsed)
5542

@@ -78,54 +65,13 @@ def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData
7865
)
7966

8067

81-
def _derive_map_key(serial: str, model: str) -> bytes:
82-
"""Derive the B01/Q7 map decrypt key from serial + model."""
83-
model_suffix = model.split(".")[-1]
84-
model_key = (model_suffix + "0" * 16)[:16].encode()
85-
material = f"{serial}+{model_suffix}+{serial}".encode()
86-
encrypted = Utils.encrypt_ecb(material, model_key)
87-
md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest()
88-
return md5[8:24].encode()
89-
90-
91-
def _decode_base64_payload(raw_payload: bytes) -> bytes:
92-
blob = raw_payload.strip()
93-
padded = blob + b"=" * (-len(blob) % 4)
94-
try:
95-
return base64.b64decode(padded, validate=True)
96-
except binascii.Error as err:
97-
raise RoborockException("Failed to decode B01 map payload") from err
98-
99-
100-
def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes:
101-
"""Decode raw B01 `MAP_RESPONSE` payload into inflated SCMap bytes."""
102-
# TODO: Move this lower-level B01 transport decode under `roborock.protocols`
103-
# so this module only handles SCMap parsing/rendering.
104-
encrypted_payload = _decode_base64_payload(raw_payload)
105-
if len(encrypted_payload) % AES.block_size != 0:
106-
raise RoborockException("Unexpected encrypted B01 map payload length")
107-
108-
map_key = _derive_map_key(serial, model)
109-
110-
try:
111-
compressed_hex = Utils.decrypt_ecb(encrypted_payload, map_key).decode("ascii")
112-
compressed_payload = bytes.fromhex(compressed_hex)
113-
return zlib.decompress(compressed_payload)
114-
except (ValueError, UnicodeDecodeError, zlib.error) as err:
115-
raise RoborockException("Failed to decode B01 map payload") from err
116-
117-
118-
def _parse_proto(blob: bytes, message: Message, *, context: str) -> None:
119-
try:
120-
message.ParseFromString(blob)
121-
except DecodeError as err:
122-
raise RoborockException(f"Failed to parse {context}") from err
123-
124-
12568
def _parse_scmap_payload(payload: bytes) -> RobotMap:
12669
"""Parse inflated SCMap bytes into a generated protobuf message."""
12770
parsed = RobotMap()
128-
_parse_proto(payload, parsed, context="B01 SCMap")
71+
try:
72+
parsed.ParseFromString(payload)
73+
except DecodeError as err:
74+
raise RoborockException("Failed to parse B01 SCMap") from err
12975
return parsed
13076

13177

0 commit comments

Comments
 (0)