Skip to content

Commit 2d03d6a

Browse files
committed
🦎 q7: split 1/3 command-layer segment + map retrieval APIs
1 parent df84567 commit 2d03d6a

File tree

3 files changed

+215
-4
lines changed

3 files changed

+215
-4
lines changed

roborock/devices/rpc/b01_q7_channel.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
decode_rpc_response,
1515
encode_mqtt_payload,
1616
)
17-
from roborock.roborock_message import RoborockMessage
17+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
1818

1919
_LOGGER = logging.getLogger(__name__)
2020
_TIMEOUT = 10.0
@@ -99,3 +99,62 @@ def find_response(response_message: RoborockMessage) -> None:
9999
raise
100100
finally:
101101
unsub()
102+
103+
104+
async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes:
105+
"""Send map upload command and wait for a map payload as bytes.
106+
107+
In practice, B01 map responses may arrive either as:
108+
- a dedicated ``MAP_RESPONSE`` message with raw payload bytes, or
109+
- a regular decoded RPC response that wraps a hex payload in ``data.payload``.
110+
111+
This helper accepts both response styles and returns the raw map payload bytes.
112+
"""
113+
114+
roborock_message = encode_mqtt_payload(request_message)
115+
future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
116+
117+
def find_response(response_message: RoborockMessage) -> None:
118+
if future.done():
119+
return
120+
121+
if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload:
122+
if not future.done():
123+
future.set_result(response_message.payload)
124+
return
125+
126+
try:
127+
decoded_dps = decode_rpc_response(response_message)
128+
except RoborockException:
129+
return
130+
131+
for dps_value in decoded_dps.values():
132+
if not isinstance(dps_value, str):
133+
continue
134+
try:
135+
inner = json.loads(dps_value)
136+
except (json.JSONDecodeError, TypeError):
137+
continue
138+
if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id):
139+
continue
140+
code = inner.get("code", 0)
141+
if code != 0:
142+
if not future.done():
143+
future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})"))
144+
return
145+
data = inner.get("data")
146+
if isinstance(data, dict) and isinstance(data.get("payload"), str):
147+
try:
148+
if not future.done():
149+
future.set_result(bytes.fromhex(data["payload"]))
150+
except ValueError:
151+
pass
152+
153+
unsub = await mqtt_channel.subscribe(find_response)
154+
try:
155+
await mqtt_channel.publish(roborock_message)
156+
return await asyncio.wait_for(future, timeout=_TIMEOUT)
157+
except TimeoutError as ex:
158+
raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
159+
finally:
160+
unsub()

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

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Traits for Q7 B01 devices.
22
Potentially other devices may fall into this category in the future."""
33

4+
import asyncio
45
from typing import Any
56

67
from roborock import B01Props
@@ -13,9 +14,10 @@
1314
SCWindMapping,
1415
WaterLevelMapping,
1516
)
16-
from roborock.devices.rpc.b01_q7_channel import send_decoded_command
17+
from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command
1718
from roborock.devices.traits import Trait
1819
from roborock.devices.transport.mqtt_channel import MqttChannel
20+
from roborock.exceptions import RoborockException
1921
from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage
2022
from roborock.roborock_message import RoborockB01Props
2123
from roborock.roborock_typing import RoborockB01Q7Methods
@@ -27,6 +29,8 @@
2729
"CleanSummaryTrait",
2830
]
2931

32+
_Q7_DPS = 10000
33+
3034

3135
class Q7PropertiesApi(Trait):
3236
"""API for interacting with B01 devices."""
@@ -38,6 +42,8 @@ def __init__(self, channel: MqttChannel) -> None:
3842
"""Initialize the B01Props API."""
3943
self._channel = channel
4044
self.clean_summary = CleanSummaryTrait(channel)
45+
# Map uploads are serialized per-device to avoid response cross-wiring.
46+
self._map_command_lock = asyncio.Lock()
4147

4248
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
4349
"""Query the device for the values of the given Q7 properties."""
@@ -87,6 +93,17 @@ async def start_clean(self) -> None:
8793
},
8894
)
8995

96+
async def clean_segments(self, segment_ids: list[int]) -> None:
97+
"""Start segment cleaning for the given ids (Q7 uses room ids)."""
98+
await self.send(
99+
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
100+
params={
101+
"clean_type": CleanTaskTypeMapping.ROOM.code,
102+
"ctrl_value": SCDeviceCleanParam.START.code,
103+
"room_ids": segment_ids,
104+
},
105+
)
106+
90107
async def pause_clean(self) -> None:
91108
"""Pause cleaning."""
92109
await self.send(
@@ -123,11 +140,61 @@ async def find_me(self) -> None:
123140
params={},
124141
)
125142

143+
async def get_map_list(self) -> dict[str, Any] | None:
144+
"""Return map list metadata from the robot."""
145+
response = await self.send(
146+
command=RoborockB01Q7Methods.GET_MAP_LIST,
147+
params={},
148+
)
149+
if response is None:
150+
return None
151+
if not isinstance(response, dict):
152+
raise TypeError(f"Unexpected response type for GET_MAP_LIST: {type(response).__name__}: {response!r}")
153+
return response
154+
155+
async def get_current_map_id(self) -> int:
156+
"""Resolve and return the currently active map id."""
157+
map_list_response = await self.get_map_list()
158+
map_id = self._extract_current_map_id(map_list_response)
159+
if map_id is None:
160+
raise RoborockException(f"Unable to determine map_id from map list response: {map_list_response!r}")
161+
return map_id
162+
163+
async def get_map_payload(self, *, map_id: int) -> bytes:
164+
"""Fetch raw map payload bytes for the given map id."""
165+
request = Q7RequestMessage(
166+
dps=_Q7_DPS,
167+
command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
168+
params={"map_id": map_id},
169+
)
170+
async with self._map_command_lock:
171+
return await send_map_command(self._channel, request)
172+
173+
async def get_current_map_payload(self) -> bytes:
174+
"""Fetch raw map payload bytes for the map currently selected by the robot."""
175+
return await self.get_map_payload(map_id=await self.get_current_map_id())
176+
177+
def _extract_current_map_id(self, map_list_response: dict[str, Any] | None) -> int | None:
178+
if not isinstance(map_list_response, dict):
179+
return None
180+
map_list = map_list_response.get("map_list")
181+
if not isinstance(map_list, list) or not map_list:
182+
return None
183+
184+
for entry in map_list:
185+
if isinstance(entry, dict) and entry.get("cur") and isinstance(entry.get("id"), int):
186+
return entry["id"]
187+
188+
first = map_list[0]
189+
if isinstance(first, dict) and isinstance(first.get("id"), int):
190+
return first["id"]
191+
return None
192+
126193
async def send(self, command: CommandType, params: ParamsType) -> Any:
127194
"""Send a command to the device."""
128195
return await send_decoded_command(
129196
self._channel,
130-
Q7RequestMessage(dps=10000, command=command, params=params),
197+
Q7RequestMessage(dps=_Q7_DPS, command=command, params=params),
131198
)
132199

133200

tests/devices/traits/b01/q7/test_init.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from roborock.devices.traits.b01.q7 import Q7PropertiesApi
1818
from roborock.exceptions import RoborockException
1919
from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage
20-
from roborock.roborock_message import RoborockB01Props, RoborockMessageProtocol
20+
from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol
2121
from tests.fixtures.channel_fixtures import FakeChannel
2222

2323
from . import B01MessageBuilder
@@ -257,3 +257,88 @@ async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel
257257
payload_data = json.loads(unpad(message.payload, AES.block_size))
258258
assert payload_data["dps"]["10000"]["method"] == "service.find_device"
259259
assert payload_data["dps"]["10000"]["params"] == {}
260+
261+
262+
async def test_q7_api_clean_segments(
263+
q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder
264+
):
265+
"""Test room/segment cleaning helper for Q7."""
266+
fake_channel.response_queue.append(message_builder.build({"result": "ok"}))
267+
await q7_api.clean_segments([10, 11])
268+
269+
assert len(fake_channel.published_messages) == 1
270+
message = fake_channel.published_messages[0]
271+
payload_data = json.loads(unpad(message.payload, AES.block_size))
272+
assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean"
273+
assert payload_data["dps"]["10000"]["params"] == {
274+
"clean_type": CleanTaskTypeMapping.ROOM.code,
275+
"ctrl_value": SCDeviceCleanParam.START.code,
276+
"room_ids": [10, 11],
277+
}
278+
279+
280+
async def test_q7_api_get_current_map_payload(
281+
q7_api: Q7PropertiesApi,
282+
fake_channel: FakeChannel,
283+
message_builder: B01MessageBuilder,
284+
):
285+
"""Fetch current map by map-list lookup, then upload_by_mapid."""
286+
fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]}))
287+
fake_channel.response_queue.append(
288+
RoborockMessage(
289+
protocol=RoborockMessageProtocol.MAP_RESPONSE,
290+
payload=b"raw-map-payload",
291+
version=b"B01",
292+
seq=message_builder.seq + 1,
293+
)
294+
)
295+
296+
raw_payload = await q7_api.get_current_map_payload()
297+
assert raw_payload == b"raw-map-payload"
298+
299+
assert len(fake_channel.published_messages) == 2
300+
301+
first = fake_channel.published_messages[0]
302+
first_payload = json.loads(unpad(first.payload, AES.block_size))
303+
assert first_payload["dps"]["10000"]["method"] == "service.get_map_list"
304+
assert first_payload["dps"]["10000"]["params"] == {}
305+
306+
second = fake_channel.published_messages[1]
307+
second_payload = json.loads(unpad(second.payload, AES.block_size))
308+
assert second_payload["dps"]["10000"]["method"] == "service.upload_by_mapid"
309+
assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512}
310+
311+
312+
async def test_q7_api_get_current_map_payload_falls_back_to_first_map(
313+
q7_api: Q7PropertiesApi,
314+
fake_channel: FakeChannel,
315+
message_builder: B01MessageBuilder,
316+
):
317+
"""If no current map marker exists, first map in list is used."""
318+
fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 111}, {"id": 222, "cur": False}]}))
319+
fake_channel.response_queue.append(
320+
RoborockMessage(
321+
protocol=RoborockMessageProtocol.MAP_RESPONSE,
322+
payload=b"raw-map-payload",
323+
version=b"B01",
324+
seq=message_builder.seq + 1,
325+
)
326+
)
327+
328+
await q7_api.get_current_map_payload()
329+
330+
second = fake_channel.published_messages[1]
331+
second_payload = json.loads(unpad(second.payload, AES.block_size))
332+
assert second_payload["dps"]["10000"]["params"] == {"map_id": 111}
333+
334+
335+
async def test_q7_api_get_current_map_payload_errors_without_map_list(
336+
q7_api: Q7PropertiesApi,
337+
fake_channel: FakeChannel,
338+
message_builder: B01MessageBuilder,
339+
):
340+
"""Current-map payload fetch should fail clearly when map list is unusable."""
341+
fake_channel.response_queue.append(message_builder.build({"map_list": []}))
342+
343+
with pytest.raises(RoborockException, match="Unable to determine map_id"):
344+
await q7_api.get_current_map_payload()

0 commit comments

Comments
 (0)