Skip to content

Commit aa1340a

Browse files
allenporterCopilot
andauthored
chore: update map-related commands and payload decoding for B01/Q7 devices (#804)
* feat: implement map-related commands and payload decoding for B01/Q7 devices * Update roborock/devices/traits/b01/q7/map_content.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: Update roborock/devices/rpc/b01_q7_channel.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure device serial number and product model are provided when creating Q7PropertiesApi --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 239297f commit aa1340a

11 files changed

Lines changed: 239 additions & 267 deletions

File tree

roborock/devices/rpc/b01_q7_channel.py

Lines changed: 35 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,32 @@ 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 a map upload command and return decoded SCMap bytes.
146+
147+
This publishes the request and waits for a matching ``MAP_RESPONSE`` message
148+
with the correct protocol version. The raw ``MAP_RESPONSE`` payload bytes are
149+
then decoded/inflated via :func:`decode_map_payload` using this channel's
150+
``map_key``, and the resulting SCMap bytes are returned.
151+
152+
The returned value is the decoded map data bytes suitable for passing to the
153+
map parser library, not the raw MQTT ``MAP_RESPONSE`` payload bytes.
154+
"""
155+
156+
try:
157+
raw_payload = await _send_command(
158+
self._mqtt_channel,
159+
request_message,
160+
response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
161+
)
162+
except TimeoutError as ex:
163+
raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
164+
165+
return decode_map_payload(raw_payload, map_key=self._map_key)

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
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.exceptions import RoborockException
25+
from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage, create_map_key
2526
from roborock.roborock_message import RoborockB01Props
2627
from roborock.roborock_typing import RoborockB01Q7Methods
2728

@@ -51,9 +52,12 @@ class Q7PropertiesApi(Trait):
5152
map_content: MapContentTrait
5253
"""Trait for fetching parsed current map content."""
5354

54-
def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: HomeDataProduct) -> None:
55+
def __init__(
56+
self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct
57+
) -> None:
5558
"""Initialize the Q7 API."""
5659
self._channel = channel
60+
self._map_rpc_channel = map_rpc_channel
5761
self._device = device
5862
self._product = product
5963

@@ -63,9 +67,8 @@ def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: Hom
6367
self.clean_summary = CleanSummaryTrait(channel)
6468
self.map = MapTrait(channel)
6569
self.map_content = MapContentTrait(
70+
self._map_rpc_channel,
6671
self.map,
67-
serial=device.sn,
68-
model=product.model,
6972
)
7073

7174
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
@@ -173,4 +176,9 @@ async def send(self, command: CommandType, params: ParamsType) -> Any:
173176

174177
def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
175178
"""Create traits for B01 Q7 devices."""
176-
return Q7PropertiesApi(channel, device=device, product=product)
179+
if device.sn is None or product.model is None:
180+
raise RoborockException(
181+
f"Device serial number and product model are required (sn:: {device.sn}, model: {product.model})"
182+
)
183+
map_rpc_channel = MapRpcChannel(channel, map_key=create_map_key(serial=device.sn, model=product.model))
184+
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: 28 additions & 26 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,38 +55,38 @@ 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:
@@ -91,8 +95,6 @@ def parse_map_content(self, response: bytes) -> MapContent:
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)