Skip to content

Commit a2c013a

Browse files
Merge remote-tracking branch 'origin/main' into vc/q10-map-refresh
# Conflicts: # roborock/devices/traits/b01/q10/map.py # tests/devices/traits/b01/q10/test_map.py
2 parents 0fee055 + e8d5466 commit a2c013a

9 files changed

Lines changed: 247 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
<!-- version list -->
44

5+
## v6.2.1 (2026-08-02)
6+
7+
### Bug Fixes
8+
9+
- Show docked Q10 robot on map ([#904](https://github.com/Python-roborock/python-roborock/pull/904),
10+
[`df75659`](https://github.com/Python-roborock/python-roborock/commit/df75659f4ee87d1c04f241bc0b1fa7b6d1f70715))
11+
12+
### Refactoring
13+
14+
- Render Q10 overlays with V1 ([#903](https://github.com/Python-roborock/python-roborock/pull/903),
15+
[`347a8f6`](https://github.com/Python-roborock/python-roborock/commit/347a8f6190cb8b7b8b607f3ca561ae025be956b5))
16+
17+
518
## v6.2.0 (2026-07-30)
619

720
### Features

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-roborock"
3-
version = "6.2.0"
3+
version = "6.2.1"
44
description = "A package to control Roborock vacuums."
55
authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
66
requires-python = ">=3.11, <4"

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
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`` uses a
10-
stored ID from ``MapsTrait`` only when it requests content. It combines map,
11-
trace, and overlay state through the pure functions in
12-
:mod:`roborock.map.b01_q10_render`. Map-list updates do not refresh content.
9+
``MapDpsTrait`` owns the low-level map-specific DPS read model.
10+
``MapContentTrait`` uses a stored ID from ``MapsTrait`` only when it requests
11+
content. It combines the latest map and trace packets with the map DPS state
12+
through the pure functions in :mod:`roborock.map.b01_q10_render`. Map-list
13+
updates do not refresh content.
1314
"""
1415

1516
import logging
@@ -19,7 +20,7 @@
1920

2021
from roborock.callbacks import CallbackList
2122
from roborock.data import RoborockBase
22-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
23+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
2324
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2425
from roborock.exceptions import RoborockException
2526
from roborock.map.b01_q10_map_parser import (
@@ -37,12 +38,14 @@
3738
from .maps import MapsTrait
3839

3940
_LOGGER = logging.getLogger(__name__)
41+
_DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}
4042

4143

4244
@dataclass
4345
class MapDps(RoborockBase):
4446
"""Low-level map values delivered in the Q10 DPS stream."""
4547

48+
status: YXDeviceState | None = field(default=None, metadata={"dps": B01_Q10_DP.STATUS})
4649
restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
4750
virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
4851

@@ -62,8 +65,13 @@ def overlays(self) -> Q10MapOverlays:
6265
"""Overlays decoded once from the latest relevant DPS update."""
6366
return self._overlays
6467

68+
@property
69+
def robot_at_dock(self) -> bool:
70+
"""Whether status places the idle robot at the saved dock."""
71+
return self.status in _DOCKED_STATES
72+
6573
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
66-
"""Decode overlay blobs when they arrive, then notify dependents."""
74+
"""Update one coherent snapshot of the DPS inputs used by the map."""
6775
if not self._CONVERTER.update_from_dps(self, decoded_dps):
6876
return
6977
self._overlays = Q10MapOverlays(
@@ -77,7 +85,7 @@ class MapContentTrait(TraitUpdateListener):
7785
"""High-level composed Q10 map view.
7886
7987
The latest map and trace packets are combined with the injected
80-
:class:`MapDpsTrait` whenever any of those three sources changes. The
88+
:class:`MapDpsTrait` whenever a source changes. The
8189
:class:`MapsTrait` supplies a stored ID only when this trait requests
8290
content.
8391
"""
@@ -166,7 +174,7 @@ def _add_trace_packet_listener(self, callback: Callable[[], None]) -> Callable[[
166174
return self._trace_packet_callbacks.add_callback(lambda _: callback())
167175

168176
def _map_dps_updated(self) -> None:
169-
"""Render after the low-level DPS source changes."""
177+
"""Render after the low-level map DPS source changes."""
170178
if self._map_packet is None:
171179
return
172180
self._render()
@@ -179,9 +187,10 @@ def _render(self) -> None:
179187
try:
180188
self._image_content = render_q10_map(
181189
self._map_packet,
182-
self._trace_packet,
190+
self._trace_packet if not self._map_dps.robot_at_dock else None,
183191
self._map_dps.overlays,
184192
config=self._config,
193+
robot_at_dock=self._map_dps.robot_at_dock,
185194
)
186195
except RoborockException as ex:
187196
_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: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88

99
import asyncio
1010
import base64
11-
from collections.abc import AsyncGenerator
11+
from collections.abc import AsyncGenerator, Generator
1212
from pathlib import Path
1313
from typing import cast
1414
from unittest.mock import Mock, patch
1515

1616
import pytest
1717

1818
from roborock.cli import _await_q10_map_push, cli
19-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
19+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
2020
from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create
2121
from roborock.devices.traits.b01.q10.command import CommandTrait
2222
from roborock.devices.traits.b01.q10.map import MapContentTrait, MapDpsTrait
@@ -62,6 +62,12 @@ def _zone_blob() -> str:
6262
return base64.b64encode(bytes([1, 1]) + record).decode()
6363

6464

65+
@pytest.fixture(name="render_map")
66+
def render_map_fixture() -> Generator[Mock, None, None]:
67+
with patch("roborock.devices.traits.b01.q10.map.render_q10_map") as render:
68+
yield render
69+
70+
6571
def test_update_from_map_packet_populates_image_and_rooms() -> None:
6672
"""A pushed 01 01 map packet populates the image and rooms."""
6773
packet = parse_map_packet(FIXTURE.read_bytes())
@@ -441,18 +447,16 @@ def test_trace_without_map_is_retained_without_rendering() -> None:
441447
assert trait.image_content is None
442448

443449

444-
def test_render_failure_clears_stale_image() -> None:
450+
def test_render_failure_clears_stale_image(render_map: Mock) -> None:
445451
"""A failed composition cannot leave an image from older source data."""
446452
packet = parse_map_packet(FIXTURE.read_bytes())
447453
trace = Q10TracePacket(points=[Q10Point(1, 2)])
448454
trait = _map_trait()
449455

450-
with patch(
451-
"roborock.devices.traits.b01.q10.map.render_q10_map",
452-
side_effect=[b"initial image", RoborockException("invalid map")],
453-
):
454-
trait.update_from_map_packet(packet)
455-
trait.update_from_trace_packet(trace)
456+
render_map.side_effect = [b"initial image", RoborockException("invalid map")]
457+
458+
trait.update_from_map_packet(packet)
459+
trait.update_from_trace_packet(trace)
456460

457461
assert trait.path == trace.points
458462
assert trait.image_content is None
@@ -461,29 +465,27 @@ def test_render_failure_clears_stale_image() -> None:
461465
# --- Overlays ----------------------------------------------------------------
462466

463467

464-
def test_map_dps_update_renders_decoded_overlays() -> None:
468+
def test_map_dps_update_renders_decoded_overlays(render_map: Mock) -> None:
465469
"""A DPS update recomposes an existing map with decoded overlays."""
466470
map_dps = MapDpsTrait()
467471
trait = _map_trait(map_dps)
468472
packet = parse_map_packet(FIXTURE.read_bytes())
469473
notified: list[None] = []
470474
trait.add_update_listener(lambda: notified.append(None))
471475

472-
with patch(
473-
"roborock.devices.traits.b01.q10.map.render_q10_map",
474-
side_effect=[b"base image", b"image with overlays"],
475-
) as render:
476-
trait.update_from_map_packet(packet)
477-
notified.clear()
478-
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
476+
render_map.side_effect = [b"base image", b"image with overlays"]
477+
478+
trait.update_from_map_packet(packet)
479+
notified.clear()
480+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
479481

480482
assert len(map_dps.overlays.zones) == 1
481483
assert trait.image_content == b"image with overlays"
482484
assert notified == [None]
483-
assert render.call_count == 2
484-
assert render.call_args.args[0] is packet
485-
assert render.call_args.args[1] is None
486-
assert render.call_args.args[2] is map_dps.overlays
485+
assert render_map.call_count == 2
486+
assert render_map.call_args.args[0] is packet
487+
assert render_map.call_args.args[1] is None
488+
assert render_map.call_args.args[2] is map_dps.overlays
487489

488490

489491
def test_map_dps_blobs_are_decoded_only_when_dps_arrives() -> None:
@@ -538,3 +540,99 @@ def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
538540

539541
assert map_dps.overlays == Q10MapOverlays()
540542
assert not notified
543+
544+
545+
async def test_charging_status_renders_robot_at_dock(render_map: Mock) -> None:
546+
"""Charging status adds the idle robot marker without inventing a path."""
547+
map_dps = MapDpsTrait()
548+
trait = _map_trait(map_dps)
549+
packet = parse_map_packet(FIXTURE.read_bytes())
550+
updated = asyncio.Event()
551+
trait.add_update_listener(updated.set)
552+
render_map.side_effect = [b"map with dock", b"map with docked robot"]
553+
554+
trait.update_from_map_packet(packet)
555+
updated.clear()
556+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
557+
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
558+
559+
await asyncio.wait_for(updated.wait(), timeout=1)
560+
561+
assert trait.image_content == b"map with docked robot"
562+
assert trait.path == []
563+
assert render_map.call_count == 2
564+
assert render_map.call_args.kwargs["robot_at_dock"] is True
565+
566+
567+
def test_docked_state_hides_trace_only_from_rendering(render_map: Mock) -> None:
568+
"""A docked render omits the valid trace without deleting source data."""
569+
map_dps = MapDpsTrait()
570+
trait = _map_trait(map_dps)
571+
packet = parse_map_packet(FIXTURE.read_bytes())
572+
trace = Q10TracePacket(points=[Q10Point(1, 2), Q10Point(3, 4)])
573+
render_map.return_value = b"map"
574+
575+
trait.update_from_map_packet(packet)
576+
trait.update_from_trace_packet(trace)
577+
assert render_map.call_args.args[1] is trace
578+
579+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
580+
581+
assert trait.path == trace.points
582+
assert render_map.call_args.args[1] is None
583+
assert render_map.call_args.kwargs["robot_at_dock"] is True
584+
585+
586+
def test_late_trace_is_retained_but_hidden_while_docked(render_map: Mock) -> None:
587+
"""A late trace stays available but is not part of a docked render."""
588+
map_dps = MapDpsTrait()
589+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code})
590+
trait = _map_trait(map_dps)
591+
trace = Q10TracePacket(points=[Q10Point(1, 2)])
592+
render_map.return_value = b"map"
593+
594+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
595+
trait.update_from_trace_packet(trace)
596+
597+
assert trait.path == trace.points
598+
assert render_map.call_args.args[1] is None
599+
600+
601+
def test_emptying_state_keeps_robot_at_dock(render_map: Mock) -> None:
602+
"""Dock emptying must not briefly remove the docked robot marker."""
603+
map_dps = MapDpsTrait()
604+
trait = _map_trait(map_dps)
605+
packet = parse_map_packet(FIXTURE.read_bytes())
606+
607+
render_map.side_effect = [b"map with dock", b"map while emptying"]
608+
609+
trait.update_from_map_packet(packet)
610+
map_dps.update_from_dps({B01_Q10_DP.STATUS: YXDeviceState.EMPTYING_THE_BIN.code})
611+
612+
assert trait.image_content == b"map while emptying"
613+
assert render_map.call_args.kwargs["robot_at_dock"] is True
614+
615+
616+
async def test_combined_status_and_overlay_update_renders_once(render_map: Mock) -> None:
617+
"""One map DPS update publishes the complete new rendering state."""
618+
map_dps = MapDpsTrait()
619+
trait = _map_trait(map_dps)
620+
updated = asyncio.Event()
621+
trait.add_update_listener(updated.set)
622+
render_map.side_effect = [b"base map", b"combined map"]
623+
624+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
625+
updated.clear()
626+
map_dps.update_from_dps(
627+
{
628+
B01_Q10_DP.STATUS: YXDeviceState.CHARGING.code,
629+
B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob(),
630+
}
631+
)
632+
633+
await asyncio.wait_for(updated.wait(), timeout=1)
634+
635+
assert render_map.call_count == 2
636+
assert len(render_map.call_args.args[2].zones) == 1
637+
assert render_map.call_args.kwargs["robot_at_dock"] is True
638+
assert trait.image_content == b"combined map"

0 commit comments

Comments
 (0)