diff --git a/roborock/cli.py b/roborock/cli.py index 9def26af..3d8a6396 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -594,26 +594,25 @@ async def maps(ctx, device_id: str): await _display_v1_trait(context, device_id, lambda v1: v1.maps) -# The Q10 pushes its map ~9s after a dpRequestDps; firmware throttles pushes to -# ~once per 60-70s, so a single request is answered quickly but rapid re-requests -# may not be. This bounds how long a one-shot CLI command waits for that push. +# The Q10 publishes its map asynchronously after a dpMultiMap list/get request. +# Firmware throttles pushes to ~once per 60-70s, so rapid re-requests may not be +# answered immediately. This bounds how long a one-shot CLI command waits. _Q10_MAP_PUSH_TIMEOUT = 30.0 async def _await_q10_map_push( properties: Q10PropertiesApi, predicate: Callable[[], bool], + add_source_listener: Callable[[Callable[[], None]], Callable[[], None]], *, timeout: float = _Q10_MAP_PUSH_TIMEOUT, allow_cached_on_timeout: bool = False, ) -> bool: - """Nudge a Q10 to push its map/trace and wait for a fresh update. + """Request Q10 map content and wait for a fresh map or trace packet. - The Q10 map API is entirely push-driven: there is no synchronous get-map - request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``, - which the device's subscribe loop feeds into the map trait. Here we register - an update listener, send the request, and wait for a newly pushed update to - satisfy ``predicate``. Returns whether it did within ``timeout``. + A Q10 needs a saved-map ID before it can request content. The map list and + content have independent refresh schedules, so the list is requested only + when no ID is stored. The content then arrives as a later ``MAP_RESPONSE``. """ loop = asyncio.get_running_loop() updated: asyncio.Future[None] = loop.create_future() @@ -622,10 +621,25 @@ def on_update() -> None: if predicate() and not updated.done(): updated.set_result(None) - unsub = properties.map.add_update_listener(on_update) + unsub = add_source_listener(on_update) try: - await properties.refresh() - await asyncio.wait_for(updated, timeout=timeout) + async with asyncio.timeout(timeout): + if properties.maps.current_map_id is None: + map_list_updated: asyncio.Future[None] = loop.create_future() + + def on_map_list_update() -> None: + if properties.maps.current_map_id is not None and not map_list_updated.done(): + map_list_updated.set_result(None) + + unsub_maps = properties.maps.add_update_listener(on_map_list_update) + try: + await properties.maps.refresh() + if properties.maps.current_map_id is None: + await map_list_updated + finally: + unsub_maps() + await properties.map.refresh() + await updated return True except TimeoutError: return allow_cached_on_timeout and predicate() @@ -648,6 +662,7 @@ async def map_image(ctx, device_id: str, output_file: str): await _await_q10_map_push( properties, lambda: properties.map.image_content is not None, + properties.map._add_map_packet_listener, allow_cached_on_timeout=True, ) image_content = properties.map.image_content @@ -706,7 +721,11 @@ async def q10_position(ctx, device_id: str, include_path: bool): click.echo("Feature not supported by device") return properties = device.b01_q10_properties - got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path)) + got_trace = await _await_q10_map_push( + properties, + lambda: bool(properties.map.path), + properties.map._add_trace_packet_listener, + ) if not got_trace: click.echo("No live trace available (the robot only reports position while cleaning).") return @@ -871,6 +890,7 @@ async def rooms(ctx, device_id: str): await _await_q10_map_push( properties, lambda: properties.map.image_content is not None, + properties.map._add_map_packet_listener, allow_cached_on_timeout=True, ) click.echo(dump_json({room.id: room.name for room in properties.map.rooms})) diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 2223cc86..6ee43553 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -83,11 +83,32 @@ def start_datetime(self) -> datetime.datetime | None: return None +@dataclass +class Q10MapInfo(RoborockBase): + """A saved map reported by ``dpMultiMap``. + + Q10 firmware represents the map identifier as a string on the wire. The + value is sent back unchanged in a subsequent ``{"op": "get"}`` request. + """ + + id: str + name: str | None = None + timestamp: int | None = None + + @dataclass class dpMultiMap(RoborockBase): + """Response envelope for the Q10 ``dpMultiMap`` data point.""" + op: str result: int - data: list + data: list[Q10MapInfo] = field(default_factory=list) + + @property + def current_map_id(self) -> str | None: + """Return the first saved-map identifier, if one was reported.""" + first = next((map_info for map_info in self.data if isinstance(map_info, Q10MapInfo) and map_info.id), None) + return first.id if first else None @dataclass diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 3c8c73ff..6fe7c2cd 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -17,6 +17,7 @@ from .do_not_disturb import DoNotDisturbTrait from .dust_collection import DustCollectionTrait from .map import MapContentTrait, MapDpsTrait +from .maps import MapsTrait from .network_info import NetworkInfoTrait from .remote import RemoteTrait from .status import StatusTrait @@ -32,6 +33,7 @@ "DoNotDisturbTrait", "DustCollectionTrait", "MapContentTrait", + "MapsTrait", "NetworkInfoTrait", "SoundVolumeTrait", "StatusTrait", @@ -79,6 +81,9 @@ class Q10PropertiesApi(Trait): map: MapContentTrait """Composed map image plus caller-facing map and trace data.""" + maps: MapsTrait + """Saved-map list metadata.""" + _map_dps: MapDpsTrait """Private source of restricted zones and virtual walls received through DPS.""" @@ -100,7 +105,8 @@ def __init__(self, channel: B01Q10Channel) -> None: self.network_info = NetworkInfoTrait() self.consumable = ConsumableTrait() self._map_dps = MapDpsTrait() - self.map = MapContentTrait(self._map_dps) + self.maps = MapsTrait(self.command) + self.map = MapContentTrait(self._map_dps, self.maps, self.command) self.clean_history = CleanHistoryTrait(self.command) # Read-model traits updated from the device's DPS push stream. self._updatable_traits = [ @@ -113,6 +119,7 @@ def __init__(self, channel: B01Q10Channel) -> None: self.consumable, self.clean_history, self._map_dps, + self.maps, ] self._subscribe_task: asyncio.Task[None] | None = None @@ -132,8 +139,8 @@ async def close(self) -> None: async def refresh(self) -> None: """Refresh all traits.""" - # Sending the REQUEST_DPS will cause the device to send all DPS values - # to the device. Updates will be received by the subscribe loop below. + # Sending REQUEST_DPS causes the device to publish its ordinary status + # values. Map-list and map-content refreshes have separate schedules. await self.command.send(B01_Q10_DP.REQUEST_DPS, params={}) async def _subscribe_loop(self) -> None: @@ -144,10 +151,8 @@ async def _subscribe_loop(self) -> None: def _handle_message(self, message: Q10Message) -> None: """Route a single decoded message to the trait responsible for it. - Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes (the - Q10 is entirely push-driven: there is no synchronous get-map request, a - ``dpRequestDps`` just nudges the device to publish its current map). DPS - updates feed the read-model traits. More traits can be dispatched here below. + Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes. + Map-list DPS responses and other DPS updates feed the read-model traits. """ if isinstance(message, Q10MapPacket): self.map.update_from_map_packet(message) diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index e206623d..09599109 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -7,16 +7,18 @@ * restricted zones, virtual walls and dock state arrive as ordinary DPS values. ``MapDpsTrait`` owns the low-level map-specific DPS read model. -``MapContentTrait`` combines that state with the latest map/trace packets -through the pure functions in :mod:`roborock.map.b01_q10_render`. The high-level -trait keeps only the latest value from each source and one replace-whole image; -calibration, path placement and overlay placement remain inside the renderer. +``MapContentTrait`` uses a stored ID from ``MapsTrait`` only when it requests +content. It combines the latest map and trace packets with the map DPS state +through the pure functions in :mod:`roborock.map.b01_q10_render`. Map-list +updates do not refresh content. """ import logging +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any +from roborock.callbacks import CallbackList from roborock.data import RoborockBase from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener @@ -31,7 +33,9 @@ from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map +from .command import CommandTrait from .common import UpdatableTrait +from .maps import MapsTrait _LOGGER = logging.getLogger(__name__) _DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN} @@ -80,24 +84,48 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: class MapContentTrait(TraitUpdateListener): """High-level composed Q10 map view. - The latest map and trace packets are combined with the injected map DPS - whenever any source changes. + The latest map and trace packets are combined with the injected + :class:`MapDpsTrait` whenever a source changes. The + :class:`MapsTrait` supplies a stored ID only when this trait requests + content. """ def __init__( self, map_dps: MapDpsTrait, + maps: MapsTrait, + command: CommandTrait, *, map_parser_config: B01Q10MapParserConfig | None = None, ) -> None: TraitUpdateListener.__init__(self, logger=_LOGGER) self._config = map_parser_config or B01Q10MapParserConfig() self._map_dps = map_dps + self._maps = maps + self._command = command self._map_packet: Q10MapPacket | None = None self._trace_packet: Q10TracePacket | None = None self._image_content: bytes | None = None + self._map_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER) + self._trace_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER) self._map_dps.add_update_listener(self._map_dps_updated) + async def refresh(self) -> None: + """Request content for the first map in the latest saved-map list.""" + if (map_id := self._maps.current_map_id) is None: + raise RoborockException("Cannot request Q10 map content before the map list is available") + # Map lists and map content can change at different times. Reuse the + # stored ID so a content refresh does not also refresh the list. + await self._command.send( + B01_Q10_DP.COMMON, + { + str(B01_Q10_DP.MULTI_MAP.code): { + "op": "get", + "id": map_id, + } + }, + ) + @property def image_content(self) -> bytes | None: """The composed map PNG, if the latest map rendered successfully.""" @@ -128,12 +156,22 @@ def update_from_map_packet(self, packet: Q10MapPacket) -> None: self._map_packet = packet self._render() self._notify_update() + self._map_packet_callbacks(None) def update_from_trace_packet(self, packet: Q10TracePacket) -> None: """Store a trace-protocol update and render the latest sources.""" self._trace_packet = packet self._render() self._notify_update() + self._trace_packet_callbacks(None) + + def _add_map_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register an internal callback for decoded map packets.""" + return self._map_packet_callbacks.add_callback(lambda _: callback()) + + def _add_trace_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register an internal callback for decoded trace packets.""" + return self._trace_packet_callbacks.add_callback(lambda _: callback()) def _map_dps_updated(self) -> None: """Render after the low-level map DPS source changes.""" diff --git a/roborock/devices/traits/b01/q10/maps.py b/roborock/devices/traits/b01/q10/maps.py new file mode 100644 index 00000000..d0945776 --- /dev/null +++ b/roborock/devices/traits/b01/q10/maps.py @@ -0,0 +1,58 @@ +"""Trait for Q10 saved-map list data.""" + +import logging +from dataclasses import dataclass, field +from typing import Any + +from roborock.data import RoborockBase +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import dpMultiMap +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Maps(RoborockBase): + """Saved-map list data from the Q10 DPS stream.""" + + multi_map: dpMultiMap | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP}) + + @property + def current_map_id(self) -> str | None: + """Return the first saved-map ID for a content request, if available.""" + if self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1: + return None + return self.multi_map.current_map_id + + +class MapsTrait(Maps, UpdatableTrait): + """Request and store the Q10 saved-map list.""" + + _CONVERTER = DpsDataConverter.from_dataclass(Maps) + _command: CommandTrait + + def __init__(self, command: CommandTrait) -> None: + """Initialize the saved-map list trait.""" + Maps.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + self._command = command + + async def refresh(self) -> None: + """Request a new saved-map list from the device.""" + await self._command.send( + B01_Q10_DP.COMMON, + {str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}}, + ) + + def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: + """Store a successful saved-map list response.""" + response = decoded_dps.get(B01_Q10_DP.MULTI_MAP) + # DP 61 also carries map-content acknowledgements. Ignore them so they + # cannot replace a usable map list with an unrelated response. + if not isinstance(response, dict) or response.get("op") != "list" or response.get("result") != 1: + return + super().update_from_dps(decoded_dps) diff --git a/roborock/map/b01_q10_map_parser.py b/roborock/map/b01_q10_map_parser.py index 6f1195c9..95e08cc3 100644 --- a/roborock/map/b01_q10_map_parser.py +++ b/roborock/map/b01_q10_map_parser.py @@ -1,7 +1,7 @@ """Parser for Roborock Q10 (B01/ss07) map packets. -Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message (pushed a -few seconds after a ``dpRequestDps`` request). Unlike the Q7 ``SCMap`` protobuf +Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a +``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf format, the Q10 uses a custom, unencrypted binary packet: - ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 724d5006..6697cda1 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -1,11 +1,9 @@ """Tests for the Q10 B01 map content trait. -The Q10 map API is push-driven: the device publishes ``MAP_RESPONSE`` messages -which the protocol layer decodes into typed map/trace packets; the trait updates -its cached state from them via ``update_from_map_packet`` / -``update_from_trace_packet`` (there is no synchronous get-map request). These -tests cover that state management; the pixel/geometry work it drives is tested in -``tests/map/test_b01_q10_render.py``. +Map list data and map content have independent refresh schedules. Content +requests use a stored map ID, and the device sends the data later in a +``MAP_RESPONSE`` packet. These tests cover that state management. The render +details are tested in ``tests/map/test_b01_q10_render.py``. """ import asyncio @@ -20,7 +18,9 @@ from roborock.cli import _await_q10_map_push, cli from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create +from roborock.devices.traits.b01.q10.command import CommandTrait from roborock.devices.traits.b01.q10.map import MapContentTrait, MapDpsTrait +from roborock.devices.traits.b01.q10.maps import MapsTrait from roborock.exceptions import RoborockException from roborock.map.b01_q10_map_parser import ( Q10Point, @@ -29,7 +29,7 @@ parse_trace_packet, ) from roborock.map.b01_q10_render import Q10MapOverlays -from roborock.protocols.b01_q10_protocol import Q10Message +from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message from .conftest import FakeB01Q10Channel @@ -38,8 +38,19 @@ def _map_trait(map_dps: MapDpsTrait | None = None) -> MapContentTrait: - """Create a high-level trait with its required low-level dependency.""" - return MapContentTrait(map_dps or MapDpsTrait()) + """Create map content with a stored map ID for tests that do not perform I/O.""" + command = cast(CommandTrait, Mock(spec=CommandTrait)) + maps = MapsTrait(command) + maps.update_from_dps( + { + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}], + "op": "list", + "result": 1, + } + } + ) + return MapContentTrait(map_dps or MapDpsTrait(), maps, command) def _zone_blob() -> str: @@ -98,17 +109,63 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None: class _FakeQ10Properties: def __init__(self) -> None: - self.map = _map_trait() + command = cast(CommandTrait, Mock(spec=CommandTrait)) + self.maps = MapsTrait(command) + self.maps.update_from_dps( + { + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}], + "op": "list", + "result": 1, + } + } + ) + self.map = MapContentTrait(MapDpsTrait(), self.maps, command) self.refresh_count = 0 - async def refresh(self) -> None: - self.refresh_count += 1 + async def refresh_map() -> None: + self.refresh_count += 1 + + self.map.refresh = refresh_map # type: ignore[method-assign] class _FakeQ10PropertiesWithTrace(_FakeQ10Properties): - async def refresh(self) -> None: - await super().refresh() - self.map.update_from_trace_packet(parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())) + def __init__(self) -> None: + super().__init__() + + async def refresh_map() -> None: + self.refresh_count += 1 + self.map.update_from_trace_packet(parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())) + + self.map.refresh = refresh_map # type: ignore[method-assign] + + +class _FakeQ10PropertiesWithoutMapId: + def __init__(self) -> None: + command = cast(CommandTrait, Mock(spec=CommandTrait)) + self.maps = MapsTrait(command) + self.map = MapContentTrait(MapDpsTrait(), self.maps, command) + self.maps_refresh_count = 0 + self.map_refresh_count = 0 + + async def refresh_maps() -> None: + self.maps_refresh_count += 1 + self.maps.update_from_dps( + { + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}], + "op": "list", + "result": 1, + } + } + ) + + async def refresh_map() -> None: + self.map_refresh_count += 1 + self.map.update_from_trace_packet(parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())) + + self.maps.refresh = refresh_maps # type: ignore[method-assign] + self.map.refresh = refresh_map # type: ignore[method-assign] async def test_await_q10_map_push_waits_for_fresh_update() -> None: @@ -117,7 +174,10 @@ async def test_await_q10_map_push_waits_for_fresh_update() -> None: properties.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1, 2)])) got_trace = await _await_q10_map_push( - cast(Q10PropertiesApi, properties), lambda: bool(properties.map.path), timeout=0.01 + cast(Q10PropertiesApi, properties), + lambda: bool(properties.map.path), + properties.map._add_trace_packet_listener, + timeout=0.01, ) assert got_trace is False @@ -128,13 +188,32 @@ async def test_await_q10_map_push_returns_true_after_update() -> None: properties = _FakeQ10PropertiesWithTrace() got_trace = await _await_q10_map_push( - cast(Q10PropertiesApi, properties), lambda: bool(properties.map.path), timeout=0.01 + cast(Q10PropertiesApi, properties), + lambda: bool(properties.map.path), + properties.map._add_trace_packet_listener, + timeout=0.01, ) assert got_trace is True assert len(properties.map.path) == 14 +async def test_await_q10_map_push_requests_map_list_only_on_first_use() -> None: + """Content gets the list first only when no stored map ID is available.""" + properties = _FakeQ10PropertiesWithoutMapId() + + got_trace = await _await_q10_map_push( + cast(Q10PropertiesApi, properties), + lambda: bool(properties.map.path), + properties.map._add_trace_packet_listener, + timeout=0.01, + ) + + assert got_trace is True + assert properties.maps_refresh_count == 1 + assert properties.map_refresh_count == 1 + + async def test_await_q10_map_push_can_fall_back_to_cached_map_on_timeout() -> None: properties = _FakeQ10Properties() properties.map.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes())) @@ -142,6 +221,7 @@ async def test_await_q10_map_push_can_fall_back_to_cached_map_on_timeout() -> No got_map = await _await_q10_map_push( cast(Q10PropertiesApi, properties), lambda: properties.map.image_content is not None, + properties.map._add_map_packet_listener, timeout=0.01, allow_cached_on_timeout=True, ) @@ -150,6 +230,30 @@ async def test_await_q10_map_push_can_fall_back_to_cached_map_on_timeout() -> No assert properties.refresh_count == 1 +async def test_await_q10_map_push_ignores_overlay_only_render() -> None: + """A DPS recomposition cannot masquerade as a fresh map packet.""" + map_dps = MapDpsTrait() + properties = _FakeQ10Properties() + properties.map = _map_trait(map_dps) + properties.map.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes())) + + async def refresh_overlay_only() -> None: + properties.refresh_count += 1 + map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()}) + + properties.map.refresh = refresh_overlay_only # type: ignore[method-assign] + + got_map = await _await_q10_map_push( + cast(Q10PropertiesApi, properties), + lambda: properties.map.image_content is not None, + properties.map._add_map_packet_listener, + timeout=0.01, + ) + + assert got_map is False + assert properties.refresh_count == 1 + + # --- Integration through the Q10PropertiesApi subscribe loop ----------------- @@ -210,6 +314,128 @@ async def test_subscribe_loop_routes_trace_push( assert q10_api.map.robot_position is not None +async def test_map_list_and_content_refresh_are_independent( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + message_queue: asyncio.Queue[Q10Message], +) -> None: + """A list update stores an ID but does not request map content.""" + await q10_api.maps.refresh() + assert mock_channel.published_commands == [ + ( + B01_Q10_DP.COMMON, + {str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}}, + ) + ] + + message_queue.put_nowait( + Q10DpsUpdate( + dps={ + B01_Q10_DP.MULTI_MAP: { + "data": [ + {"id": "12345", "name": "Current", "timestamp": 1}, + {"id": "67890", "name": "Other", "timestamp": 2}, + ], + "op": "list", + "result": 1, + } + } + ) + ) + + await _wait_for(lambda: q10_api.maps.current_map_id == "12345") + assert len(mock_channel.published_commands) == 1 + + await q10_api.map.refresh() + + assert mock_channel.published_commands[1] == ( + B01_Q10_DP.COMMON, + { + str(B01_Q10_DP.MULTI_MAP.code): { + "op": "get", + "id": "12345", + } + }, + ) + assert q10_api.maps.current_map_id == "12345" + + +async def test_empty_map_list_does_not_request_content( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + message_queue: asyncio.Queue[Q10Message], +) -> None: + """An empty list leaves map content unavailable.""" + message_queue.put_nowait( + Q10DpsUpdate( + dps={ + B01_Q10_DP.MULTI_MAP: { + "data": [], + "op": "list", + "result": 1, + } + } + ) + ) + + await asyncio.sleep(0.01) + assert q10_api.maps.current_map_id is None + assert mock_channel.published_commands == [] + + +async def test_map_content_refresh_requires_stored_map_id(q10_api: Q10PropertiesApi) -> None: + """Content cannot be requested until the map list supplies an ID.""" + with pytest.raises(RoborockException, match="map list is available"): + await q10_api.map.refresh() + + +async def test_map_content_refresh_requests_are_not_rate_limited(q10_api: Q10PropertiesApi) -> None: + """The caller controls content cadence; each refresh sends a get request.""" + q10_api.maps.update_from_dps( + { + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}], + "op": "list", + "result": 1, + } + } + ) + with patch.object(q10_api.command, "send") as send: + await q10_api.map.refresh() + await q10_api.map.refresh() + + assert send.await_count == 2 + + +def test_map_get_ack_does_not_replace_saved_map_list(q10_api: Q10PropertiesApi) -> None: + """A content acknowledgement cannot remove the stored map ID.""" + q10_api._handle_message( + Q10DpsUpdate( + dps={ + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}], + "op": "list", + "result": 1, + } + } + ) + ) + + q10_api._handle_message( + Q10DpsUpdate( + dps={ + B01_Q10_DP.MULTI_MAP: { + "data": [], + "op": "get", + "result": 1, + } + } + ) + ) + + assert q10_api.maps.current_map_id == "12345" + + # --- Source composition + rendering ------------------------------------------ diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index edd4bcb1..ed55c3c6 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -128,10 +128,7 @@ async def test_status_trait_refresh( # Send a refresh command await q10_api.refresh() - assert len(mock_channel.published_commands) == 1 - command, params = mock_channel.published_commands[0] - assert command == B01_Q10_DP.REQUEST_DPS - assert params == {} + assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {})] # Push the response message into the queue message_queue.put_nowait(message) diff --git a/tests/protocols/test_b01_q10_protocol.py b/tests/protocols/test_b01_q10_protocol.py index f5d916d1..5fddc41a 100644 --- a/tests/protocols/test_b01_q10_protocol.py +++ b/tests/protocols/test_b01_q10_protocol.py @@ -41,6 +41,26 @@ def test_decode_message_dps_update() -> None: assert decoded == Q10DpsUpdate(dps={B01_Q10_DP.BATTERY: 100}) +def test_decode_message_common_multi_map_list() -> None: + """A nested dpCommon map-list response is flattened into dpMultiMap.""" + message = _message( + b'{"dps":{"101":{"61":{"data":[{"id":"12345","name":"Map","timestamp":1}],"op":"list","result":1}}}}', + RoborockMessageProtocol.RPC_RESPONSE, + ) + + decoded = decode_message(message) + + assert decoded == Q10DpsUpdate( + dps={ + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345", "name": "Map", "timestamp": 1}], + "op": "list", + "result": 1, + } + } + ) + + def test_decode_message_map_packet() -> None: """A MAP_RESPONSE 01 01 payload decodes into a Q10MapPacket.""" message = _message(MAP_FIXTURE.read_bytes(), RoborockMessageProtocol.MAP_RESPONSE)