Skip to content

Commit 3eddd11

Browse files
fix: show docked Q10 robot on map
1 parent 07c85f1 commit 3eddd11

7 files changed

Lines changed: 133 additions & 21 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
119119
self.network_info = NetworkInfoTrait()
120120
self.consumable = ConsumableTrait()
121121
self._map_dps = MapDpsTrait()
122-
self.map = MapContentTrait(self._map_dps)
122+
self.map = MapContentTrait(self._map_dps, self.status)
123123
self.clean_history = CleanHistoryTrait(self.command)
124124
# Read-model traits updated from the device's DPS push stream.
125125
self._updatable_traits = [

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

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"""Push-driven map traits for B01 Q10 devices.
22
3-
Map-related state arrives on three independent streams:
3+
Map-related state arrives on four independent streams:
44
55
* map packets are decoded from map-protocol responses;
66
* trace packets are decoded from trace-protocol responses;
77
* restricted zones and virtual walls arrive as ordinary DPS values.
8+
* the device status indicates when an idle robot is charging at the saved dock.
89
910
``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;
11+
on it and the status trait, then combines that state with the latest map/trace
12+
packets through the pure functions in :mod:`roborock.map.b01_q10_render`. The
13+
high-level trait keeps only the latest value from each source and one
14+
replace-whole rendered image;
1315
calibration, path placement and overlay placement remain inside the renderer.
1416
"""
1517

@@ -18,7 +20,7 @@
1820
from typing import Any
1921

2022
from roborock.data import RoborockBase
21-
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
2224
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2325
from roborock.exceptions import RoborockException
2426
from roborock.map.b01_q10_map_parser import (
@@ -32,6 +34,7 @@
3234
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3335

3436
from .common import UpdatableTrait
37+
from .status import StatusTrait
3538

3639
_LOGGER = logging.getLogger(__name__)
3740

@@ -73,23 +76,27 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
7376
class MapContentTrait(TraitUpdateListener):
7477
"""High-level composed Q10 map view.
7578
76-
The latest map and trace packets are combined with the injected
77-
:class:`MapDpsTrait` whenever any of those three sources changes.
79+
The latest map and trace packets are combined with the injected map DPS and
80+
status traits whenever any source changes.
7881
"""
7982

8083
def __init__(
8184
self,
8285
map_dps: MapDpsTrait,
86+
status: StatusTrait | None = None,
8387
*,
8488
map_parser_config: B01Q10MapParserConfig | None = None,
8589
) -> None:
8690
TraitUpdateListener.__init__(self, logger=_LOGGER)
8791
self._config = map_parser_config or B01Q10MapParserConfig()
8892
self._map_dps = map_dps
93+
self._status = status or StatusTrait()
94+
self._robot_at_dock = self._status.status == YXDeviceState.CHARGING
8995
self._map_packet: Q10MapPacket | None = None
9096
self._trace_packet: Q10TracePacket | None = None
9197
self._image_content: bytes | None = None
9298
self._map_dps.add_update_listener(self._map_dps_updated)
99+
self._status.add_update_listener(self._status_updated)
93100

94101
@property
95102
def image_content(self) -> bytes | None:
@@ -129,7 +136,18 @@ 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."""
140+
if self._map_packet is None:
141+
return
142+
self._render()
143+
self._notify_update()
144+
145+
def _status_updated(self) -> None:
146+
"""Render only when the status changes whether the robot is docked."""
147+
robot_at_dock = self._status.status == YXDeviceState.CHARGING
148+
if robot_at_dock == self._robot_at_dock:
149+
return
150+
self._robot_at_dock = robot_at_dock
133151
if self._map_packet is None:
134152
return
135153
self._render()
@@ -145,6 +163,7 @@ def _render(self) -> None:
145163
self._trace_packet,
146164
self._map_dps.overlays,
147165
config=self._config,
166+
robot_at_dock=self._robot_at_dock,
148167
)
149168
except RoborockException as ex:
150169
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)

roborock/map/b01_q10_map_parser.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,18 @@ def classify_q10_cell(value: int) -> str:
9393
# 50 mm/px, so dividing by 10 yields the origin in grid pixels -- the (ox, oy)
9494
# that solve_calibration otherwise recovers by sliding the path.
9595
# - 15-16 resolution (u16be): reads 5 (= 0.05 m/px = 50 mm/px) universally.
96-
# - 17-18 charger x, 19-20 charger y (s16be, 5 mm units), 21-22 charger phi.
96+
# - 17-18 charger x, 19-20 charger y (s16be, absolute map decipixels),
97+
# 21-22 charger phi. The official app's device-point transform confirms these
98+
# coordinates are already in the map-array frame and must not receive x_min /
99+
# y_min a second time.
97100
_ORIGIN_X_OFFSET = 11
98101
_ORIGIN_Y_OFFSET = 13
99102
_HEADER_RESOLUTION_OFFSET = 15
100103
_CHARGER_X_OFFSET = 17
101104
_CHARGER_Y_OFFSET = 19
102105
_CHARGER_PHI_OFFSET = 21
103-
# The header origin/charger are in 5 mm units and the grid is 50 mm/px, so a
104-
# header coordinate maps to grid pixels by dividing by this.
106+
# The header origin and charger fields both divide by 10 to reach grid pixels,
107+
# although they use different coordinate frames as documented above.
105108
_HEADER_UNITS_PER_PIXEL = 10
106109

107110
# Grid cell values >= this are walls / borders rather than room segments.
@@ -149,9 +152,11 @@ class Q10HeaderCalibration:
149152
straight from the map packet -- no cleaning path / fit required, so it works
150153
docked or pre-clean. See :meth:`origin_pixels`.
151154
152-
``origin_x`` / ``origin_y`` and the charger coordinates are in 5 mm units;
153-
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is the
154-
raw dock heading field. Reported and verified by @andrewlyeats (ss07).
155+
``origin_x`` / ``origin_y`` are in 5 mm units and define the world-coordinate
156+
origin. The charger coordinates are absolute decipixels in the map-array
157+
frame, so they are divided by 10 without applying that origin again.
158+
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is
159+
the raw dock heading field. Reported and verified by @andrewlyeats (ss07).
155160
"""
156161

157162
origin_x: int
@@ -177,6 +182,15 @@ def origin_pixels(self) -> tuple[float, float] | None:
177182
return None
178183
return (self.origin_x / _HEADER_UNITS_PER_PIXEL, self.origin_y / _HEADER_UNITS_PER_PIXEL)
179184

185+
def charger_pixels(self) -> tuple[float, float] | None:
186+
"""Return the saved dock in absolute map-array pixel coordinates."""
187+
if (self.charger_x == 0 and self.charger_y == 0) or self.charger_x == -1 or self.charger_y == -1:
188+
return None
189+
return (
190+
self.charger_x / _HEADER_UNITS_PER_PIXEL,
191+
self.charger_y / _HEADER_UNITS_PER_PIXEL,
192+
)
193+
180194

181195
@dataclass
182196
class Q10MapPacket:

roborock/map/b01_q10_render.py

Lines changed: 30 additions & 6 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
@@ -116,8 +119,10 @@ def render_q10_map(
116119
charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None
117120
_place_trace(map_data, trace_calibration, trace, charger_heading=charger_heading)
118121
has_drawables = True
122+
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
119125
if vector_calibration is not None:
120-
has_drawables = _place_charger_from_header(map_data, packet, vector_calibration) or has_drawables
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)
123128
if has_drawables:
@@ -253,14 +258,33 @@ def _place_trace(
253258
def _place_charger_from_header(
254259
map_data: MapData,
255260
packet: Q10MapPacket,
256-
calibration: GridCalibration,
257261
) -> bool:
258-
"""Place the saved dock using the header's 5 mm coordinates."""
262+
"""Place the saved dock using its absolute header pixel coordinates."""
259263
header = packet.header_calibration
260-
if header is None:
264+
if header is None or (position := header.charger_pixels()) is None:
265+
return False
266+
map_data.charger = Point(*position, -header.charger_phi)
267+
return True
268+
269+
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:
261280
return False
262-
px, py = calibration.world_to_pixel(header.charger_x, header.charger_y)
263-
map_data.charger = Point(px, py, calibration.y_sign * header.charger_phi)
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+
)
264288
return True
265289

266290

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
2222
from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create
2323
from roborock.devices.traits.b01.q10.map import MapContentTrait, MapDpsTrait
24+
from roborock.devices.traits.b01.q10.status import StatusTrait
2425
from roborock.exceptions import RoborockException
2526
from roborock.map.b01_q10_map_parser import (
2627
Q10Point,
@@ -375,3 +376,27 @@ def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
375376

376377
assert map_dps.overlays == Q10MapOverlays()
377378
assert not notified
379+
380+
381+
def test_charging_status_renders_robot_at_dock() -> None:
382+
"""Charging status adds the idle robot marker without inventing a path."""
383+
status = StatusTrait()
384+
trait = MapContentTrait(MapDpsTrait(), status)
385+
packet = parse_map_packet(FIXTURE.read_bytes())
386+
notified: list[None] = []
387+
trait.add_update_listener(lambda: notified.append(None))
388+
389+
with patch(
390+
"roborock.devices.traits.b01.q10.map.render_q10_map",
391+
side_effect=[b"map with dock", b"map with docked robot"],
392+
) as render:
393+
trait.update_from_map_packet(packet)
394+
notified.clear()
395+
status.update_from_dps({B01_Q10_DP.STATUS: 8})
396+
status.update_from_dps({B01_Q10_DP.BATTERY: 50})
397+
398+
assert trait.image_content == b"map with docked robot"
399+
assert trait.path == []
400+
assert notified == [None]
401+
assert render.call_count == 2
402+
assert render.call_args.kwargs["robot_at_dock"] is True

tests/map/test_b01_q10_map_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ def test_parse_header_calibration_fields() -> None:
442442
assert not cal.is_keepalive
443443
# 5 mm units / (50 mm/px) -> divide by 10 for grid pixels.
444444
assert cal.origin_pixels() == (-376.0, 192.0)
445+
assert cal.charger_pixels() == (-5.0, 3.0)
445446

446447

447448
def test_parse_header_calibration_keepalive_has_no_origin() -> None:

tests/map/test_b01_q10_render.py

Lines changed: 29 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,8 @@
3234
Q10MapOverlays,
3335
_calibration_from_header_metadata,
3436
_erased_cells,
37+
_place_charger_from_header,
38+
_place_docked_robot,
3539
_vector_calibration,
3640
render_q10_map,
3741
solve_q10_calibration,
@@ -159,6 +163,31 @@ def test_render_draws_dock_from_header_without_trace() -> None:
159163
assert rendered != base
160164

161165

166+
def test_place_charger_uses_absolute_header_pixels() -> None:
167+
"""The dock coordinates do not receive the world origin a second time."""
168+
packet = replace(_packet(), header_calibration=HEADER)
169+
map_data = MapData()
170+
171+
assert _place_charger_from_header(map_data, packet)
172+
173+
assert map_data.charger == Point(3, 3, -90)
174+
175+
176+
def test_place_docked_robot_uses_shared_v1_marker_geometry() -> None:
177+
"""The idle robot sits beside the dock, facing it, without a path."""
178+
map_data = MapData()
179+
map_data.charger = Point(20, 30, 90)
180+
181+
assert _place_docked_robot(map_data)
182+
183+
assert map_data.vacuum_position == Point(
184+
20,
185+
30 - Sizes.SIZES[Size.CHARGER_RADIUS],
186+
90,
187+
)
188+
assert map_data.path is None
189+
190+
162191
def test_render_applies_erase_zones() -> None:
163192
"""With a calibration, erase-zone cells are blanked from the image."""
164193
packet, trace = _calibrated_inputs()

0 commit comments

Comments
 (0)