Skip to content

Commit df75659

Browse files
fix: show docked Q10 robot on map (#904)
1 parent 347a8f6 commit df75659

6 files changed

Lines changed: 238 additions & 40 deletions

File tree

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

Lines changed: 19 additions & 11 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,8 +61,13 @@ 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."""
6471
if not self._CONVERTER.update_from_dps(self, decoded_dps):
6572
return
6673
self._overlays = Q10MapOverlays(
@@ -73,8 +80,8 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
7380
class MapContentTrait(TraitUpdateListener):
7481
"""High-level composed Q10 map view.
7582
76-
The latest map and trace packets are combined with the injected
77-
:class:`MapDpsTrait` whenever any of those three sources changes.
83+
The latest map and trace packets are combined with the injected map DPS
84+
whenever any source changes.
7885
"""
7986

8087
def __init__(
@@ -129,7 +136,7 @@ def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
129136
self._notify_update()
130137

131138
def _map_dps_updated(self) -> None:
132-
"""Render after the low-level DPS source changes."""
139+
"""Render after the low-level map DPS source changes."""
133140
if self._map_packet is None:
134141
return
135142
self._render()
@@ -142,9 +149,10 @@ def _render(self) -> None:
142149
try:
143150
self._image_content = render_q10_map(
144151
self._map_packet,
145-
self._trace_packet,
152+
self._trace_packet if not self._map_dps.robot_at_dock else None,
146153
self._map_dps.overlays,
147154
config=self._config,
155+
robot_at_dock=self._map_dps.robot_at_dock,
148156
)
149157
except RoborockException as ex:
150158
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)

roborock/devices/traits/common.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,14 @@ def update_from_dps(self, target: RoborockBase, decoded_dps: dict[TDps, Any]) ->
105105
decoded_dps: The decoded DPS data to convert.
106106
107107
Returns:
108-
True if any values were updated, False otherwise.
108+
True if any values changed, False otherwise.
109109
"""
110110
conversions = RoborockBase.convert_dict(self._dps_type_map, decoded_dps)
111+
changed = False
111112
for dps_id, value in conversions.items():
112113
field_name = self._dps_field_map[dps_id]
114+
if getattr(target, field_name) == value:
115+
continue
113116
setattr(target, field_name, value)
114-
return bool(conversions)
117+
changed = True
118+
return changed

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: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010

1111
import asyncio
1212
import base64
13-
from collections.abc import AsyncGenerator
13+
from collections.abc import AsyncGenerator, Generator
1414
from pathlib import Path
1515
from typing import cast
1616
from unittest.mock import Mock, patch
1717

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:
@@ -51,6 +51,12 @@ def _zone_blob() -> str:
5151
return base64.b64encode(bytes([1, 1]) + record).decode()
5252

5353

54+
@pytest.fixture(name="render_map")
55+
def render_map_fixture() -> Generator[Mock, None, None]:
56+
with patch("roborock.devices.traits.b01.q10.map.render_q10_map") as render:
57+
yield render
58+
59+
5460
def test_update_from_map_packet_populates_image_and_rooms() -> None:
5561
"""A pushed 01 01 map packet populates the image and rooms."""
5662
packet = parse_map_packet(FIXTURE.read_bytes())
@@ -215,18 +221,16 @@ def test_trace_without_map_is_retained_without_rendering() -> None:
215221
assert trait.image_content is None
216222

217223

218-
def test_render_failure_clears_stale_image() -> None:
224+
def test_render_failure_clears_stale_image(render_map: Mock) -> None:
219225
"""A failed composition cannot leave an image from older source data."""
220226
packet = parse_map_packet(FIXTURE.read_bytes())
221227
trace = Q10TracePacket(points=[Q10Point(1, 2)])
222228
trait = _map_trait()
223229

224-
with patch(
225-
"roborock.devices.traits.b01.q10.map.render_q10_map",
226-
side_effect=[b"initial image", RoborockException("invalid map")],
227-
):
228-
trait.update_from_map_packet(packet)
229-
trait.update_from_trace_packet(trace)
230+
render_map.side_effect = [b"initial image", RoborockException("invalid map")]
231+
232+
trait.update_from_map_packet(packet)
233+
trait.update_from_trace_packet(trace)
230234

231235
assert trait.path == trace.points
232236
assert trait.image_content is None
@@ -235,35 +239,33 @@ def test_render_failure_clears_stale_image() -> None:
235239
# --- Overlays ----------------------------------------------------------------
236240

237241

238-
def test_map_dps_update_renders_decoded_overlays() -> None:
242+
def test_map_dps_update_renders_decoded_overlays(render_map: Mock) -> None:
239243
"""A DPS update recomposes an existing map with decoded overlays."""
240244
map_dps = MapDpsTrait()
241-
trait = MapContentTrait(map_dps)
245+
trait = _map_trait(map_dps)
242246
packet = parse_map_packet(FIXTURE.read_bytes())
243247
notified: list[None] = []
244248
trait.add_update_listener(lambda: notified.append(None))
245249

246-
with patch(
247-
"roborock.devices.traits.b01.q10.map.render_q10_map",
248-
side_effect=[b"base image", b"image with overlays"],
249-
) as render:
250-
trait.update_from_map_packet(packet)
251-
notified.clear()
252-
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
250+
render_map.side_effect = [b"base image", b"image with overlays"]
251+
252+
trait.update_from_map_packet(packet)
253+
notified.clear()
254+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
253255

254256
assert len(map_dps.overlays.zones) == 1
255257
assert trait.image_content == b"image with overlays"
256258
assert notified == [None]
257-
assert render.call_count == 2
258-
assert render.call_args.args[0] is packet
259-
assert render.call_args.args[1] is None
260-
assert render.call_args.args[2] is map_dps.overlays
259+
assert render_map.call_count == 2
260+
assert render_map.call_args.args[0] is packet
261+
assert render_map.call_args.args[1] is None
262+
assert render_map.call_args.args[2] is map_dps.overlays
261263

262264

263265
def test_map_dps_blobs_are_decoded_only_when_dps_arrives() -> None:
264266
"""Map and trace renders reuse the overlays decoded by the DPS trait."""
265267
map_dps = MapDpsTrait()
266-
trait = MapContentTrait(map_dps)
268+
trait = _map_trait(map_dps)
267269

268270
with (
269271
patch("roborock.devices.traits.b01.q10.map.parse_zone_blob", return_value=[]) as parse_zones,
@@ -291,7 +293,7 @@ def test_load_overlays_partial_update_keeps_existing_zones() -> None:
291293
def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
292294
"""A DPS update cannot change high-level content before a map arrives."""
293295
map_dps = MapDpsTrait()
294-
trait = MapContentTrait(map_dps)
296+
trait = _map_trait(map_dps)
295297
notified = []
296298
trait.add_update_listener(lambda: notified.append(True))
297299

@@ -304,11 +306,107 @@ def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
304306
def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
305307
"""A DPS push carrying neither overlay DP leaves both traits untouched."""
306308
map_dps = MapDpsTrait()
307-
trait = MapContentTrait(map_dps)
309+
trait = _map_trait(map_dps)
308310
notified = []
309311
trait.add_update_listener(lambda: notified.append(True))
310312

311313
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
312314

313315
assert map_dps.overlays == Q10MapOverlays()
314316
assert not notified
317+
318+
319+
async def test_charging_status_renders_robot_at_dock(render_map: Mock) -> None:
320+
"""Charging status adds the idle robot marker without inventing a path."""
321+
map_dps = MapDpsTrait()
322+
trait = _map_trait(map_dps)
323+
packet = parse_map_packet(FIXTURE.read_bytes())
324+
updated = asyncio.Event()
325+
trait.add_update_listener(updated.set)
326+
render_map.side_effect = [b"map with dock", b"map with docked robot"]
327+
328+
trait.update_from_map_packet(packet)
329+
updated.clear()
330+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
331+
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
332+
333+
await asyncio.wait_for(updated.wait(), timeout=1)
334+
335+
assert trait.image_content == b"map with docked robot"
336+
assert trait.path == []
337+
assert render_map.call_count == 2
338+
assert render_map.call_args.kwargs["robot_at_dock"] is True
339+
340+
341+
def test_docked_state_hides_trace_only_from_rendering(render_map: Mock) -> None:
342+
"""A docked render omits the valid trace without deleting source data."""
343+
map_dps = MapDpsTrait()
344+
trait = _map_trait(map_dps)
345+
packet = parse_map_packet(FIXTURE.read_bytes())
346+
trace = Q10TracePacket(points=[Q10Point(1, 2), Q10Point(3, 4)])
347+
render_map.return_value = b"map"
348+
349+
trait.update_from_map_packet(packet)
350+
trait.update_from_trace_packet(trace)
351+
assert render_map.call_args.args[1] is trace
352+
353+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
354+
355+
assert trait.path == trace.points
356+
assert render_map.call_args.args[1] is None
357+
assert render_map.call_args.kwargs["robot_at_dock"] is True
358+
359+
360+
def test_late_trace_is_retained_but_hidden_while_docked(render_map: Mock) -> None:
361+
"""A late trace stays available but is not part of a docked render."""
362+
map_dps = MapDpsTrait()
363+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
364+
trait = _map_trait(map_dps)
365+
trace = Q10TracePacket(points=[Q10Point(1, 2)])
366+
render_map.return_value = b"map"
367+
368+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
369+
trait.update_from_trace_packet(trace)
370+
371+
assert trait.path == trace.points
372+
assert render_map.call_args.args[1] is None
373+
374+
375+
def test_emptying_state_keeps_robot_at_dock(render_map: Mock) -> None:
376+
"""Dock emptying must not briefly remove the docked robot marker."""
377+
map_dps = MapDpsTrait()
378+
trait = _map_trait(map_dps)
379+
packet = parse_map_packet(FIXTURE.read_bytes())
380+
381+
render_map.side_effect = [b"map with dock", b"map while emptying"]
382+
383+
trait.update_from_map_packet(packet)
384+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.EMPTYING_THE_BIN.code})
385+
386+
assert trait.image_content == b"map while emptying"
387+
assert render_map.call_args.kwargs["robot_at_dock"] is True
388+
389+
390+
async def test_combined_status_and_overlay_update_renders_once(render_map: Mock) -> None:
391+
"""One map DPS update publishes the complete new rendering state."""
392+
map_dps = MapDpsTrait()
393+
trait = _map_trait(map_dps)
394+
updated = asyncio.Event()
395+
trait.add_update_listener(updated.set)
396+
render_map.side_effect = [b"base map", b"combined map"]
397+
398+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
399+
updated.clear()
400+
map_dps.update_from_dps(
401+
{
402+
B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code,
403+
B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob(),
404+
}
405+
)
406+
407+
await asyncio.wait_for(updated.wait(), timeout=1)
408+
409+
assert render_map.call_count == 2
410+
assert len(render_map.call_args.args[2].zones) == 1
411+
assert render_map.call_args.kwargs["robot_at_dock"] is True
412+
assert trait.image_content == b"combined map"

0 commit comments

Comments
 (0)