Skip to content

Commit f3f025d

Browse files
fix: show docked Q10 robot on map
1 parent 347a8f6 commit f3f025d

4 files changed

Lines changed: 203 additions & 22 deletions

File tree

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

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
55
* map packets are decoded from map-protocol responses;
66
* trace packets are decoded from trace-protocol responses;
7-
* restricted zones and virtual walls arrive as ordinary DPS values.
7+
* restricted zones, virtual walls and dock state arrive as ordinary DPS values.
88
9-
``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
10-
on it and combines that state with the latest map/trace packets through the pure
11-
functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12-
the latest value from each source and one replace-whole rendered image;
9+
``MapDpsTrait`` owns the low-level map-specific DPS read model.
10+
``MapContentTrait`` combines that state with the latest map/trace packets
11+
through the pure functions in :mod:`roborock.map.b01_q10_render`. The high-level
12+
trait keeps only the latest value from each source and one replace-whole image;
1313
calibration, path placement and overlay placement remain inside the renderer.
1414
"""
1515

@@ -18,7 +18,7 @@
1818
from typing import Any
1919

2020
from roborock.data import RoborockBase
21-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
21+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
2222
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2323
from roborock.exceptions import RoborockException
2424
from roborock.map.b01_q10_map_parser import (
@@ -34,12 +34,14 @@
3434
from .common import UpdatableTrait
3535

3636
_LOGGER = logging.getLogger(__name__)
37+
_DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}
3738

3839

3940
@dataclass
4041
class MapDps(RoborockBase):
4142
"""Low-level map values delivered in the Q10 DPS stream."""
4243

44+
status: YXDeviceState | None = field(default=None, metadata={"dps": B01_Q10_DP.STATUS})
4345
restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
4446
virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
4547

@@ -59,22 +61,36 @@ def overlays(self) -> Q10MapOverlays:
5961
"""Overlays decoded once from the latest relevant DPS update."""
6062
return self._overlays
6163

64+
@property
65+
def robot_at_dock(self) -> bool:
66+
"""Whether status places the idle robot at the saved dock."""
67+
return self.status in _DOCKED_STATES
68+
6269
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
63-
"""Decode overlay blobs when they arrive, then notify dependents."""
70+
"""Update one coherent snapshot of the DPS inputs used by the map."""
71+
previous_robot_at_dock = self.robot_at_dock
72+
previous_restricted_zones = self.restricted_zone_up
73+
previous_virtual_walls = self.virtual_wall_up
6474
if not self._CONVERTER.update_from_dps(self, decoded_dps):
6575
return
66-
self._overlays = Q10MapOverlays(
67-
zones=tuple(parse_zone_blob(self.restricted_zone_up)),
68-
virtual_walls=tuple(parse_virtual_wall_blob(self.virtual_wall_up)),
76+
overlays_changed = (
77+
self.restricted_zone_up != previous_restricted_zones or self.virtual_wall_up != previous_virtual_walls
6978
)
79+
if overlays_changed:
80+
self._overlays = Q10MapOverlays(
81+
zones=tuple(parse_zone_blob(self.restricted_zone_up)),
82+
virtual_walls=tuple(parse_virtual_wall_blob(self.virtual_wall_up)),
83+
)
84+
if not overlays_changed and self.robot_at_dock == previous_robot_at_dock:
85+
return
7086
self._notify_update()
7187

7288

7389
class MapContentTrait(TraitUpdateListener):
7490
"""High-level composed Q10 map view.
7591
76-
The latest map and trace packets are combined with the injected
77-
:class:`MapDpsTrait` whenever any of those three sources changes.
92+
The latest map and trace packets are combined with the injected map DPS
93+
whenever any source changes.
7894
"""
7995

8096
def __init__(
@@ -86,6 +102,7 @@ def __init__(
86102
TraitUpdateListener.__init__(self, logger=_LOGGER)
87103
self._config = map_parser_config or B01Q10MapParserConfig()
88104
self._map_dps = map_dps
105+
self._robot_at_dock = self._map_dps.robot_at_dock
89106
self._map_packet: Q10MapPacket | None = None
90107
self._trace_packet: Q10TracePacket | None = None
91108
self._image_content: bytes | None = None
@@ -124,13 +141,19 @@ def update_from_map_packet(self, packet: Q10MapPacket) -> None:
124141

125142
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
126143
"""Store a trace-protocol update and render the latest sources."""
127-
self._trace_packet = packet
144+
# A late packet from the completed clean cannot move a robot that the
145+
# map's DPS snapshot already confirmed is docked.
146+
self._trace_packet = None if self._robot_at_dock else packet
128147
self._render()
129148
self._notify_update()
130149

131150
def _map_dps_updated(self) -> None:
132-
"""Render after the low-level DPS source changes."""
133-
if self._map_packet is None:
151+
"""Apply the latest map-specific DPS snapshot and recompose."""
152+
self._robot_at_dock = self._map_dps.robot_at_dock
153+
trace_cleared = self._robot_at_dock and self._trace_packet is not None
154+
if self._robot_at_dock:
155+
self._trace_packet = None
156+
if self._map_packet is None and not trace_cleared:
134157
return
135158
self._render()
136159
self._notify_update()
@@ -145,6 +168,7 @@ def _render(self) -> None:
145168
self._trace_packet,
146169
self._map_dps.overlays,
147170
config=self._config,
171+
robot_at_dock=self._robot_at_dock,
148172
)
149173
except RoborockException as ex:
150174
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)

roborock/map/b01_q10_render.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
"""
1515

1616
import io
17+
import math
1718
from collections.abc import Sequence
1819
from dataclasses import dataclass
1920

2021
from vacuum_map_parser_base.config.drawable import Drawable
22+
from vacuum_map_parser_base.config.size import Size, Sizes
2123
from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
2224

2325
from roborock.exceptions import RoborockException
@@ -86,6 +88,7 @@ def render_q10_map(
8688
overlays: Q10MapOverlays,
8789
*,
8890
config: B01Q10MapParserConfig,
91+
robot_at_dock: bool = False,
8992
) -> bytes:
9093
"""Compose the latest map, trace and DPS inputs into one PNG image.
9194
@@ -117,6 +120,8 @@ def render_q10_map(
117120
_place_trace(map_data, trace_calibration, trace, charger_heading=charger_heading)
118121
has_drawables = True
119122
has_drawables = _place_charger_from_header(map_data, packet) or has_drawables
123+
if robot_at_dock:
124+
has_drawables = _place_docked_robot(map_data) or has_drawables
120125
if vector_calibration is not None:
121126
_place_overlays(map_data, vector_calibration, overlays)
122127
has_drawables = has_drawables or bool(map_data.no_go_areas or map_data.no_mopping_areas or map_data.walls)
@@ -262,6 +267,27 @@ def _place_charger_from_header(
262267
return True
263268

264269

270+
def _place_docked_robot(map_data: MapData) -> bool:
271+
"""Place a charging robot immediately in front of the saved dock.
272+
273+
A zero-point idle trace has no robot coordinates. The dock heading does,
274+
however, identify its outward-facing side. Offset the robot by the shared
275+
unscaled V1 charger radius so the two standard glyphs meet without one
276+
covering the other, and preserve the saved dock heading.
277+
"""
278+
charger = map_data.charger
279+
if charger is None or charger.a is None:
280+
return False
281+
angle = math.radians(charger.a)
282+
offset = Sizes.SIZES[Size.CHARGER_RADIUS]
283+
map_data.vacuum_position = Point(
284+
charger.x + offset * math.cos(angle),
285+
charger.y - offset * math.sin(angle),
286+
charger.a,
287+
)
288+
return True
289+
290+
265291
def _place_overlays(
266292
map_data: MapData,
267293
calibration: GridCalibration,

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

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import pytest
1919

2020
from roborock.cli import _await_q10_map_push, cli
21-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
21+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
2222
from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create
2323
from roborock.devices.traits.b01.q10.map import MapContentTrait, MapDpsTrait
2424
from roborock.exceptions import RoborockException
@@ -37,9 +37,9 @@
3737
TRACE_SESSION_FIXTURE = Path("tests/map/testdata/b01_q10_trace_session.bin")
3838

3939

40-
def _map_trait() -> MapContentTrait:
40+
def _map_trait(map_dps: MapDpsTrait | None = None) -> MapContentTrait:
4141
"""Create a high-level trait with its required low-level dependency."""
42-
return MapContentTrait(MapDpsTrait())
42+
return MapContentTrait(map_dps or MapDpsTrait())
4343

4444

4545
def _zone_blob() -> str:
@@ -238,7 +238,7 @@ def test_render_failure_clears_stale_image() -> None:
238238
def test_map_dps_update_renders_decoded_overlays() -> None:
239239
"""A DPS update recomposes an existing map with decoded overlays."""
240240
map_dps = MapDpsTrait()
241-
trait = MapContentTrait(map_dps)
241+
trait = _map_trait(map_dps)
242242
packet = parse_map_packet(FIXTURE.read_bytes())
243243
notified: list[None] = []
244244
trait.add_update_listener(lambda: notified.append(None))
@@ -263,7 +263,7 @@ def test_map_dps_update_renders_decoded_overlays() -> None:
263263
def test_map_dps_blobs_are_decoded_only_when_dps_arrives() -> None:
264264
"""Map and trace renders reuse the overlays decoded by the DPS trait."""
265265
map_dps = MapDpsTrait()
266-
trait = MapContentTrait(map_dps)
266+
trait = _map_trait(map_dps)
267267

268268
with (
269269
patch("roborock.devices.traits.b01.q10.map.parse_zone_blob", return_value=[]) as parse_zones,
@@ -291,7 +291,7 @@ def test_load_overlays_partial_update_keeps_existing_zones() -> None:
291291
def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
292292
"""A DPS update cannot change high-level content before a map arrives."""
293293
map_dps = MapDpsTrait()
294-
trait = MapContentTrait(map_dps)
294+
trait = _map_trait(map_dps)
295295
notified = []
296296
trait.add_update_listener(lambda: notified.append(True))
297297

@@ -304,11 +304,110 @@ def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
304304
def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
305305
"""A DPS push carrying neither overlay DP leaves both traits untouched."""
306306
map_dps = MapDpsTrait()
307-
trait = MapContentTrait(map_dps)
307+
trait = _map_trait(map_dps)
308308
notified = []
309309
trait.add_update_listener(lambda: notified.append(True))
310310

311311
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
312312

313313
assert map_dps.overlays == Q10MapOverlays()
314314
assert not notified
315+
316+
317+
# --- Dock state --------------------------------------------------------------
318+
319+
320+
def test_charging_status_renders_robot_at_dock() -> None:
321+
"""Charging status adds the idle robot marker without inventing a path."""
322+
map_dps = MapDpsTrait()
323+
trait = _map_trait(map_dps)
324+
packet = parse_map_packet(FIXTURE.read_bytes())
325+
notified: list[None] = []
326+
trait.add_update_listener(lambda: notified.append(None))
327+
328+
with patch(
329+
"roborock.devices.traits.b01.q10.map.render_q10_map",
330+
side_effect=[b"map with dock", b"map with docked robot"],
331+
) as render:
332+
trait.update_from_map_packet(packet)
333+
notified.clear()
334+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
335+
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
336+
337+
assert trait.image_content == b"map with docked robot"
338+
assert trait.path == []
339+
assert notified == [None]
340+
assert render.call_count == 2
341+
assert render.call_args.kwargs["robot_at_dock"] is True
342+
343+
344+
def test_entering_docked_state_clears_stale_live_trace() -> None:
345+
"""A completed cleaning path cannot remain the caller-facing live position."""
346+
map_dps = MapDpsTrait()
347+
trait = _map_trait(map_dps)
348+
trait.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1, 2), Q10Point(3, 4)]))
349+
notified: list[None] = []
350+
trait.add_update_listener(lambda: notified.append(None))
351+
352+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
353+
354+
assert trait.path == []
355+
assert trait.robot_position is None
356+
assert trait.robot_heading is None
357+
assert notified == [None]
358+
359+
360+
def test_late_trace_does_not_move_docked_robot() -> None:
361+
"""A delayed trace packet cannot revive a completed cleaning path."""
362+
map_dps = MapDpsTrait()
363+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
364+
trait = _map_trait(map_dps)
365+
366+
trait.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1, 2)]))
367+
368+
assert trait.path == []
369+
assert trait.robot_position is None
370+
371+
372+
def test_emptying_state_keeps_robot_at_dock() -> None:
373+
"""Dock emptying must not briefly remove the docked robot marker."""
374+
map_dps = MapDpsTrait()
375+
trait = _map_trait(map_dps)
376+
packet = parse_map_packet(FIXTURE.read_bytes())
377+
378+
with patch(
379+
"roborock.devices.traits.b01.q10.map.render_q10_map",
380+
side_effect=[b"map with dock", b"map while emptying"],
381+
) as render:
382+
trait.update_from_map_packet(packet)
383+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.EMPTYING_THE_BIN.code})
384+
385+
assert trait.image_content == b"map while emptying"
386+
assert render.call_args.kwargs["robot_at_dock"] is True
387+
388+
389+
def test_combined_status_and_overlay_update_renders_once() -> None:
390+
"""One map DPS update publishes the complete new rendering state."""
391+
map_dps = MapDpsTrait()
392+
trait = _map_trait(map_dps)
393+
notified: list[None] = []
394+
trait.add_update_listener(lambda: notified.append(None))
395+
396+
with patch(
397+
"roborock.devices.traits.b01.q10.map.render_q10_map",
398+
side_effect=[b"base map", b"combined map"],
399+
) as render:
400+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
401+
notified.clear()
402+
map_dps.update_from_dps(
403+
{
404+
B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code,
405+
B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob(),
406+
}
407+
)
408+
409+
assert render.call_count == 2
410+
assert len(render.call_args.args[2].zones) == 1
411+
assert render.call_args.kwargs["robot_at_dock"] is True
412+
assert notified == [None]
413+
assert trait.image_content == b"combined map"

tests/map/test_b01_q10_render.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from pathlib import Path
1111

1212
from PIL import Image
13+
from vacuum_map_parser_base.config.size import Size, Sizes
14+
from vacuum_map_parser_base.map_data import MapData, Point
1315

1416
from roborock.map.b01_grid_layers import GridCalibration
1517
from roborock.map.b01_q10_map_parser import (
@@ -32,6 +34,7 @@
3234
Q10MapOverlays,
3335
_calibration_from_header_metadata,
3436
_erased_cells,
37+
_place_docked_robot,
3538
_vector_calibration,
3639
render_q10_map,
3740
solve_q10_calibration,
@@ -159,6 +162,35 @@ def test_render_draws_dock_from_header_without_trace() -> None:
159162
assert rendered != base
160163

161164

165+
def test_place_docked_robot_uses_shared_v1_marker_geometry() -> None:
166+
"""The idle robot sits beside the dock, facing it, without a path."""
167+
map_data = MapData()
168+
map_data.charger = Point(20, 30, 90)
169+
170+
assert _place_docked_robot(map_data)
171+
172+
assert map_data.vacuum_position == Point(
173+
20,
174+
30 - Sizes.SIZES[Size.CHARGER_RADIUS],
175+
90,
176+
)
177+
assert map_data.path is None
178+
179+
180+
def test_zero_degree_dock_places_robot_to_its_right() -> None:
181+
"""The Q10 dock heading is already its outward-facing direction."""
182+
map_data = MapData()
183+
map_data.charger = Point(3, 3, 0)
184+
185+
assert _place_docked_robot(map_data)
186+
187+
assert map_data.vacuum_position == Point(
188+
3 + Sizes.SIZES[Size.CHARGER_RADIUS],
189+
3,
190+
0,
191+
)
192+
193+
162194
def test_render_applies_erase_zones() -> None:
163195
"""With a calibration, erase-zone cells are blanked from the image."""
164196
packet, trace = _calibrated_inputs()

0 commit comments

Comments
 (0)