Skip to content

Commit b85893d

Browse files
refactor: tighten Q10 map trait lifecycle
1 parent af66aa9 commit b85893d

3 files changed

Lines changed: 61 additions & 88 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ class Q10PropertiesApi(Trait):
7878
"""Trait exposing remaining life of consumables."""
7979

8080
map: MapContentTrait
81-
"""Trait for fetching the current parsed map (image + rooms)."""
81+
"""Composed map image plus caller-facing map and trace data."""
8282

8383
map_dps: MapDpsTrait
84-
"""Low-level DPS values used to compose map overlays."""
84+
"""Restricted zones and virtual walls received through DPS."""
8585

8686
clean_history: CleanHistoryTrait
8787
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""

roborock/devices/traits/b01/q10/map.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,21 @@ class MapContentTrait(TraitUpdateListener):
7272

7373
def __init__(
7474
self,
75-
map_dps: MapDpsTrait | None = None,
75+
map_dps: MapDpsTrait,
7676
*,
7777
map_parser_config: B01Q10MapParserConfig | None = None,
7878
) -> None:
7979
TraitUpdateListener.__init__(self, logger=_LOGGER)
8080
self._config = map_parser_config or B01Q10MapParserConfig()
81-
self._map_dps = map_dps or MapDpsTrait()
81+
self._map_dps = map_dps
8282
self._map_packet: Q10MapPacket | None = None
8383
self._trace_packet: Q10TracePacket | None = None
8484
self._image_content: bytes | None = None
8585
self._map_dps.add_update_listener(self._map_dps_updated)
8686

8787
@property
8888
def image_content(self) -> bytes | None:
89-
"""The composed map PNG, if a map has been pushed."""
89+
"""The composed map PNG, if the latest map rendered successfully."""
9090
return self._image_content
9191

9292
@property
@@ -123,6 +123,8 @@ def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
123123

124124
def _map_dps_updated(self) -> None:
125125
"""Render after the low-level DPS source changes."""
126+
if self._map_packet is None:
127+
return
126128
self._render()
127129
self._notify_update()
128130

@@ -142,3 +144,4 @@ def _render(self) -> None:
142144
)
143145
except RoborockException as ex:
144146
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)
147+
self._image_content = None

tests/devices/traits/b01/q10/test_map.py

Lines changed: 53 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,18 @@
1111
import asyncio
1212
import base64
1313
from collections.abc import AsyncGenerator
14-
from dataclasses import replace
1514
from pathlib import Path
1615
from typing import cast
17-
from unittest.mock import Mock
16+
from unittest.mock import Mock, patch
1817

1918
import pytest
2019

2120
from roborock.cli import _await_q10_map_push, cli
2221
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
2322
from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create
2423
from roborock.devices.traits.b01.q10.map import MapContentTrait, MapDpsTrait
25-
from roborock.map.b01_grid_layers import GridCalibration
24+
from roborock.exceptions import RoborockException
2625
from roborock.map.b01_q10_map_parser import (
27-
Q10HeaderCalibration,
28-
Q10MapPacket,
2926
Q10Point,
3027
Q10TracePacket,
3128
parse_map_packet,
@@ -38,34 +35,25 @@
3835
FIXTURE = Path("tests/map/testdata/b01_q10_map.bin")
3936
TRACE_SESSION_FIXTURE = Path("tests/map/testdata/b01_q10_trace_session.bin")
4037

41-
# A header calibration whose pixel origin (0, 5) is usable (not a keepalive
42-
# frame), so a short path can calibrate the fixture map.
43-
_USABLE_HEADER = Q10HeaderCalibration(origin_x=0, origin_y=50, resolution=5, charger_x=0, charger_y=0, charger_phi=0)
4438

39+
def _map_trait() -> MapContentTrait:
40+
"""Create a high-level trait with its required low-level dependency."""
41+
return MapContentTrait(MapDpsTrait())
4542

46-
def _trait_with_map() -> MapContentTrait:
47-
"""A trait with the fixture map already pushed into it."""
48-
trait = MapContentTrait()
49-
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
50-
return trait
5143

52-
53-
def _floor_world_points(packet: Q10MapPacket, cal: GridCalibration, count: int) -> list[Q10Point]:
54-
"""``count`` world points lying on the map's floor under ``cal``."""
55-
layers = packet.layers
56-
floor = [
57-
(px, py)
58-
for py in range(layers.height)
59-
for px in range(layers.width)
60-
if layers.cell_class(layers.grid[py * layers.width + px]) == "floor"
61-
]
62-
return [Q10Point(*(int(v) for v in cal.pixel_to_world(px, py))) for px, py in floor[:count]]
44+
def _zone_blob() -> str:
45+
"""Return one base64-encoded restricted-zone DPS value."""
46+
vertices = [(0, 0), (40, 0), (40, 40), (0, 40)]
47+
record = bytes([0, len(vertices)]) + b"".join(
48+
int.to_bytes(value & 0xFFFF, 2, "big") for point in vertices for value in point
49+
)
50+
return base64.b64encode(bytes([1, 1]) + record).decode()
6351

6452

6553
def test_update_from_map_packet_populates_image_and_rooms() -> None:
66-
"""A pushed 01 01 map packet populates the image, rooms and map data."""
54+
"""A pushed 01 01 map packet populates the image and rooms."""
6755
packet = parse_map_packet(FIXTURE.read_bytes())
68-
trait = MapContentTrait()
56+
trait = _map_trait()
6957
updates: list[None] = []
7058
trait.add_update_listener(lambda: updates.append(None))
7159

@@ -80,7 +68,7 @@ def test_update_from_map_packet_populates_image_and_rooms() -> None:
8068
def test_update_from_trace_packet_populates_path_and_position() -> None:
8169
"""A pushed 02 01 trace packet populates the path, position and heading."""
8270
trace = parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())
83-
trait = MapContentTrait()
71+
trait = _map_trait()
8472
updates: list[None] = []
8573
trait.add_update_listener(lambda: updates.append(None))
8674

@@ -103,7 +91,7 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None:
10391

10492
class _FakeQ10Properties:
10593
def __init__(self) -> None:
106-
self.map = MapContentTrait()
94+
self.map = _map_trait()
10795
self.refresh_count = 0
10896

10997
async def refresh(self) -> None:
@@ -220,97 +208,79 @@ async def test_subscribe_loop_routes_trace_push(
220208

221209
def test_trace_without_map_is_retained_without_rendering() -> None:
222210
"""A trace is retained even when no map is available to render yet."""
223-
trait = MapContentTrait()
211+
trait = _map_trait()
224212
trait.update_from_trace_packet(Q10TracePacket(points=[Q10Point(i, 0) for i in range(30)]))
225213
assert len(trait.path) == 30
226214
assert trait.image_content is None
227215

228216

229-
def test_trace_update_projects_short_path_using_header() -> None:
230-
"""A map header and short trace are sufficient to render a path."""
231-
trait = MapContentTrait()
232-
packet = replace(parse_map_packet(FIXTURE.read_bytes()), header_calibration=_USABLE_HEADER)
233-
trait.update_from_map_packet(packet)
234-
base = trait.image_content
235-
assert base is not None
236-
true = GridCalibration(resolution=20.0, origin_x=0.0, origin_y=5.0, y_sign=1)
237-
trait.update_from_trace_packet(Q10TracePacket(points=_floor_world_points(packet, true, 6)))
238-
assert len(trait.path) < 20 # far too short for the full origin+resolution fit
239-
240-
assert trait.image_content is not None
241-
assert trait.image_content != base
217+
def test_render_failure_clears_stale_image() -> None:
218+
"""A failed composition cannot leave an image from older source data."""
219+
packet = parse_map_packet(FIXTURE.read_bytes())
220+
trace = Q10TracePacket(points=[Q10Point(1, 2)])
221+
trait = _map_trait()
242222

223+
with patch(
224+
"roborock.devices.traits.b01.q10.map.render_q10_map",
225+
side_effect=[b"initial image", RoborockException("invalid map")],
226+
):
227+
trait.update_from_map_packet(packet)
228+
trait.update_from_trace_packet(trace)
243229

244-
def test_short_trace_without_header_cannot_be_projected() -> None:
245-
"""Without a header origin a short trace cannot be placed on the map."""
246-
packet = parse_map_packet(FIXTURE.read_bytes())
247-
trait = MapContentTrait()
248-
trait.update_from_map_packet(packet) # the fixture header is a keepalive frame
249-
base = trait.image_content
250-
true = GridCalibration(resolution=10.0, origin_x=0.0, origin_y=5.0, y_sign=1)
251-
trait.update_from_trace_packet(Q10TracePacket(points=_floor_world_points(packet, true, 6)))
252-
assert trait.image_content == base
230+
assert trait.path == trace.points
231+
assert trait.image_content is None
253232

254233

255234
# --- Overlays ----------------------------------------------------------------
256235

257236

258-
def test_load_overlays_places_zones_after_calibration() -> None:
259-
"""Decoded no-go / no-mop zones are drawn once the sources calibrate."""
237+
def test_map_dps_update_renders_decoded_overlays() -> None:
238+
"""A DPS update recomposes an existing map with decoded overlays."""
260239
map_dps = MapDpsTrait()
261240
trait = MapContentTrait(map_dps)
262-
packet = replace(parse_map_packet(FIXTURE.read_bytes()), header_calibration=_USABLE_HEADER)
263-
trait.update_from_map_packet(packet)
264-
true = GridCalibration(resolution=20.0, origin_x=0.0, origin_y=5.0, y_sign=1)
265-
trait.update_from_trace_packet(Q10TracePacket(points=_floor_world_points(packet, true, 6)))
266-
before = trait.image_content
267-
assert before is not None
268-
269-
def rect(zone_type: int, corners: list[tuple[int, int]]) -> bytes:
270-
out = bytes([zone_type, len(corners)])
271-
for x, y in corners:
272-
out += int.to_bytes(x & 0xFFFF, 2, "big") + int.to_bytes(y & 0xFFFF, 2, "big")
273-
return out.ljust(18, b"\x00")
241+
packet = parse_map_packet(FIXTURE.read_bytes())
242+
notified: list[None] = []
243+
trait.add_update_listener(lambda: notified.append(None))
274244

275-
blob = bytes([1, 1]) + rect(0, [(0, 0), (40, 0), (40, 40), (0, 40)])
276-
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
245+
with patch(
246+
"roborock.devices.traits.b01.q10.map.render_q10_map",
247+
side_effect=[b"base image", b"image with overlays"],
248+
) as render:
249+
trait.update_from_map_packet(packet)
250+
notified.clear()
251+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
277252

278253
assert len(map_dps.zones) == 1
279-
assert trait.image_content != before
254+
assert trait.image_content == b"image with overlays"
255+
assert notified == [None]
256+
assert render.call_count == 2
257+
assert render.call_args.args[0] is packet
258+
assert render.call_args.args[1] is None
259+
assert tuple(render.call_args.args[2].zones) == tuple(map_dps.zones)
280260

281261

282262
def test_load_overlays_partial_update_keeps_existing_zones() -> None:
283263
"""A status push without the zone DP (None) must not wipe loaded zones."""
284264
map_dps = MapDpsTrait()
285-
blob = (
286-
bytes([1, 1])
287-
+ bytes([0, 4])
288-
+ b"".join(int.to_bytes(v & 0xFFFF, 2, "big") for xy in [(0, 0), (4, 0), (4, 4), (0, 4)] for v in xy)
289-
)
290-
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
265+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
291266
assert len(map_dps.zones) == 1
292267
# A later partial update carrying only the (empty) virtual-wall DP.
293268
map_dps.update_from_dps({B01_Q10_DP.VIRTUAL_WALL_UP: "AA=="})
294269
assert len(map_dps.zones) == 1 # zones preserved
295270
assert map_dps.virtual_walls == []
296271

297272

298-
def test_map_dps_trait_updates_high_level_map_content() -> None:
299-
"""The low-level DPS trait notifies the dependent high-level map trait."""
273+
def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
274+
"""A DPS update cannot change high-level content before a map arrives."""
300275
map_dps = MapDpsTrait()
301276
trait = MapContentTrait(map_dps)
302-
blob = (
303-
bytes([1, 1])
304-
+ bytes([0, 4])
305-
+ b"".join(int.to_bytes(v & 0xFFFF, 2, "big") for xy in [(0, 0), (4, 0), (4, 4), (0, 4)] for v in xy)
306-
)
307277
notified = []
308278
trait.add_update_listener(lambda: notified.append(True))
309279

310-
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
280+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
311281

312282
assert len(map_dps.zones) == 1
313-
assert notified # listeners learn the overlays changed
283+
assert not notified
314284

315285

316286
def test_map_dps_push_without_overlay_data_points_is_noop() -> None:

0 commit comments

Comments
 (0)