diff --git a/roborock/cli.py b/roborock/cli.py index 5d1e1cbc7..8a4e0c8ca 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -697,8 +697,8 @@ async def map_data(ctx, device_id: str, include_path: bool): async def q10_position(ctx, device_id: str, include_path: bool): """Get the current Q10 robot position and live cleaning path. - The Q10 only streams its position/path while it is actively cleaning, so this - will report that no live trace is available for an idle/docked robot. + The Q10 normally streams position/path while it is actively cleaning, so an + idle device may report that no fresh live trace is available. """ context: RoborockContext = ctx.obj device_manager = await context.get_device_manager() @@ -712,7 +712,7 @@ async def q10_position(ctx, device_id: str, include_path: bool): lambda: bool(properties.map.path), ) if not got_trace: - click.echo("No live trace available (the robot only reports position while cleaning).") + click.echo("No fresh live trace available.") return map_trait = properties.map position = map_trait.robot_position diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 8550bd7c6..90b23fbdf 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -144,7 +144,9 @@ 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. + value is sent back unchanged in a subsequent ``{"op": "select"}`` detail + request. On Q10 firmware, ``select`` previews a saved map without applying + it as the active map. """ id: str diff --git a/roborock/devices/README.md b/roborock/devices/README.md index 54cd2b25b..74c7891c0 100644 --- a/roborock/devices/README.md +++ b/roborock/devices/README.md @@ -39,3 +39,60 @@ Use `FileCache` or your own `Cache` implementation to persist: - `Device Capabilities`: What features your specific model supports. This speeds up startup time and reduces load on the Roborock cloud APIs. + + +## Q10 archive updates + +Archive updates use the same push-driven pattern as all other Q10 traits. +Register an update listener and update your view's state from the trait when the +callback runs. `refresh_detail()` publishes a selection; the device sends the +archive independently through its push stream. A history listener can also fire +for record-list updates, so read whichever properties your view displays on each +callback. + +```python +from roborock.devices.traits.b01.q10 import Q10PropertiesApi + + +class ArchivePreview: + def __init__(self, properties: Q10PropertiesApi) -> None: + self.history = properties.clean_history + self.image_content = self.history.detail_image_content + self._unsubscribe = self.history.add_update_listener(self._archive_updated) + + def _archive_updated(self) -> None: + self.image_content = self.history.detail_image_content + # Notify your UI to redraw using the latest received image. + + async def select_latest_record(self) -> None: + record = self.history.last_record + if record is not None and record.map_len: + await self.history.refresh_detail(record) + + def close(self) -> None: + self._unsubscribe() +``` + +Keep the listener registered while the view is open and unsubscribe when it +closes. The callback receives updates regardless of which client requested them. + +Clean-record detail packets contain no record identifier. `detail` represents +the latest received archive, including delayed or unsolicited pushes; it cannot +be reliably attributed to the record passed to `refresh_detail()`. In +particular, the API does not expose a `detail_record` association. Avoid +presenting a requested record's label as confirmed metadata for the response. +Concurrent selections and selections from another client have the same +limitation. + +Saved-map previews follow the same listener pattern via `properties.maps`. +Call `await properties.maps.refresh_detail(map_id)` for a listed map, or omit +`map_id` to use the first map in the list. `detail_map_id` comes from the received +packet itself, so a consumer can check it before displaying a preview. A delayed +preview can replace the latest saved-map preview; no request ID is available to +identify which selection produced it. Archive updates never replace the live +map or trace in `properties.map`. + +`properties.as_dict()` includes clean-history records and path data and +saved-map metadata. Binary map grids and PNG bytes are excluded; access images +through `detail_image_content`. Live map and trace updates use the usual trait +listener API. diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index 156e3ac56..2960b2709 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -19,6 +19,7 @@ from roborock.devices.device import DeviceReadyCallback, RoborockDevice from roborock.diagnostics import Diagnostics, redact_device_data from roborock.exceptions import RoborockException +from roborock.map.b01_q10_map_parser import B01Q10MapParserConfig from roborock.map.map_parser import MapParserConfig from roborock.mqtt.roborock_session import create_lazy_mqtt_session from roborock.mqtt.session import MqttSession, SessionUnauthorizedHook @@ -262,7 +263,12 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat if "ss" in model_part: b01_q10_channel = create_b01_q10_channel(mqtt_channel) channel = b01_q10_channel - trait = b01.q10.create(channel) + trait = b01.q10.create( + channel, + map_parser_config=( + B01Q10MapParserConfig(map_scale=map_parser_config.map_scale) if map_parser_config else None + ), + ) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel) diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 5416b7d77..31810de93 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -8,7 +8,13 @@ from roborock.data.containers import RoborockBase from roborock.devices.rpc.b01_q10_channel import B01Q10Channel from roborock.devices.traits import Trait -from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket +from roborock.map.b01_q10_map_parser import ( + B01Q10MapParserConfig, + Q10CleanRecordDetail, + Q10MapPacket, + Q10MapPacketKind, + Q10TracePacket, +) from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message from .button_light import ButtonLightTrait @@ -92,7 +98,12 @@ class Q10PropertiesApi(Trait): clean_history: CleanHistoryTrait """Trait for fetching the device clean-record history (``dpCleanRecord``).""" - def __init__(self, channel: B01Q10Channel) -> None: + def __init__( + self, + channel: B01Q10Channel, + *, + map_parser_config: B01Q10MapParserConfig, + ) -> None: """Initialize the B01Props API.""" self._channel = channel self.command = CommandTrait(channel) @@ -106,10 +117,17 @@ def __init__(self, channel: B01Q10Channel) -> None: self.network_info = NetworkInfoTrait() self.consumable = ConsumableTrait() self._map_dps = MapDpsTrait() - self.maps = MapsTrait(self.command) - self.map = MapContentTrait(self._map_dps, self.maps, self.command) + self.maps = MapsTrait(self.command, map_parser_config=map_parser_config) + self.map = MapContentTrait( + self._map_dps, + self.command, + map_parser_config=map_parser_config, + ) + self.clean_history = CleanHistoryTrait( + self.command, + map_parser_config=map_parser_config, + ) self.vacuum = VacuumTrait(self.command, self.status, self.map) - self.clean_history = CleanHistoryTrait(self.command) # Read-model traits updated from the device's DPS push stream. self._updatable_traits = [ self.status, @@ -158,7 +176,12 @@ def _handle_message(self, message: Q10Message) -> None: Map-list DPS responses and other DPS updates feed the read-model traits. """ if isinstance(message, Q10MapPacket): - self.map.update_from_map_packet(message) + if message.kind is Q10MapPacketKind.CURRENT: + self.map.update_from_map_packet(message) + elif message.kind is Q10MapPacketKind.SAVED_MAP_DETAIL: + self.maps.update_from_map_packet(message) + elif isinstance(message, Q10CleanRecordDetail): + self.clean_history.update_from_detail(message) elif isinstance(message, Q10TracePacket): self.map.update_from_trace_packet(message) elif isinstance(message, Q10DpsUpdate): @@ -179,6 +202,13 @@ def as_dict(self) -> dict[str, Any]: return result -def create(channel: B01Q10Channel) -> Q10PropertiesApi: +def create( + channel: B01Q10Channel, + *, + map_parser_config: B01Q10MapParserConfig | None = None, +) -> Q10PropertiesApi: """Create traits for B01 devices.""" - return Q10PropertiesApi(channel) + return Q10PropertiesApi( + channel, + map_parser_config=map_parser_config or B01Q10MapParserConfig(), + ) diff --git a/roborock/devices/traits/b01/q10/clean_history.py b/roborock/devices/traits/b01/q10/clean_history.py index fde2bb2fe..d4f52064f 100644 --- a/roborock/devices/traits/b01/q10/clean_history.py +++ b/roborock/devices/traits/b01/q10/clean_history.py @@ -10,10 +10,12 @@ a ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`, and the trait applies it. """ +import asyncio 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, YXCleaningResult, @@ -22,11 +24,22 @@ YXStartMethod, ) from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord +from roborock.exceptions import RoborockException +from roborock.map.b01_q10_map_parser import ( + B01Q10MapParserConfig, + Q10CleanRecordDetail, + Q10HistoricalTracePacket, + Q10MapPacket, + Q10MapPacketKind, + Q10Point, +) +from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map from .command import CommandTrait from .common import UpdatableTrait __all__ = [ + "CleanHistory", "CleanHistoryTrait", "CleanRecordConverter", "CleanRecordPush", @@ -107,26 +120,67 @@ def parse_record(raw: Any | None) -> Q10CleanRecord | None: return None -class CleanHistoryTrait(UpdatableTrait): - """Access to the Q10 clean-record history (``dpCleanRecord``, DP 52). - - A read-model trait updated from the DPS stream like the others, but it overrides - :meth:`update_from_dps` because the payload is a structured push (a record list, - or a single ``op:"notify"`` record) rather than a flat data-point-to-field map. - """ +@dataclass +class CleanHistory(RoborockBase): + """Received clean-history data, independent of request and transport state.""" - def __init__(self, command: CommandTrait) -> None: - """Initialize the clean history trait.""" - UpdatableTrait.__init__(self, command, _LOGGER) - self._converter = CleanRecordConverter() - self.records: list[Q10CleanRecord] = [] - """Decoded clean records, most recent first.""" + records: list[Q10CleanRecord] = field(default_factory=list) + """Decoded clean records, most recent first.""" + detail: Q10CleanRecordDetail | None = None + """Latest received archive. Its wire payload contains no record identifier.""" + detail_image_content: bytes | None = None + """Rendered archive PNG, available separately from diagnostic serialization.""" @property def last_record(self) -> Q10CleanRecord | None: """The most recent clean record, or ``None`` if there are none.""" return self.records[0] if self.records else None + @property + def detail_packet(self) -> Q10MapPacket | None: + """The map from the most recently received clean-record detail.""" + return self.detail.map if self.detail else None + + @property + def detail_trace(self) -> Q10HistoricalTracePacket | None: + """Historical path embedded in the latest received clean-record detail.""" + return self.detail.trace if self.detail else None + + @property + def detail_path(self) -> list[Q10Point]: + """Historical path points from the latest received archive.""" + return list(self.detail_trace.points) if self.detail_trace else [] + + def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize public history data without binary map grids or PNG bytes.""" + excluded = exclude or set() + data: dict[str, Any] = {} + if "records" not in excluded: + data["records"] = [record.as_dict() for record in self.records] + if "detail_path" not in excluded: + data["detailPath"] = [point.as_dict() for point in self.detail_path] + return data + + +class CleanHistoryTrait(CleanHistory, UpdatableTrait): + """Request history updates and notify listeners when device pushes arrive. + + ``refresh_detail`` returns after publishing; it does not wait for an archive. + Consumers subscribe with ``add_update_listener`` and read ``detail`` there. + Clean-record archives cannot be correlated with selections: delayed pushes + and selections by other clients are indistinguishable on the wire. + """ + + _command: CommandTrait + + def __init__(self, command: CommandTrait, *, map_parser_config: B01Q10MapParserConfig) -> None: + CleanHistory.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + self._command = command + self._converter = CleanRecordConverter() + self._map_parser_config = map_parser_config + self._detail_lock = asyncio.Lock() + async def refresh(self) -> None: """Request the clean-record list from the device. @@ -134,13 +188,27 @@ async def refresh(self) -> None: asynchronously on the device stream and populate :attr:`records` once :meth:`update_from_dps` processes the ``dpCleanRecord`` push. """ - if self._command is None: - raise ValueError("Trait is read-only; no command channel was provided") await self._command.send( B01_Q10_DP.COMMON, params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}}, ) + async def refresh_detail(self, record: Q10CleanRecord) -> None: + """Publish a selection using the complete firmware record identifier. + + Returns after sending. The resulting archive is received independently + through the update listener API, with no guaranteed record attribution. + The lock serializes publication only; missing pushes do not block retries. + """ + if not record.raw or not record.map_len: + raise RoborockException("The Q10 clean record has no saved map detail") + record_id = record.raw + async with self._detail_lock: + await self._command.send( + B01_Q10_DP.COMMON, + {str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "select", "id": record_id}}, + ) + def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: """Apply a ``dpCleanRecord`` push (a full list reply or a single notify).""" envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD) @@ -151,6 +219,23 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: return self._apply(push) + def update_from_detail(self, detail: Q10CleanRecordDetail) -> None: + """Store and render a pushed clean-record detail map.""" + if detail.map.kind is not Q10MapPacketKind.CLEAN_RECORD_DETAIL: + raise ValueError(f"Expected a Q10 clean-record detail packet, got {detail.map.kind.value}") + self.detail = detail + try: + self.detail_image_content = render_q10_map( + detail.map, + detail.trace, + Q10MapOverlays(), + config=self._map_parser_config, + ) + except RoborockException: + _LOGGER.debug("Failed to render Q10 clean-record detail", exc_info=True) + self.detail_image_content = None + self._notify_update() + def _apply(self, push: CleanRecordPush) -> None: """Merge or replace the records from ``push``, then sort newest-first and notify.""" if push.replace: diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index a33c2df22..f54f09931 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -25,6 +25,7 @@ from roborock.map.b01_q10_map_parser import ( B01Q10MapParserConfig, Q10MapPacket, + Q10MapPacketKind, Q10Point, Q10Room, Q10TracePacket, @@ -34,7 +35,6 @@ from .command import CommandTrait from .common import UpdatableTrait -from .maps import MapsTrait _LOGGER = logging.getLogger(__name__) _DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN} @@ -84,15 +84,13 @@ class MapContentTrait(TraitUpdateListener): """High-level composed Q10 map view. 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. + :class:`MapDpsTrait` whenever a source changes. Current-map acquisition is + independent of the saved-map list. """ def __init__( self, map_dps: MapDpsTrait, - maps: MapsTrait, command: CommandTrait, *, map_parser_config: B01Q10MapParserConfig | None = None, @@ -100,7 +98,6 @@ def __init__( 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 @@ -150,18 +147,25 @@ def robot_heading(self) -> int | None: def update_from_map_packet(self, packet: Q10MapPacket) -> None: """Store a map-protocol update and render the latest sources.""" + if packet.kind is not Q10MapPacketKind.CURRENT: + raise ValueError(f"Expected a current Q10 map packet, got {packet.kind.value}") self._map_packet = packet self._render() self._notify_update() def update_from_trace_packet(self, packet: Q10TracePacket) -> None: """Store a trace-protocol update and render the latest sources.""" - self._trace_packet = packet + self._trace_packet = None if self._map_dps.robot_at_dock else packet self._render() self._notify_update() def _map_dps_updated(self) -> None: """Render after the low-level map DPS source changes.""" + if self._map_dps.robot_at_dock and self._trace_packet is not None: + # A completed cleaning trace is not the current robot position once + # the device is docked. Clear the public live-path state even if the + # firmware does not send its usual zero-point trace. + self._trace_packet = None if self._map_packet is None: return self._render() diff --git a/roborock/devices/traits/b01/q10/maps.py b/roborock/devices/traits/b01/q10/maps.py index d09457764..286906259 100644 --- a/roborock/devices/traits/b01/q10/maps.py +++ b/roborock/devices/traits/b01/q10/maps.py @@ -1,13 +1,17 @@ """Trait for Q10 saved-map list data.""" +import asyncio 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.data.b01_q10.b01_q10_containers import Q10MapInfo, dpMultiMap from roborock.devices.traits.common import DpsDataConverter +from roborock.exceptions import RoborockException +from roborock.map.b01_q10_map_parser import B01Q10MapParserConfig, Q10MapPacket, Q10MapPacketKind +from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map from .command import CommandTrait from .common import UpdatableTrait @@ -21,6 +25,23 @@ class Maps(RoborockBase): multi_map: dpMultiMap | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP}) + detail_packet: Q10MapPacket | None = None + """Most recently received saved-map preview.""" + detail_map_id: str | None = None + """Map ID supplied by the preview packet itself.""" + detail_image_content: bytes | None = None + """Rendered preview PNG, excluded from diagnostic serialization.""" + + def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize metadata without binary map grids or PNG bytes.""" + excluded = exclude or set() + data: dict[str, Any] = {} + if self.multi_map is not None and "multi_map" not in excluded: + data["multiMap"] = self.multi_map.as_dict() + if self.detail_map_id is not None and "detail_map_id" not in excluded: + data["detailMapId"] = self.detail_map_id + return data + @property def current_map_id(self) -> str | None: """Return the first saved-map ID for a content request, if available.""" @@ -28,6 +49,13 @@ def current_map_id(self) -> str | None: return None return self.multi_map.current_map_id + @property + def map_list(self) -> list[Q10MapInfo]: + """Return a copy of the successfully reported saved-map list.""" + if self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1: + return [] + return list(self.multi_map.data) + class MapsTrait(Maps, UpdatableTrait): """Request and store the Q10 saved-map list.""" @@ -35,11 +63,18 @@ class MapsTrait(Maps, UpdatableTrait): _CONVERTER = DpsDataConverter.from_dataclass(Maps) _command: CommandTrait - def __init__(self, command: CommandTrait) -> None: + def __init__( + self, + command: CommandTrait, + *, + map_parser_config: B01Q10MapParserConfig, + ) -> None: """Initialize the saved-map list trait.""" Maps.__init__(self) UpdatableTrait.__init__(self, command, _LOGGER) self._command = command + self._map_parser_config = map_parser_config + self._detail_lock = asyncio.Lock() async def refresh(self) -> None: """Request a new saved-map list from the device.""" @@ -48,6 +83,25 @@ async def refresh(self) -> None: {str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}}, ) + async def refresh_detail(self, map_id: str | None = None) -> None: + """Request a read-only preview for one saved map. + + Returns after publishing. Subscribe with ``add_update_listener`` to + receive previews separately from the live map. ``detail_map_id`` is + read from each pushed packet; it is not a request correlation ID. + """ + if map_id is None: + map_id = self.current_map_id + if map_id is None: + raise RoborockException("Cannot request Q10 saved-map detail before the map list is available") + if map_id not in {map_info.id for map_info in self.map_list}: + raise RoborockException(f"Unknown Q10 saved-map ID: {map_id}") + async with self._detail_lock: + await self._command.send( + B01_Q10_DP.COMMON, + {str(B01_Q10_DP.MULTI_MAP.code): {"op": "select", "id": map_id}}, + ) + 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) @@ -56,3 +110,21 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: if not isinstance(response, dict) or response.get("op") != "list" or response.get("result") != 1: return super().update_from_dps(decoded_dps) + + def update_from_map_packet(self, packet: Q10MapPacket) -> None: + """Store and render a pushed saved-map detail packet.""" + if packet.kind is not Q10MapPacketKind.SAVED_MAP_DETAIL: + raise ValueError(f"Expected a Q10 saved-map detail packet, got {packet.kind.value}") + self.detail_map_id = str(packet.map_id) + self.detail_packet = packet + try: + self.detail_image_content = render_q10_map( + packet, + None, + Q10MapOverlays(), + config=self._map_parser_config, + ) + except RoborockException: + _LOGGER.debug("Failed to render Q10 saved-map detail", exc_info=True) + self.detail_image_content = None + self._notify_update() diff --git a/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr b/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr index daac7f55b..636079707 100644 --- a/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr +++ b/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr @@ -4,6 +4,12 @@ 'child_lock': dict({ 'childLock': False, }), + 'clean_history': dict({ + 'detailPath': list([ + ]), + 'records': list([ + ]), + }), 'consumable': dict({ 'filterLife': 0, 'mainBrushLife': 0, @@ -384,6 +390,12 @@ 'child_lock': dict({ 'childLock': False, }), + 'clean_history': dict({ + 'detailPath': list([ + ]), + 'records': list([ + ]), + }), 'consumable': dict({ 'filterLife': 0, 'mainBrushLife': 0, @@ -473,6 +485,12 @@ dict({ 'child_lock': dict({ }), + 'clean_history': dict({ + 'detailPath': list([ + ]), + 'records': list([ + ]), + }), 'consumable': dict({ }), 'do_not_disturb': dict({ diff --git a/tests/devices/traits/b01/q10/conftest.py b/tests/devices/traits/b01/q10/conftest.py index 85cf0a028..bf631dfa2 100644 --- a/tests/devices/traits/b01/q10/conftest.py +++ b/tests/devices/traits/b01/q10/conftest.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import AsyncGenerator from typing import Any @@ -24,6 +25,9 @@ class FakeB01Q10Channel(B01Q10Channel): def __init__(self) -> None: self.published_commands: list[tuple[Any, Any]] = [] self.messages_to_stream: list[Q10Message] = [] + self.send_started = asyncio.Event() + self.send_gate: asyncio.Event | None = None + self.send_error: Exception | None = None @property def is_connected(self) -> bool: @@ -41,6 +45,11 @@ async def subscribe_stream(self) -> AsyncGenerator[Q10Message, None]: yield msg async def send_command(self, command: Any, params: Any = None) -> None: + self.send_started.set() + if self.send_gate is not None: + await self.send_gate.wait() + if self.send_error is not None: + raise self.send_error self.published_commands.append((command, params)) diff --git a/tests/devices/traits/b01/q10/test_clean_history.py b/tests/devices/traits/b01/q10/test_clean_history.py index 10fce2b64..b6b370235 100644 --- a/tests/devices/traits/b01/q10/test_clean_history.py +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -1,3 +1,7 @@ +import asyncio +import json +from pathlib import Path + import pytest from roborock.data.b01_q10.b01_q10_code_mappings import ( @@ -9,7 +13,14 @@ ) from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord from roborock.devices.traits.b01.q10 import Q10PropertiesApi -from roborock.devices.traits.b01.q10.clean_history import CleanHistoryTrait, CleanRecordConverter +from roborock.devices.traits.b01.q10.clean_history import CleanHistory, CleanHistoryTrait, CleanRecordConverter +from roborock.exceptions import RoborockException +from roborock.map.b01_q10_map_parser import ( + Q10CleanRecordDetail, + Q10HistoricalTracePacket, + Q10Point, + parse_clean_record_detail, +) from .conftest import FakeB01Q10Channel @@ -175,3 +186,90 @@ async def test_refresh_sends_op_list(q10_api: Q10PropertiesApi, fake_channel: Fa B01_Q10_DP.COMMON, {"52": {"op": "list"}}, ) + + +async def test_refresh_detail_sends_op_select( + clean_history: CleanHistoryTrait, + fake_channel: FakeB01Q10Channel, +) -> None: + record = CleanRecordConverter.parse_record(RECORD_A) + assert record is not None + await clean_history.refresh_detail(record) + assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})] + + +def _detail() -> Q10CleanRecordDetail: + fixture = Path("tests/map/testdata/b01_q10_map.bin").read_bytes() + return parse_clean_record_detail(b"\x03\x01" + fixture[2:]) + + +async def test_refresh_detail_rejects_record_without_map(clean_history: CleanHistoryTrait) -> None: + record = CleanRecordConverter.parse_record("x_1781226271_1_1_0_0_0_0_2_1_1_0") + assert record is not None + with pytest.raises(RoborockException, match="no saved map detail"): + await clean_history.refresh_detail(record) + + +async def test_refresh_detail_after_cancelled_publication( + clean_history: CleanHistoryTrait, + fake_channel: FakeB01Q10Channel, +) -> None: + record = CleanRecordConverter.parse_record(RECORD_A) + assert record is not None + fake_channel.send_gate = asyncio.Event() + request = asyncio.create_task(clean_history.refresh_detail(record)) + await fake_channel.send_started.wait() + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + assert fake_channel.published_commands == [] + + fake_channel.send_gate = None + await clean_history.refresh_detail(record) + assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})] + + +async def test_refresh_detail_after_publication_error( + clean_history: CleanHistoryTrait, + fake_channel: FakeB01Q10Channel, +) -> None: + record = CleanRecordConverter.parse_record(RECORD_A) + assert record is not None + fake_channel.send_error = RoborockException("publish failed") + with pytest.raises(RoborockException, match="publish failed"): + await clean_history.refresh_detail(record) + assert fake_channel.published_commands == [] + + fake_channel.send_error = None + await clean_history.refresh_detail(record) + assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})] + + +def test_received_detail_is_serializable_and_path_list_is_defensive(q10_api: Q10PropertiesApi) -> None: + history = q10_api.clean_history + history.update_from_dps(_list_push(RECORD_A)) + detail = _detail() + detail.trace = Q10HistoricalTracePacket(points=[Q10Point(x=1, y=2)], heading=0) + history.update_from_detail(detail) + history.detail_path.clear() + assert history.detail_path == [Q10Point(x=1, y=2)] + assert isinstance(history, CleanHistory) + data = q10_api.as_dict()["clean_history"] + assert data["records"][0]["recordId"] == "abc123def456" + assert data["detailPath"] == [{"x": 1, "y": 2}] + assert "detailImageContent" not in data + assert "detail" not in data + json.dumps(data) + assert history.as_dict(exclude={"records", "detail_path"}) == {} + + +def test_unsolicited_and_repeated_details_notify_listeners(clean_history: CleanHistoryTrait) -> None: + updates = [] + unsubscribe = clean_history.add_update_listener(lambda: updates.append(clean_history.detail)) + first, second = _detail(), _detail() + clean_history.update_from_detail(first) + clean_history.update_from_detail(second) + assert updates == [first, second] + unsubscribe() + clean_history.update_from_detail(first) + assert updates == [first, second] diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 060553af5..a57841a40 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -1,30 +1,35 @@ """Tests for the Q10 B01 map content trait. -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``. +Map list data and current-map content have independent refresh schedules. The +device sends map data later in a ``MAP_RESPONSE`` packet. These tests cover +that state management; rendering is tested separately. """ import asyncio import base64 +import io +import json from collections.abc import AsyncGenerator, Generator +from dataclasses import replace from pathlib import Path -from typing import cast from unittest.mock import Mock, patch import pytest +from PIL import Image 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 ( + B01Q10MapParserConfig, + Q10CleanRecordDetail, + Q10MapPacketKind, Q10Point, Q10TracePacket, + parse_clean_record_detail, parse_map_packet, parse_trace_packet, ) @@ -38,19 +43,9 @@ def _map_trait(map_dps: MapDpsTrait | None = None) -> MapContentTrait: - """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) + """Create map content for tests that do not perform I/O.""" + command = CommandTrait(FakeB01Q10Channel()) + return MapContentTrait(map_dps or MapDpsTrait(), command) def _zone_blob() -> str: @@ -83,6 +78,15 @@ def test_update_from_map_packet_populates_image_and_rooms() -> None: assert len(updates) == 1 +def test_live_map_trait_rejects_archived_packet() -> None: + """Direct callers cannot bypass API routing and replace live map state.""" + payload = FIXTURE.read_bytes() + archived = parse_clean_record_detail(b"\x03\x01" + payload[2:]) + + with pytest.raises(ValueError, match="Expected a current Q10 map packet"): + _map_trait().update_from_map_packet(archived.map) + + def test_update_from_trace_packet_populates_path_and_position() -> None: """A pushed 02 01 trace packet populates the path, position and heading.""" trace = parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes()) @@ -108,81 +112,34 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None: # --- CLI push waiting -------------------------------------------------------- -class _FakeQ10Properties: - def __init__(self) -> None: - 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_map() -> None: - self.refresh_count += 1 - - self.map.refresh = refresh_map # type: ignore[method-assign] - - -class _FakeQ10PropertiesWithTrace(_FakeQ10Properties): - 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] - - -async def test_await_q10_map_push_waits_for_fresh_update() -> None: - """A cached trace alone is not treated as a successful new map push.""" - properties = _FakeQ10Properties() - 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, - ) - - assert got_trace is False - assert properties.refresh_count == 1 - - -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, - ) +async def test_await_q10_map_push_waits_for_update(q10_api: Q10PropertiesApi, mock_channel: FakeB01Q10Channel) -> None: + """Cached state alone does not complete a one-shot CLI wait.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1, 2)])) + assert not await _await_q10_map_push(q10_api, lambda: bool(q10_api.map.path), timeout=0.01) + assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {})] - assert got_trace is True - assert len(properties.map.path) == 14 +async def test_await_q10_map_push_returns_after_listener_update( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + message_queue: asyncio.Queue[Q10Message], +) -> None: + request = asyncio.create_task(_await_q10_map_push(q10_api, lambda: bool(q10_api.map.path), timeout=1)) + await mock_channel.send_started.wait() + message_queue.put_nowait(parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())) + assert await request + assert len(q10_api.map.path) == 14 -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())) - got_map = await _await_q10_map_push( - cast(Q10PropertiesApi, properties), - lambda: properties.map.image_content is not None, +async def test_await_q10_map_push_can_fall_back_to_cached_map_on_timeout(q10_api: Q10PropertiesApi) -> None: + q10_api.map.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes())) + assert await _await_q10_map_push( + q10_api, + lambda: q10_api.map.image_content is not None, timeout=0.01, allow_cached_on_timeout=True, ) - assert got_map is True - assert properties.refresh_count == 1 - # --- Integration through the Q10PropertiesApi subscribe loop ----------------- @@ -231,6 +188,82 @@ async def test_subscribe_loop_routes_map_push( assert {room.id: room.name for room in q10_api.map.rooms} == {2: "Living Room", 3: "Bedroom"} +async def test_archived_map_pushes_cannot_overwrite_live_map( + q10_api: Q10PropertiesApi, + message_queue: asyncio.Queue[Q10Message], +) -> None: + """03/04 detail packets are isolated from the current live-map trait.""" + current_bytes = FIXTURE.read_bytes() + current = parse_map_packet(current_bytes) + trace = parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes()) + clean_record = parse_clean_record_detail(b"\x03\x01" + current_bytes[2:]) + saved_map = parse_map_packet(b"\x04\x01" + current_bytes[2:]) + + message_queue.put_nowait(current) + message_queue.put_nowait(trace) + await _wait_for(lambda: q10_api.map.image_content is not None and bool(q10_api.map.path)) + live_image = q10_api.map.image_content + live_rooms = list(q10_api.map.rooms) + live_path = list(q10_api.map.path) + live_position = q10_api.map.robot_position + live_heading = q10_api.map.robot_heading + live_updates: list[None] = [] + clean_record_updates: list[None] = [] + saved_map_updates: list[None] = [] + q10_api.map.add_update_listener(lambda: live_updates.append(None)) + q10_api.clean_history.add_update_listener(lambda: clean_record_updates.append(None)) + q10_api.maps.add_update_listener(lambda: saved_map_updates.append(None)) + + message_queue.put_nowait(clean_record) + message_queue.put_nowait(saved_map) + await _wait_for(lambda: q10_api.maps.detail_packet is not None) + + assert q10_api.map.image_content == live_image + assert q10_api.map.rooms == live_rooms + assert q10_api.map.path == live_path + assert q10_api.map.robot_position == live_position + assert q10_api.map.robot_heading == live_heading + assert live_updates == [] + assert q10_api.clean_history.detail_packet is not None + assert q10_api.clean_history.detail_packet.kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL + assert q10_api.clean_history.detail_image_content is not None + assert q10_api.maps.detail_packet is not None + assert q10_api.maps.detail_packet.kind is Q10MapPacketKind.SAVED_MAP_DETAIL + assert q10_api.maps.detail_image_content is not None + assert clean_record_updates == [None] + assert saved_map_updates == [None] + + +def test_archive_owners_reject_wrong_packet_kinds(q10_api: Q10PropertiesApi) -> None: + """Semantic archive traits reject packets owned by another map stream.""" + current = parse_map_packet(FIXTURE.read_bytes()) + + with pytest.raises(ValueError, match="clean-record detail"): + q10_api.clean_history.update_from_detail(Q10CleanRecordDetail(map=current)) + with pytest.raises(ValueError, match="saved-map detail"): + q10_api.maps.update_from_map_packet(current) + + +@pytest.mark.parametrize("scale", [1, 2, 4]) +async def test_all_q10_map_views_render_at_requested_scale(scale: int, fake_channel: FakeB01Q10Channel) -> None: + api = create(fake_channel, map_parser_config=B01Q10MapParserConfig(map_scale=scale)) + payload = FIXTURE.read_bytes() + fake_channel.messages_to_stream = [ + parse_map_packet(payload), + parse_clean_record_detail(b"\x03\x01" + payload[2:]), + parse_map_packet(b"\x04\x01" + payload[2:]), + ] + await api.start() + try: + await _wait_for(lambda: api.maps.detail_image_content is not None) + for content in [api.map.image_content, api.clean_history.detail_image_content, api.maps.detail_image_content]: + assert content is not None + with Image.open(io.BytesIO(content)) as image: + assert image.size == (8 * scale, 6 * scale) + finally: + await api.close() + + async def test_subscribe_loop_routes_trace_push( q10_api: Q10PropertiesApi, message_queue: asyncio.Queue[Q10Message], @@ -315,22 +348,66 @@ async def test_map_content_refresh_does_not_require_stored_map_id( assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {})] -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.""" +async def test_map_content_refresh_requests_are_not_rate_limited( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, +) -> None: + await q10_api.map.refresh() + await q10_api.map.refresh() + assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {}), (B01_Q10_DP.REQUEST_DPS, {})] + + +@pytest.mark.parametrize("selected,expected", [(None, "12345"), ("67890", "67890")]) +async def test_saved_map_detail_refresh_publishes_selection( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + selected: str | None, + expected: str, +) -> None: q10_api.maps.update_from_dps( { B01_Q10_DP.MULTI_MAP: { - "data": [{"id": "12345"}], + "data": [{"id": "12345"}, {"id": "67890"}], "op": "list", "result": 1, } } ) - with patch.object(q10_api.command, "send") as send: - await q10_api.map.refresh() - await q10_api.map.refresh() + await q10_api.maps.refresh_detail(selected) + assert mock_channel.published_commands == [(B01_Q10_DP.COMMON, {"61": {"op": "select", "id": expected}})] + assert q10_api.maps.detail_packet is None + + +async def test_saved_map_detail_rejects_unknown_id(q10_api: Q10PropertiesApi, mock_channel: FakeB01Q10Channel) -> None: + q10_api.maps.update_from_dps({B01_Q10_DP.MULTI_MAP: {"data": [{"id": "12345"}], "op": "list", "result": 1}}) + with pytest.raises(RoborockException, match="Unknown Q10 saved-map ID"): + await q10_api.maps.refresh_detail("67890") + assert mock_channel.published_commands == [] + + +async def test_saved_map_details_publish_packet_map_id_and_notify( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + message_queue: asyncio.Queue[Q10Message], +) -> None: + updates = [] + q10_api.maps.add_update_listener(lambda: updates.append(q10_api.maps.detail_map_id)) + packet = parse_map_packet(b"\x04\x01" + FIXTURE.read_bytes()[2:]) + message_queue.put_nowait(packet) + await _wait_for(lambda: bool(updates)) + assert updates == [str(packet.map_id)] + assert q10_api.maps.detail_packet is packet + assert q10_api.maps.detail_image_content is not None + message_queue.put_nowait(replace(packet, map_id=999)) + await _wait_for(lambda: len(updates) == 2) + assert updates == [str(packet.map_id), "999"] + assert q10_api.map.image_content is None + - assert send.await_count == 2 +async def test_saved_map_detail_refresh_requires_stored_map_id(q10_api: Q10PropertiesApi) -> None: + """Detail cannot be requested until the saved-map list supplies an ID.""" + with pytest.raises(RoborockException, match="map list is available"): + await q10_api.maps.refresh_detail() def test_map_get_ack_does_not_replace_saved_map_list(q10_api: Q10PropertiesApi) -> None: @@ -490,8 +567,8 @@ async def test_charging_status_renders_robot_at_dock(render_map: Mock) -> None: assert render_map.call_args.kwargs["robot_at_dock"] is True -def test_docked_state_hides_trace_only_from_rendering(render_map: Mock) -> None: - """A docked render omits the valid trace without deleting source data.""" +def test_docked_state_clears_stale_live_trace(render_map: Mock) -> None: + """A docked update removes a completed path from public live state.""" map_dps = MapDpsTrait() trait = _map_trait(map_dps) packet = parse_map_packet(FIXTURE.read_bytes()) @@ -504,13 +581,13 @@ def test_docked_state_hides_trace_only_from_rendering(render_map: Mock) -> None: map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code}) - assert trait.path == trace.points + assert trait.path == [] assert render_map.call_args.args[1] is None assert render_map.call_args.kwargs["robot_at_dock"] is True -def test_late_trace_is_retained_but_hidden_while_docked(render_map: Mock) -> None: - """A late trace stays available but is not part of a docked render.""" +def test_late_trace_is_ignored_while_docked(render_map: Mock) -> None: + """A delayed trace cannot repopulate public state while docked.""" map_dps = MapDpsTrait() map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code}) trait = _map_trait(map_dps) @@ -520,7 +597,7 @@ def test_late_trace_is_retained_but_hidden_while_docked(render_map: Mock) -> Non trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes())) trait.update_from_trace_packet(trace) - assert trait.path == trace.points + assert trait.path == [] assert render_map.call_args.args[1] is None @@ -586,3 +663,95 @@ def test_map_content_trait_as_dict_camelizes_child_keys() -> None: } assert data["path"] == [{"x": 100, "y": 200}, {"x": 150, "y": 250}] assert data["robotPosition"] == {"x": 25875, "y": 26125} + + +@pytest.mark.parametrize("failure", ["cancel", "send_error"]) +async def test_saved_map_publication_recovers( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, + failure: str, +) -> None: + q10_api.maps.update_from_dps({B01_Q10_DP.MULTI_MAP: {"data": [{"id": "12345"}], "op": "list", "result": 1}}) + if failure == "cancel": + mock_channel.send_gate = asyncio.Event() + else: + mock_channel.send_error = RoborockException("publish failed") + request = asyncio.create_task(q10_api.maps.refresh_detail("12345")) + await mock_channel.send_started.wait() + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError if failure == "cancel" else RoborockException): + await request + mock_channel.send_gate = None + mock_channel.send_error = None + await q10_api.maps.refresh_detail("12345") + await q10_api.maps.refresh_detail("12345") + assert len(mock_channel.published_commands) == 2 + + +async def test_saved_map_selections_serialize_publication( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, +) -> None: + q10_api.maps.update_from_dps( + { + B01_Q10_DP.MULTI_MAP: { + "data": [{"id": "12345"}, {"id": "67890"}], + "op": "list", + "result": 1, + } + } + ) + gate = asyncio.Event() + mock_channel.send_gate = gate + first = asyncio.create_task(q10_api.maps.refresh_detail("12345")) + await mock_channel.send_started.wait() + mock_channel.send_started.clear() + second = asyncio.create_task(q10_api.maps.refresh_detail("67890")) + await asyncio.sleep(0) + assert not mock_channel.send_started.is_set() + gate.set() + await asyncio.gather(first, second) + assert [params["61"]["id"] for _, params in mock_channel.published_commands] == ["12345", "67890"] + + +def test_saved_map_diagnostics_exclude_binary_content(q10_api: Q10PropertiesApi) -> None: + packet = parse_map_packet(b"\x04\x01" + FIXTURE.read_bytes()[2:]) + q10_api.maps.update_from_map_packet(packet) + data = q10_api.as_dict()["maps"] + assert data == {"detailMapId": str(packet.map_id)} + assert q10_api.maps.detail_image_content is not None + assert q10_api.maps.as_dict(exclude={"detail_map_id"}) == {} + json.dumps(data) + + +async def test_cli_listener_is_removed_after_timeout(q10_api: Q10PropertiesApi) -> None: + calls = [] + + def predicate() -> bool: + calls.append(True) + return False + + assert not await _await_q10_map_push(q10_api, predicate, timeout=0.01) + calls.clear() + q10_api.map.update_from_trace_packet(Q10TracePacket()) + assert calls == [] + + +async def test_cli_listener_is_removed_after_cancellation( + q10_api: Q10PropertiesApi, + mock_channel: FakeB01Q10Channel, +) -> None: + calls = [] + + def predicate() -> bool: + calls.append(True) + return False + + request = asyncio.create_task(_await_q10_map_push(q10_api, predicate, timeout=1)) + await mock_channel.send_started.wait() + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + q10_api.map.update_from_trace_packet(Q10TracePacket()) + assert calls == [] diff --git a/tests/protocols/test_b01_q10_protocol.py b/tests/protocols/test_b01_q10_protocol.py index 16fe9a108..ebdd3d8e7 100644 --- a/tests/protocols/test_b01_q10_protocol.py +++ b/tests/protocols/test_b01_q10_protocol.py @@ -139,25 +139,23 @@ def test_decode_message_map_packet() -> None: assert {room.id: room.name for room in decoded.rooms} == {2: "Living Room", 3: "Bedroom"} -@pytest.mark.parametrize( - ("marker", "kind"), - [ - (b"\x03\x01", Q10MapPacketKind.CLEAN_RECORD_DETAIL), - (b"\x04\x01", Q10MapPacketKind.SAVED_MAP_DETAIL), - ], -) -def test_decode_message_archived_map_packet(marker: bytes, kind: Q10MapPacketKind) -> None: - """The decoder recognizes both archived map-detail markers.""" +def test_decode_message_clean_record_detail() -> None: + """The clean-record marker decodes into a composed map and optional trace.""" + fixture = MAP_FIXTURE.read_bytes() + decoded = decode_message(_message(b"\x03\x01" + fixture[2:], RoborockMessageProtocol.MAP_RESPONSE)) + + assert isinstance(decoded, Q10CleanRecordDetail) + assert decoded.map.kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL + assert decoded.trace is None + + +def test_decode_message_saved_map_detail() -> None: + """The saved-map marker decodes into a map packet.""" fixture = MAP_FIXTURE.read_bytes() - decoded = decode_message(_message(marker + fixture[2:], RoborockMessageProtocol.MAP_RESPONSE)) - - if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL: - assert isinstance(decoded, Q10CleanRecordDetail) - assert decoded.map.kind is kind - assert decoded.trace is None - else: - assert isinstance(decoded, Q10MapPacket) - assert decoded.kind is kind + decoded = decode_message(_message(b"\x04\x01" + fixture[2:], RoborockMessageProtocol.MAP_RESPONSE)) + + assert isinstance(decoded, Q10MapPacket) + assert decoded.kind is Q10MapPacketKind.SAVED_MAP_DETAIL def test_decode_message_trace_packet() -> None: