From 9a94f6c5fc971b12be309eb1bfbdb0f2f61700e3 Mon Sep 17 00:00:00 2001 From: Harry Coureau Date: Sat, 7 Nov 2026 19:02:58 +0000 Subject: [PATCH 1/2] feat: expose Q10 map archives --- roborock/data/b01_q10/b01_q10_containers.py | 4 +- roborock/devices/device_manager.py | 12 +- roborock/devices/traits/b01/q10/__init__.py | 46 ++- .../devices/traits/b01/q10/clean_history.py | 100 ++++++- roborock/devices/traits/b01/q10/map.py | 11 +- roborock/devices/traits/b01/q10/maps.py | 73 ++++- roborock/map/b01_q10_map_parser.py | 4 + roborock/map/b01_q10_render.py | 8 +- .../b01/q10/__snapshots__/test_status.ambr | 18 ++ .../traits/b01/q10/test_clean_history.py | 64 ++++- tests/devices/traits/b01/q10/test_map.py | 267 +++++++++++++++--- 11 files changed, 536 insertions(+), 71 deletions(-) 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/device_manager.py b/roborock/devices/device_manager.py index 156e3ac56..f7d3a150a 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,16 @@ 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, drawables=map_parser_config.drawables + ) + 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..6ccac58c9 100644 --- a/roborock/devices/traits/b01/q10/clean_history.py +++ b/roborock/devices/traits/b01/q10/clean_history.py @@ -14,6 +14,7 @@ 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 +23,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 +119,57 @@ def parse_record(raw: Any | None) -> Q10CleanRecord | None: return None -class CleanHistoryTrait(UpdatableTrait): - """Access to the Q10 clean-record history (``dpCleanRecord``, DP 52). +@dataclass +class CleanHistory(RoborockBase): + """Received clean-history data, independent of request and transport state.""" - 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. - """ + 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.""" - 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.""" + detail_path: list[Q10Point] = field(default_factory=list) + """Historical path points from the latest received archive, included in diagnostics.""" @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 + + def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Serialize history metadata and path without map grids or PNG bytes.""" + return super().as_dict(exclude=(exclude or set()) | {"detail", "detail_image_content"}) + + +class CleanHistoryTrait(CleanHistory, UpdatableTrait): + """Show past cleaning runs with their saved map image and historical path. + + Subscribe with ``add_update_listener`` to receive requested or unsolicited + archives. Archive pushes contain no record ID, so the latest detail cannot + be attributed to a particular selection. + """ + + _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 + async def refresh(self) -> None: """Request the clean-record list from the device. @@ -134,13 +177,24 @@ 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: + """Request the saved map and path for a past cleaning run. + + Returns after sending; listeners receive the archive separately. The + push contains no record ID to correlate it with this selection. + """ + if not record.raw or not record.map_len: + raise RoborockException("The Q10 clean record has no saved map detail") + await self._command.send( + B01_Q10_DP.COMMON, + {str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "select", "id": record.raw}}, + ) + 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 +205,24 @@ 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 + self.detail_path = list(detail.trace.points) if detail.trace else [] + 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..4c73d781a 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,6 +147,8 @@ 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() diff --git a/roborock/devices/traits/b01/q10/maps.py b/roborock/devices/traits/b01/q10/maps.py index d09457764..8a7292bad 100644 --- a/roborock/devices/traits/b01/q10/maps.py +++ b/roborock/devices/traits/b01/q10/maps.py @@ -6,8 +6,11 @@ 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 +24,17 @@ 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.""" + return super().as_dict(exclude=(exclude or set()) | {"detail_packet", "detail_image_content"}) + @property def current_map_id(self) -> str | None: """Return the first saved-map ID for a content request, if available.""" @@ -28,18 +42,35 @@ 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.""" + """Browse saved maps and display their previews independently of the live map. + + Preview pushes identify their map, but carry no request correlation ID. + Consumers receive them through ``add_update_listener``. + """ _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 async def refresh(self) -> None: """Request a new saved-map list from the device.""" @@ -48,6 +79,24 @@ 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}") + 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 +105,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/roborock/map/b01_q10_map_parser.py b/roborock/map/b01_q10_map_parser.py index 4f35f188f..7a952caf0 100644 --- a/roborock/map/b01_q10_map_parser.py +++ b/roborock/map/b01_q10_map_parser.py @@ -25,6 +25,8 @@ import struct from dataclasses import dataclass, field, replace +from vacuum_map_parser_base.config.drawable import Drawable + from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint from roborock.data.code_mappings import RoborockEnum from roborock.data.containers import RoborockBase @@ -766,3 +768,5 @@ class B01Q10MapParserConfig: map_scale: int = 4 """Scale factor for the rendered map image.""" + drawables: list[Drawable] | None = None + """Enabled overlays, or ``None`` for the Q10 defaults.""" diff --git a/roborock/map/b01_q10_render.py b/roborock/map/b01_q10_render.py index 1ee9ec006..6e9583d39 100644 --- a/roborock/map/b01_q10_render.py +++ b/roborock/map/b01_q10_render.py @@ -332,10 +332,12 @@ def _draw_map_content( """Draw Q10 content with the shared V1 image generator.""" if map_data.image is None: raise RoborockException("Failed to render Q10 map image") - generator = _create_image_generator( - MapParserConfig(map_scale=config.map_scale), - drawables=_Q10_DRAWABLES, + drawables = ( + _Q10_DRAWABLES + if config.drawables is None + else [drawable for drawable in config.drawables if drawable in _Q10_DRAWABLE_TYPES] ) + generator = _create_image_generator(MapParserConfig(map_scale=config.map_scale), drawables=drawables) generator.draw_map(map_data) buffer = io.BytesIO() map_data.image.data.save(buffer, format="PNG") 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/test_clean_history.py b/tests/devices/traits/b01/q10/test_clean_history.py index 10fce2b64..2b441b555 100644 --- a/tests/devices/traits/b01/q10/test_clean_history.py +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import pytest from roborock.data.b01_q10.b01_q10_code_mappings import ( @@ -9,7 +12,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 +185,55 @@ 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) + + +def test_received_detail_is_serializable_and_path_is_independent(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) + detail.trace.points.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..ad01079c8 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -1,30 +1,39 @@ """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. +Saved-map previews use a stored map ID, while current content uses a read-only +status request. The device sends data later in a ``MAP_RESPONSE`` packet. +These tests cover state management. The render details are tested in ``tests/map/test_b01_q10_render.py``. """ 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 vacuum_map_parser_base.config.drawable import Drawable 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, + Q10HeaderCalibration, + Q10MapPacketKind, Q10Point, Q10TracePacket, + parse_clean_record_detail, parse_map_packet, parse_trace_packet, ) @@ -38,19 +47,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.""" + """Create map content 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) + return MapContentTrait(map_dps or MapDpsTrait(), command) def _zone_blob() -> str: @@ -111,17 +110,7 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None: 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.map = MapContentTrait(MapDpsTrait(), command) self.refresh_count = 0 async def refresh_map() -> None: @@ -315,8 +304,10 @@ 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: + """The caller controls content cadence; each refresh sends a read-only request.""" q10_api.maps.update_from_dps( { B01_Q10_DP.MULTI_MAP: { @@ -326,11 +317,10 @@ async def test_map_content_refresh_requests_are_not_rate_limited(q10_api: Q10Pro } } ) - with patch.object(q10_api.command, "send") as send: - await q10_api.map.refresh() - await q10_api.map.refresh() + await q10_api.map.refresh() + await q10_api.map.refresh() - assert send.await_count == 2 + assert mock_channel.published_commands == [(B01_Q10_DP.REQUEST_DPS, {}), (B01_Q10_DP.REQUEST_DPS, {})] def test_map_get_ack_does_not_replace_saved_map_list(q10_api: Q10PropertiesApi) -> None: @@ -586,3 +576,212 @@ 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} + + +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() + + +@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"}, {"id": "67890"}], + "op": "list", + "result": 1, + } + } + ) + 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 + + +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_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 == [] + + +def test_live_map_trait_rejects_archived_packet() -> None: + trait = _map_trait() + packet = parse_map_packet(b"\x04\x01" + FIXTURE.read_bytes()[2:]) + with pytest.raises(ValueError, match="current Q10 map packet"): + trait.update_from_map_packet(packet) + assert trait.image_content is None + + +async def test_cli_listener_is_removed_after_cancellation(q10_api: Q10PropertiesApi) -> None: + calls = [] + + def predicate() -> bool: + calls.append(True) + return False + + request = asyncio.create_task(_await_q10_map_push(q10_api, predicate, timeout=1)) + await asyncio.sleep(0) + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + q10_api.map.update_from_trace_packet(Q10TracePacket()) + assert calls == [] + + +@pytest.mark.parametrize("drawables,expected_base", [([], True), ([Drawable.CHARGER], False)]) +async def test_all_q10_map_views_honor_drawable_selection( + drawables: list[Drawable], expected_base: bool, fake_channel: FakeB01Q10Channel +) -> None: + api = create(fake_channel, map_parser_config=B01Q10MapParserConfig(drawables=drawables)) + base_packet = parse_map_packet(FIXTURE.read_bytes()) + base = create(FakeB01Q10Channel()) + base.map.update_from_map_packet(base_packet) + assert base.map.image_content is not None + with Image.open(io.BytesIO(base.map.image_content)) as image: + base_pixels = image.tobytes() + packet = replace( + base_packet, + header_calibration=Q10HeaderCalibration( + origin_x=0, origin_y=50, resolution=5, charger_x=30, charger_y=30, charger_phi=90 + ), + ) + fake_channel.messages_to_stream = [ + packet, + Q10CleanRecordDetail(map=replace(packet, kind=Q10MapPacketKind.CLEAN_RECORD_DETAIL)), + replace(packet, kind=Q10MapPacketKind.SAVED_MAP_DETAIL), + ] + 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.tobytes() == base_pixels) is expected_base + finally: + await api.close() From ada5119a8941bad86692709153ec292d2bb6934e Mon Sep 17 00:00:00 2001 From: Harry Coureau Date: Sun, 8 Nov 2026 19:00:18 +0000 Subject: [PATCH 2/2] fix: keep Q10 archive traits lightweight --- roborock/devices/traits/b01/q10/__init__.py | 11 ++--- .../devices/traits/b01/q10/clean_history.py | 26 +++-------- roborock/devices/traits/b01/q10/maps.py | 31 +++---------- .../traits/b01/q10/test_clean_history.py | 15 +++++++ tests/devices/traits/b01/q10/test_map.py | 44 +++++++++---------- 5 files changed, 52 insertions(+), 75 deletions(-) diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 31810de93..194568ab9 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -102,7 +102,7 @@ def __init__( self, channel: B01Q10Channel, *, - map_parser_config: B01Q10MapParserConfig, + map_parser_config: B01Q10MapParserConfig | None = None, ) -> None: """Initialize the B01Props API.""" self._channel = channel @@ -117,16 +117,13 @@ def __init__( self.network_info = NetworkInfoTrait() self.consumable = ConsumableTrait() self._map_dps = MapDpsTrait() - self.maps = MapsTrait(self.command, map_parser_config=map_parser_config) + self.maps = MapsTrait(self.command) 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.clean_history = CleanHistoryTrait(self.command) self.vacuum = VacuumTrait(self.command, self.status, self.map) # Read-model traits updated from the device's DPS push stream. self._updatable_traits = [ @@ -210,5 +207,5 @@ def create( """Create traits for B01 devices.""" return Q10PropertiesApi( channel, - map_parser_config=map_parser_config or B01Q10MapParserConfig(), + map_parser_config=map_parser_config, ) diff --git a/roborock/devices/traits/b01/q10/clean_history.py b/roborock/devices/traits/b01/q10/clean_history.py index 6ccac58c9..3b1549b6a 100644 --- a/roborock/devices/traits/b01/q10/clean_history.py +++ b/roborock/devices/traits/b01/q10/clean_history.py @@ -25,14 +25,12 @@ 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 @@ -127,9 +125,6 @@ class CleanHistory(RoborockBase): """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.""" - detail_path: list[Q10Point] = field(default_factory=list) """Historical path points from the latest received archive, included in diagnostics.""" @@ -149,12 +144,12 @@ def detail_trace(self) -> Q10HistoricalTracePacket | None: return self.detail.trace if self.detail else None def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: - """Serialize history metadata and path without map grids or PNG bytes.""" - return super().as_dict(exclude=(exclude or set()) | {"detail", "detail_image_content"}) + """Serialize history metadata and path without binary map packets and grids.""" + return super().as_dict(exclude=(exclude or set()) | {"detail"}) class CleanHistoryTrait(CleanHistory, UpdatableTrait): - """Show past cleaning runs with their saved map image and historical path. + """Browse past cleaning runs and retrieve their parsed maps and historical paths. Subscribe with ``add_update_listener`` to receive requested or unsolicited archives. Archive pushes contain no record ID, so the latest detail cannot @@ -163,12 +158,11 @@ class CleanHistoryTrait(CleanHistory, UpdatableTrait): _command: CommandTrait - def __init__(self, command: CommandTrait, *, map_parser_config: B01Q10MapParserConfig) -> None: + def __init__(self, command: CommandTrait) -> None: CleanHistory.__init__(self) UpdatableTrait.__init__(self, command, _LOGGER) self._command = command self._converter = CleanRecordConverter() - self._map_parser_config = map_parser_config async def refresh(self) -> None: """Request the clean-record list from the device. @@ -206,21 +200,11 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: self._apply(push) def update_from_detail(self, detail: Q10CleanRecordDetail) -> None: - """Store and render a pushed clean-record detail map.""" + """Store a pushed clean-record map and path and notify listeners.""" 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 self.detail_path = list(detail.trace.points) if detail.trace else [] - 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: diff --git a/roborock/devices/traits/b01/q10/maps.py b/roborock/devices/traits/b01/q10/maps.py index 51bcc46ee..47c0fa2a0 100644 --- a/roborock/devices/traits/b01/q10/maps.py +++ b/roborock/devices/traits/b01/q10/maps.py @@ -9,8 +9,7 @@ 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 roborock.map.b01_q10_map_parser import Q10MapPacket, Q10MapPacketKind from .command import CommandTrait from .common import UpdatableTrait @@ -28,12 +27,10 @@ class Maps(RoborockBase): """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.""" - return super().as_dict(exclude=(exclude or set()) | {"detail_packet", "detail_image_content"}) + """Serialize metadata without binary map packets and grids.""" + return super().as_dict(exclude=(exclude or set()) | {"detail_packet"}) @property def current_map_id(self) -> str | None: @@ -51,7 +48,7 @@ def map_list(self) -> list[Q10MapInfo]: class MapsTrait(Maps, UpdatableTrait): - """Browse saved maps and display their previews independently of the live map. + """Browse saved maps and retrieve their parsed previews independently of the live map. Preview pushes identify their map, but carry no request correlation ID. Consumers receive them through ``add_update_listener``. @@ -60,17 +57,11 @@ class MapsTrait(Maps, UpdatableTrait): _CONVERTER = DpsDataConverter.from_dataclass(Maps) _command: CommandTrait - def __init__( - self, - command: CommandTrait, - *, - map_parser_config: B01Q10MapParserConfig, - ) -> None: + def __init__(self, command: CommandTrait) -> 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 async def refresh(self) -> None: """Request a new saved-map list from the device.""" @@ -121,19 +112,9 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: super().update_from_dps(decoded_dps) def update_from_map_packet(self, packet: Q10MapPacket) -> None: - """Store and render a pushed saved-map detail packet.""" + """Store a pushed saved-map detail packet and notify listeners.""" 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/test_clean_history.py b/tests/devices/traits/b01/q10/test_clean_history.py index 2b441b555..b03eed6c7 100644 --- a/tests/devices/traits/b01/q10/test_clean_history.py +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -215,6 +215,9 @@ def test_received_detail_is_serializable_and_path_is_independent(q10_api: Q10Pro detail = _detail() detail.trace = Q10HistoricalTracePacket(points=[Q10Point(x=1, y=2)], heading=0) history.update_from_detail(detail) + assert history.detail is detail + assert history.detail_packet is detail.map + assert history.detail_trace is detail.trace detail.trace.points.clear() assert history.detail_path == [Q10Point(x=1, y=2)] assert isinstance(history, CleanHistory) @@ -227,6 +230,18 @@ def test_received_detail_is_serializable_and_path_is_independent(q10_api: Q10Pro assert history.as_dict(exclude={"records", "detail_path"}) == {} +def test_received_detail_without_trace_clears_previous_path(clean_history: CleanHistoryTrait) -> None: + first = _detail() + first.trace = Q10HistoricalTracePacket(points=[Q10Point(x=1, y=2)], heading=0) + clean_history.update_from_detail(first) + assert clean_history.detail_path == [Q10Point(x=1, y=2)] + second = _detail() + clean_history.update_from_detail(second) + assert clean_history.detail is second + assert clean_history.detail_trace is None + assert clean_history.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)) diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 3c4aacd83..0e65afa38 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -805,12 +805,11 @@ async def test_archived_map_pushes_cannot_overwrite_live_map( 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 is clean_record + assert q10_api.clean_history.detail_packet is clean_record.map 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 is saved_map 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] @@ -826,25 +825,30 @@ def test_archive_owners_reject_wrong_packet_kinds(q10_api: Q10PropertiesApi) -> @pytest.mark.parametrize("scale", [1, 2, 4]) -async def test_all_q10_map_views_render_at_requested_scale(scale: int, fake_channel: FakeB01Q10Channel) -> None: +async def test_live_q10_map_renders_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) + await _wait_for(lambda: api.map.image_content is not None) + assert api.map.image_content is not None + with Image.open(io.BytesIO(api.map.image_content)) as image: + assert image.size == (8 * scale, 6 * scale) finally: await api.close() +def test_q10_properties_constructor_uses_default_live_map_config(fake_channel: FakeB01Q10Channel) -> None: + api = Q10PropertiesApi(fake_channel) + api.map.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes())) + assert api.map.image_content is not None + with Image.open(io.BytesIO(api.map.image_content)) as image: + assert image.size == (32, 24) + + @pytest.mark.parametrize("selected,expected", [(None, "12345"), ("67890", "67890")]) async def test_saved_map_detail_refresh_publishes_selection( q10_api: Q10PropertiesApi, @@ -885,7 +889,6 @@ async def test_saved_map_details_publish_packet_map_id_and_notify( 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"] @@ -903,7 +906,7 @@ def test_saved_map_diagnostics_exclude_binary_content(q10_api: Q10PropertiesApi) 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.detail_packet is packet assert q10_api.maps.as_dict(exclude={"detail_map_id"}) == {} json.dumps(data) @@ -946,7 +949,7 @@ def predicate() -> bool: @pytest.mark.parametrize("drawables,expected_base", [([], True), ([Drawable.CHARGER], False)]) -async def test_all_q10_map_views_honor_drawable_selection( +async def test_live_q10_map_honors_drawable_selection( drawables: list[Drawable], expected_base: bool, fake_channel: FakeB01Q10Channel ) -> None: api = create(fake_channel, map_parser_config=B01Q10MapParserConfig(drawables=drawables)) @@ -964,15 +967,12 @@ async def test_all_q10_map_views_honor_drawable_selection( ) fake_channel.messages_to_stream = [ packet, - Q10CleanRecordDetail(map=replace(packet, kind=Q10MapPacketKind.CLEAN_RECORD_DETAIL)), - replace(packet, kind=Q10MapPacketKind.SAVED_MAP_DETAIL), ] 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.tobytes() == base_pixels) is expected_base + await _wait_for(lambda: api.map.image_content is not None) + assert api.map.image_content is not None + with Image.open(io.BytesIO(api.map.image_content)) as image: + assert (image.tobytes() == base_pixels) is expected_base finally: await api.close()