From 2d03d6aaeca853cee5eb35323c4ea5fcb57f9161 Mon Sep 17 00:00:00 2001 From: arduano Date: Sun, 1 Mar 2026 18:06:13 +1100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20split=201/3=20command-?= =?UTF-8?q?layer=20segment=20+=20map=20retrieval=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/rpc/b01_q7_channel.py | 61 ++++++++++++++- roborock/devices/traits/b01/q7/__init__.py | 71 +++++++++++++++++- tests/devices/traits/b01/q7/test_init.py | 87 +++++++++++++++++++++- 3 files changed, 215 insertions(+), 4 deletions(-) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index add5bc97..e5f0c4c8 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -14,7 +14,7 @@ decode_rpc_response, encode_mqtt_payload, ) -from roborock.roborock_message import RoborockMessage +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10.0 @@ -99,3 +99,62 @@ def find_response(response_message: RoborockMessage) -> None: raise finally: unsub() + + +async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes: + """Send map upload command and wait for a map payload as bytes. + + In practice, B01 map responses may arrive either as: + - a dedicated ``MAP_RESPONSE`` message with raw payload bytes, or + - a regular decoded RPC response that wraps a hex payload in ``data.payload``. + + This helper accepts both response styles and returns the raw map payload bytes. + """ + + roborock_message = encode_mqtt_payload(request_message) + future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + + def find_response(response_message: RoborockMessage) -> None: + if future.done(): + return + + if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: + if not future.done(): + future.set_result(response_message.payload) + return + + try: + decoded_dps = decode_rpc_response(response_message) + except RoborockException: + return + + for dps_value in decoded_dps.values(): + if not isinstance(dps_value, str): + continue + try: + inner = json.loads(dps_value) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): + continue + code = inner.get("code", 0) + if code != 0: + if not future.done(): + future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) + return + data = inner.get("data") + if isinstance(data, dict) and isinstance(data.get("payload"), str): + try: + if not future.done(): + future.set_result(bytes.fromhex(data["payload"])) + except ValueError: + pass + + unsub = await mqtt_channel.subscribe(find_response) + try: + await mqtt_channel.publish(roborock_message) + return await asyncio.wait_for(future, timeout=_TIMEOUT) + except TimeoutError as ex: + raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex + finally: + unsub() diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 9c09c05c..0fd55381 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -1,6 +1,7 @@ """Traits for Q7 B01 devices. Potentially other devices may fall into this category in the future.""" +import asyncio from typing import Any from roborock import B01Props @@ -13,9 +14,10 @@ SCWindMapping, WaterLevelMapping, ) -from roborock.devices.rpc.b01_q7_channel import send_decoded_command +from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage from roborock.roborock_message import RoborockB01Props from roborock.roborock_typing import RoborockB01Q7Methods @@ -27,6 +29,8 @@ "CleanSummaryTrait", ] +_Q7_DPS = 10000 + class Q7PropertiesApi(Trait): """API for interacting with B01 devices.""" @@ -38,6 +42,8 @@ def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel self.clean_summary = CleanSummaryTrait(channel) + # Map uploads are serialized per-device to avoid response cross-wiring. + self._map_command_lock = asyncio.Lock() async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" @@ -87,6 +93,17 @@ async def start_clean(self) -> None: }, ) + async def clean_segments(self, segment_ids: list[int]) -> None: + """Start segment cleaning for the given ids (Q7 uses room ids).""" + await self.send( + command=RoborockB01Q7Methods.SET_ROOM_CLEAN, + params={ + "clean_type": CleanTaskTypeMapping.ROOM.code, + "ctrl_value": SCDeviceCleanParam.START.code, + "room_ids": segment_ids, + }, + ) + async def pause_clean(self) -> None: """Pause cleaning.""" await self.send( @@ -123,11 +140,61 @@ async def find_me(self) -> None: params={}, ) + async def get_map_list(self) -> dict[str, Any] | None: + """Return map list metadata from the robot.""" + response = await self.send( + command=RoborockB01Q7Methods.GET_MAP_LIST, + params={}, + ) + if response is None: + return None + if not isinstance(response, dict): + raise TypeError(f"Unexpected response type for GET_MAP_LIST: {type(response).__name__}: {response!r}") + return response + + async def get_current_map_id(self) -> int: + """Resolve and return the currently active map id.""" + map_list_response = await self.get_map_list() + map_id = self._extract_current_map_id(map_list_response) + if map_id is None: + raise RoborockException(f"Unable to determine map_id from map list response: {map_list_response!r}") + return map_id + + async def get_map_payload(self, *, map_id: int) -> bytes: + """Fetch raw map payload bytes for the given map id.""" + request = Q7RequestMessage( + dps=_Q7_DPS, + command=RoborockB01Q7Methods.UPLOAD_BY_MAPID, + params={"map_id": map_id}, + ) + async with self._map_command_lock: + return await send_map_command(self._channel, request) + + async def get_current_map_payload(self) -> bytes: + """Fetch raw map payload bytes for the map currently selected by the robot.""" + return await self.get_map_payload(map_id=await self.get_current_map_id()) + + def _extract_current_map_id(self, map_list_response: dict[str, Any] | None) -> int | None: + if not isinstance(map_list_response, dict): + return None + map_list = map_list_response.get("map_list") + if not isinstance(map_list, list) or not map_list: + return None + + for entry in map_list: + if isinstance(entry, dict) and entry.get("cur") and isinstance(entry.get("id"), int): + return entry["id"] + + first = map_list[0] + if isinstance(first, dict) and isinstance(first.get("id"), int): + return first["id"] + return None + async def send(self, command: CommandType, params: ParamsType) -> Any: """Send a command to the device.""" return await send_decoded_command( self._channel, - Q7RequestMessage(dps=10000, command=command, params=params), + Q7RequestMessage(dps=_Q7_DPS, command=command, params=params), ) diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index cb16299c..f521995c 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -17,7 +17,7 @@ from roborock.devices.traits.b01.q7 import Q7PropertiesApi from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage -from roborock.roborock_message import RoborockB01Props, RoborockMessageProtocol +from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol from tests.fixtures.channel_fixtures import FakeChannel from . import B01MessageBuilder @@ -257,3 +257,88 @@ async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.find_device" assert payload_data["dps"]["10000"]["params"] == {} + + +async def test_q7_api_clean_segments( + q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder +): + """Test room/segment cleaning helper for Q7.""" + fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + await q7_api.clean_segments([10, 11]) + + assert len(fake_channel.published_messages) == 1 + message = fake_channel.published_messages[0] + payload_data = json.loads(unpad(message.payload, AES.block_size)) + assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" + assert payload_data["dps"]["10000"]["params"] == { + "clean_type": CleanTaskTypeMapping.ROOM.code, + "ctrl_value": SCDeviceCleanParam.START.code, + "room_ids": [10, 11], + } + + +async def test_q7_api_get_current_map_payload( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Fetch current map by map-list lookup, then upload_by_mapid.""" + 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, + ) + ) + + raw_payload = await q7_api.get_current_map_payload() + assert raw_payload == b"raw-map-payload" + + assert len(fake_channel.published_messages) == 2 + + first = fake_channel.published_messages[0] + first_payload = json.loads(unpad(first.payload, AES.block_size)) + assert first_payload["dps"]["10000"]["method"] == "service.get_map_list" + assert first_payload["dps"]["10000"]["params"] == {} + + second = fake_channel.published_messages[1] + second_payload = json.loads(unpad(second.payload, AES.block_size)) + assert second_payload["dps"]["10000"]["method"] == "service.upload_by_mapid" + assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} + + +async def test_q7_api_get_current_map_payload_falls_back_to_first_map( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """If no current map marker exists, first map in list is used.""" + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 111}, {"id": 222, "cur": False}]})) + fake_channel.response_queue.append( + RoborockMessage( + protocol=RoborockMessageProtocol.MAP_RESPONSE, + payload=b"raw-map-payload", + version=b"B01", + seq=message_builder.seq + 1, + ) + ) + + await q7_api.get_current_map_payload() + + second = fake_channel.published_messages[1] + second_payload = json.loads(unpad(second.payload, AES.block_size)) + assert second_payload["dps"]["10000"]["params"] == {"map_id": 111} + + +async def test_q7_api_get_current_map_payload_errors_without_map_list( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Current-map payload fetch should fail clearly when map list is unusable.""" + fake_channel.response_queue.append(message_builder.build({"map_list": []})) + + with pytest.raises(RoborockException, match="Unable to determine map_id"): + await q7_api.get_current_map_payload() From 632b16086faad9207705438038ee107e3aae6efb Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 7 Mar 2026 15:50:43 +1100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20harden=20map=20command?= =?UTF-8?q?=20decode=20+=20cover=20RPC=20payload=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/rpc/b01_q7_channel.py | 35 +++++++++++++++++++----- tests/devices/traits/b01/q7/test_init.py | 26 ++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index e5f0c4c8..b87a008b 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -113,14 +113,23 @@ async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7Request roborock_message = encode_mqtt_payload(request_message) future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + publish_started = asyncio.Event() def find_response(response_message: RoborockMessage) -> None: if future.done(): return - if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: - if not future.done(): - future.set_result(response_message.payload) + # Avoid accepting queued/stale MAP_RESPONSE messages before we actually + # publish this request. + if not publish_started.is_set(): + return + + if ( + response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE + and response_message.payload + and response_message.version == roborock_message.version + ): + future.set_result(response_message.payload) return try: @@ -145,13 +154,25 @@ def find_response(response_message: RoborockMessage) -> None: data = inner.get("data") if isinstance(data, dict) and isinstance(data.get("payload"), str): try: - if not future.done(): - future.set_result(bytes.fromhex(data["payload"])) - except ValueError: - pass + future.set_result(bytes.fromhex(data["payload"])) + except ValueError as ex: + future.set_exception( + RoborockException( + f"Invalid hex payload in B01 map response: {data.get('payload')} ({request_message})" + ) + ) + _LOGGER.debug( + "Invalid hex payload in B01 map response (msgId=%s): %s (%s): %s", + inner.get("msgId"), + data.get("payload"), + request_message, + ex, + ) + return unsub = await mqtt_channel.subscribe(find_response) try: + publish_started.set() await mqtt_channel.publish(roborock_message) return await asyncio.wait_for(future, timeout=_TIMEOUT) except TimeoutError as ex: diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index f521995c..b3d69b5c 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -309,6 +309,32 @@ async def test_q7_api_get_current_map_payload( assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} +async def test_q7_api_get_current_map_payload_rpc_wrapped_hex_payload( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Fetch current map via RPC-wrapped hex payload response.""" + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) + fake_channel.response_queue.append(message_builder.build({"payload": "68656c6c6f"})) + + raw_payload = await q7_api.get_current_map_payload() + assert raw_payload == b"hello" + + +async def test_q7_api_get_current_map_payload_rpc_wrapped_invalid_hex_errors( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Invalid hex payload should fail fast (not time out).""" + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) + fake_channel.response_queue.append(message_builder.build({"payload": "not-hex"})) + + with pytest.raises(RoborockException, match="Invalid hex payload"): + await q7_api.get_current_map_payload() + + async def test_q7_api_get_current_map_payload_falls_back_to_first_map( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, From 733159c736305bb0e09e8417741fa726fc062c56 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 7 Mar 2026 16:31:10 +1100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20drop=20RPC-wrapped=20m?= =?UTF-8?q?ap=20payload=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/rpc/b01_q7_channel.py | 65 ++++++++---------------- tests/devices/traits/b01/q7/test_init.py | 25 --------- 2 files changed, 21 insertions(+), 69 deletions(-) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index b87a008b..0c8084c3 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -102,28 +102,22 @@ def find_response(response_message: RoborockMessage) -> None: async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes: - """Send map upload command and wait for a map payload as bytes. + """Send map upload command and wait for the map payload bytes. - In practice, B01 map responses may arrive either as: - - a dedicated ``MAP_RESPONSE`` message with raw payload bytes, or - - a regular decoded RPC response that wraps a hex payload in ``data.payload``. + On a real Q7 B01 device, map uploads arrive as a dedicated + ``MAP_RESPONSE`` message with raw payload bytes. - This helper accepts both response styles and returns the raw map payload bytes. + We still decode RPC responses so we can fail fast on non-zero ``code`` + values for the initiating request (matched by msgId). """ roborock_message = encode_mqtt_payload(request_message) future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() - publish_started = asyncio.Event() def find_response(response_message: RoborockMessage) -> None: if future.done(): return - # Avoid accepting queued/stale MAP_RESPONSE messages before we actually - # publish this request. - if not publish_started.is_set(): - return - if ( response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload @@ -132,47 +126,30 @@ def find_response(response_message: RoborockMessage) -> None: future.set_result(response_message.payload) return + # Optional: look for an error response correlated by msgId. try: decoded_dps = decode_rpc_response(response_message) except RoborockException: return - for dps_value in decoded_dps.values(): - if not isinstance(dps_value, str): - continue - try: - inner = json.loads(dps_value) - except (json.JSONDecodeError, TypeError): - continue - if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): - continue - code = inner.get("code", 0) - if code != 0: - if not future.done(): - future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) - return - data = inner.get("data") - if isinstance(data, dict) and isinstance(data.get("payload"), str): - try: - future.set_result(bytes.fromhex(data["payload"])) - except ValueError as ex: - future.set_exception( - RoborockException( - f"Invalid hex payload in B01 map response: {data.get('payload')} ({request_message})" - ) - ) - _LOGGER.debug( - "Invalid hex payload in B01 map response (msgId=%s): %s (%s): %s", - inner.get("msgId"), - data.get("payload"), - request_message, - ex, - ) - return + dps_value = decoded_dps.get(request_message.dps) + if not isinstance(dps_value, str): + return + + try: + inner = json.loads(dps_value) + except (json.JSONDecodeError, TypeError): + return + + if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): + return + + code = inner.get("code", 0) + if code != 0: + future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) unsub = await mqtt_channel.subscribe(find_response) try: - publish_started.set() await mqtt_channel.publish(roborock_message) return await asyncio.wait_for(future, timeout=_TIMEOUT) except TimeoutError as ex: diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index b3d69b5c..28985422 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -309,31 +309,6 @@ async def test_q7_api_get_current_map_payload( assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} -async def test_q7_api_get_current_map_payload_rpc_wrapped_hex_payload( - q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, -): - """Fetch current map via RPC-wrapped hex payload response.""" - fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) - fake_channel.response_queue.append(message_builder.build({"payload": "68656c6c6f"})) - - raw_payload = await q7_api.get_current_map_payload() - assert raw_payload == b"hello" - - -async def test_q7_api_get_current_map_payload_rpc_wrapped_invalid_hex_errors( - q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, -): - """Invalid hex payload should fail fast (not time out).""" - fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) - fake_channel.response_queue.append(message_builder.build({"payload": "not-hex"})) - - with pytest.raises(RoborockException, match="Invalid hex payload"): - await q7_api.get_current_map_payload() - async def test_q7_api_get_current_map_payload_falls_back_to_first_map( q7_api: Q7PropertiesApi, From a20ae01bfbe53dd5668f564347313acbc8437b90 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 7 Mar 2026 16:42:38 +1100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20split=20map=20helpers?= =?UTF-8?q?=20into=20dedicated=20map=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/traits/b01/q7/__init__.py | 64 ++------------ roborock/devices/traits/b01/q7/map.py | 99 ++++++++++++++++++++++ tests/devices/traits/b01/q7/test_init.py | 22 ++++- 3 files changed, 127 insertions(+), 58 deletions(-) create mode 100644 roborock/devices/traits/b01/q7/map.py diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 0fd55381..02a285cd 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -1,7 +1,6 @@ """Traits for Q7 B01 devices. Potentially other devices may fall into this category in the future.""" -import asyncio from typing import Any from roborock import B01Props @@ -14,19 +13,22 @@ SCWindMapping, WaterLevelMapping, ) -from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command +from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage from roborock.roborock_message import RoborockB01Props from roborock.roborock_typing import RoborockB01Q7Methods from .clean_summary import CleanSummaryTrait +from .map import MapTrait, Q7MapList, Q7MapListEntry __all__ = [ "Q7PropertiesApi", "CleanSummaryTrait", + "MapTrait", + "Q7MapList", + "Q7MapListEntry", ] _Q7_DPS = 10000 @@ -38,12 +40,14 @@ class Q7PropertiesApi(Trait): clean_summary: CleanSummaryTrait """Trait for clean records / clean summary (Q7 `service.get_record_list`).""" + map: MapTrait + """Trait for map list metadata + raw map payload retrieval.""" + def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel self.clean_summary = CleanSummaryTrait(channel) - # Map uploads are serialized per-device to avoid response cross-wiring. - self._map_command_lock = asyncio.Lock() + self.map = MapTrait(channel) async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" @@ -140,56 +144,6 @@ async def find_me(self) -> None: params={}, ) - async def get_map_list(self) -> dict[str, Any] | None: - """Return map list metadata from the robot.""" - response = await self.send( - command=RoborockB01Q7Methods.GET_MAP_LIST, - params={}, - ) - if response is None: - return None - if not isinstance(response, dict): - raise TypeError(f"Unexpected response type for GET_MAP_LIST: {type(response).__name__}: {response!r}") - return response - - async def get_current_map_id(self) -> int: - """Resolve and return the currently active map id.""" - map_list_response = await self.get_map_list() - map_id = self._extract_current_map_id(map_list_response) - if map_id is None: - raise RoborockException(f"Unable to determine map_id from map list response: {map_list_response!r}") - return map_id - - async def get_map_payload(self, *, map_id: int) -> bytes: - """Fetch raw map payload bytes for the given map id.""" - request = Q7RequestMessage( - dps=_Q7_DPS, - command=RoborockB01Q7Methods.UPLOAD_BY_MAPID, - params={"map_id": map_id}, - ) - async with self._map_command_lock: - return await send_map_command(self._channel, request) - - async def get_current_map_payload(self) -> bytes: - """Fetch raw map payload bytes for the map currently selected by the robot.""" - return await self.get_map_payload(map_id=await self.get_current_map_id()) - - def _extract_current_map_id(self, map_list_response: dict[str, Any] | None) -> int | None: - if not isinstance(map_list_response, dict): - return None - map_list = map_list_response.get("map_list") - if not isinstance(map_list, list) or not map_list: - return None - - for entry in map_list: - if isinstance(entry, dict) and entry.get("cur") and isinstance(entry.get("id"), int): - return entry["id"] - - first = map_list[0] - if isinstance(first, dict) and isinstance(first.get("id"), int): - return first["id"] - return None - async def send(self, command: CommandType, params: ParamsType) -> Any: """Send a command to the device.""" return await send_decoded_command( diff --git a/roborock/devices/traits/b01/q7/map.py b/roborock/devices/traits/b01/q7/map.py new file mode 100644 index 00000000..386d6a2d --- /dev/null +++ b/roborock/devices/traits/b01/q7/map.py @@ -0,0 +1,99 @@ +"""Map trait for B01 Q7 devices.""" + +import asyncio +from dataclasses import dataclass, field + +from roborock.data import RoborockBase +from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command +from roborock.devices.traits import Trait +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException +from roborock.protocols.b01_q7_protocol import Q7RequestMessage +from roborock.roborock_typing import RoborockB01Q7Methods + +_Q7_DPS = 10000 + + +@dataclass +class Q7MapListEntry(RoborockBase): + """Single map list entry returned by `service.get_map_list`.""" + + id: int | None = None + cur: bool | None = None + + +@dataclass +class Q7MapList(RoborockBase): + """Map list response returned by `service.get_map_list`.""" + + map_list: list[Q7MapListEntry] = field(default_factory=list) + + +class MapTrait(Trait): + """Map retrieval + map metadata helpers for Q7 devices.""" + + def __init__(self, channel: MqttChannel) -> None: + self._channel = channel + # Map uploads are serialized per-device to avoid response cross-wiring. + self._map_command_lock = asyncio.Lock() + self._map_list_cache: Q7MapList | None = None + + async def refresh_map_list(self) -> Q7MapList: + """Fetch and cache map list metadata from the robot.""" + response = await send_decoded_command( + self._channel, + Q7RequestMessage(dps=_Q7_DPS, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}), + ) + if not isinstance(response, dict): + raise TypeError(f"Unexpected response type for GET_MAP_LIST: {type(response).__name__}: {response!r}") + + parsed = Q7MapList.from_dict(response) + if parsed is None: + raise TypeError(f"Failed to decode map list response: {response!r}") + + self._map_list_cache = parsed + return parsed + + async def get_map_list(self, *, refresh: bool = False) -> Q7MapList: + """Return cached map list metadata, refreshing when requested or absent.""" + if refresh or self._map_list_cache is None: + return await self.refresh_map_list() + return self._map_list_cache + + async def get_current_map_id(self, *, refresh: bool = False) -> int: + """Resolve and return the currently active map id from map list metadata.""" + map_list = await self.get_map_list(refresh=refresh) + map_id = self._extract_current_map_id(map_list) + if map_id is None: + raise RoborockException(f"Unable to determine map_id from map list response: {map_list!r}") + return map_id + + async def get_map_payload(self, *, map_id: int) -> bytes: + """Fetch raw map payload bytes for the given map id.""" + request = Q7RequestMessage( + dps=_Q7_DPS, + command=RoborockB01Q7Methods.UPLOAD_BY_MAPID, + params={"map_id": map_id}, + ) + async with self._map_command_lock: + return await send_map_command(self._channel, request) + + async def get_current_map_payload(self, *, refresh_map_list: bool = False) -> bytes: + """Fetch raw map payload bytes for the currently selected map.""" + map_id = await self.get_current_map_id(refresh=refresh_map_list) + return await self.get_map_payload(map_id=map_id) + + @staticmethod + def _extract_current_map_id(map_list_response: Q7MapList) -> int | None: + map_list = map_list_response.map_list + if not map_list: + return None + + for entry in map_list: + if entry.cur and isinstance(entry.id, int): + return entry.id + + first = map_list[0] + if isinstance(first.id, int): + return first.id + return None diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index 28985422..a1bb202c 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -293,7 +293,7 @@ async def test_q7_api_get_current_map_payload( ) ) - raw_payload = await q7_api.get_current_map_payload() + raw_payload = await q7_api.map.get_current_map_payload() assert raw_payload == b"raw-map-payload" assert len(fake_channel.published_messages) == 2 @@ -309,6 +309,22 @@ async def test_q7_api_get_current_map_payload( assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} +async def test_q7_api_map_trait_caches_map_list( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Map list is represented as dataclasses and cached on the map trait.""" + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 101, "cur": True}]})) + + first = await q7_api.map.get_map_list() + second = await q7_api.map.get_map_list() + + assert len(fake_channel.published_messages) == 1 + assert first is second + assert first.map_list[0].id == 101 + assert first.map_list[0].cur is True + async def test_q7_api_get_current_map_payload_falls_back_to_first_map( q7_api: Q7PropertiesApi, @@ -326,7 +342,7 @@ async def test_q7_api_get_current_map_payload_falls_back_to_first_map( ) ) - await q7_api.get_current_map_payload() + await q7_api.map.get_current_map_payload() second = fake_channel.published_messages[1] second_payload = json.loads(unpad(second.payload, AES.block_size)) @@ -342,4 +358,4 @@ async def test_q7_api_get_current_map_payload_errors_without_map_list( fake_channel.response_queue.append(message_builder.build({"map_list": []})) with pytest.raises(RoborockException, match="Unable to determine map_id"): - await q7_api.get_current_map_payload() + await q7_api.map.get_current_map_payload() From 0b8f6feafd0dc45575bd8b25886a12c619ef2962 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 7 Mar 2026 17:38:17 +1100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20align=20map=20trait=20?= =?UTF-8?q?with=20refresh-based=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/rpc/b01_q7_channel.py | 32 +--------------- roborock/devices/traits/b01/q7/map.py | 48 ++++++++++++------------ tests/devices/traits/b01/q7/test_init.py | 17 +++++---- 3 files changed, 36 insertions(+), 61 deletions(-) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index 0c8084c3..16170446 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -102,14 +102,7 @@ def find_response(response_message: RoborockMessage) -> None: async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes: - """Send map upload command and wait for the map payload bytes. - - On a real Q7 B01 device, map uploads arrive as a dedicated - ``MAP_RESPONSE`` message with raw payload bytes. - - We still decode RPC responses so we can fail fast on non-zero ``code`` - values for the initiating request (matched by msgId). - """ + """Send map upload command and wait for MAP_RESPONSE payload bytes.""" roborock_message = encode_mqtt_payload(request_message) future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() @@ -124,29 +117,6 @@ def find_response(response_message: RoborockMessage) -> None: and response_message.version == roborock_message.version ): future.set_result(response_message.payload) - return - - # Optional: look for an error response correlated by msgId. - try: - decoded_dps = decode_rpc_response(response_message) - except RoborockException: - return - - dps_value = decoded_dps.get(request_message.dps) - if not isinstance(dps_value, str): - return - - try: - inner = json.loads(dps_value) - except (json.JSONDecodeError, TypeError): - return - - if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): - return - - code = inner.get("code", 0) - if code != 0: - future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) unsub = await mqtt_channel.subscribe(find_response) try: diff --git a/roborock/devices/traits/b01/q7/map.py b/roborock/devices/traits/b01/q7/map.py index 386d6a2d..b98524c8 100644 --- a/roborock/devices/traits/b01/q7/map.py +++ b/roborock/devices/traits/b01/q7/map.py @@ -36,10 +36,22 @@ def __init__(self, channel: MqttChannel) -> None: self._channel = channel # Map uploads are serialized per-device to avoid response cross-wiring. self._map_command_lock = asyncio.Lock() - self._map_list_cache: Q7MapList | None = None + self._map_list: Q7MapList | None = None - async def refresh_map_list(self) -> Q7MapList: - """Fetch and cache map list metadata from the robot.""" + @property + def map_list(self) -> Q7MapList | None: + """Latest cached map list metadata, populated by ``refresh()``.""" + return self._map_list + + @property + def current_map_id(self) -> int | None: + """Current map id derived from cached map list metadata.""" + if self._map_list is None: + return None + return self._extract_current_map_id(self._map_list) + + async def refresh(self) -> None: + """Refresh cached map list metadata from the device.""" response = await send_decoded_command( self._channel, Q7RequestMessage(dps=_Q7_DPS, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}), @@ -51,24 +63,9 @@ async def refresh_map_list(self) -> Q7MapList: if parsed is None: raise TypeError(f"Failed to decode map list response: {response!r}") - self._map_list_cache = parsed - return parsed - - async def get_map_list(self, *, refresh: bool = False) -> Q7MapList: - """Return cached map list metadata, refreshing when requested or absent.""" - if refresh or self._map_list_cache is None: - return await self.refresh_map_list() - return self._map_list_cache + self._map_list = parsed - async def get_current_map_id(self, *, refresh: bool = False) -> int: - """Resolve and return the currently active map id from map list metadata.""" - map_list = await self.get_map_list(refresh=refresh) - map_id = self._extract_current_map_id(map_list) - if map_id is None: - raise RoborockException(f"Unable to determine map_id from map list response: {map_list!r}") - return map_id - - async def get_map_payload(self, *, map_id: int) -> bytes: + async def _get_map_payload(self, *, map_id: int) -> bytes: """Fetch raw map payload bytes for the given map id.""" request = Q7RequestMessage( dps=_Q7_DPS, @@ -78,10 +75,15 @@ async def get_map_payload(self, *, map_id: int) -> bytes: async with self._map_command_lock: return await send_map_command(self._channel, request) - async def get_current_map_payload(self, *, refresh_map_list: bool = False) -> bytes: + async def get_current_map_payload(self) -> bytes: """Fetch raw map payload bytes for the currently selected map.""" - map_id = await self.get_current_map_id(refresh=refresh_map_list) - return await self.get_map_payload(map_id=map_id) + if self._map_list is None: + await self.refresh() + + map_id = self.current_map_id + if map_id is None: + raise RoborockException(f"Unable to determine map_id from map list response: {self._map_list!r}") + return await self._get_map_payload(map_id=map_id) @staticmethod def _extract_current_map_id(map_list_response: Q7MapList) -> int | None: diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index a1bb202c..403cc04d 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -309,21 +309,24 @@ async def test_q7_api_get_current_map_payload( assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} -async def test_q7_api_map_trait_caches_map_list( +async def test_q7_api_map_trait_refresh_populates_cached_values( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder, ): - """Map list is represented as dataclasses and cached on the map trait.""" + """Map trait follows refresh + cached-value access pattern.""" fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 101, "cur": True}]})) - first = await q7_api.map.get_map_list() - second = await q7_api.map.get_map_list() + assert q7_api.map.map_list is None + assert q7_api.map.current_map_id is None + + await q7_api.map.refresh() assert len(fake_channel.published_messages) == 1 - assert first is second - assert first.map_list[0].id == 101 - assert first.map_list[0].cur is True + assert q7_api.map.map_list is not None + assert q7_api.map.map_list.map_list[0].id == 101 + assert q7_api.map.map_list.map_list[0].cur is True + assert q7_api.map.current_map_id == 101 async def test_q7_api_get_current_map_payload_falls_back_to_first_map(