Skip to content

Commit 2161763

Browse files
committed
feat: render Q10 map obstacle markers
1 parent eaac141 commit 2161763

12 files changed

Lines changed: 338 additions & 34 deletions

File tree

roborock/devices/device_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,12 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
266266
trait = b01.q10.create(
267267
channel,
268268
map_parser_config=(
269-
B01Q10MapParserConfig(map_scale=map_parser_config.map_scale) if map_parser_config else None
269+
B01Q10MapParserConfig(
270+
map_scale=map_parser_config.map_scale,
271+
drawables=map_parser_config.drawables,
272+
)
273+
if map_parser_config
274+
else None
270275
),
271276
)
272277
elif "sc" in model_part:

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Q10HistoricalTracePacket,
2929
Q10MapPacket,
3030
Q10MapPacketKind,
31+
Q10Obstacle,
3132
Q10Point,
3233
)
3334
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
@@ -200,6 +201,11 @@ def detail_path(self) -> list[Q10Point]:
200201
"""Historical path points for the selected clean record."""
201202
return self.detail_trace.points if self.detail_trace else []
202203

204+
@property
205+
def detail_obstacles(self) -> list[Q10Obstacle]:
206+
"""Obstacle markers embedded in the selected clean-record map."""
207+
return list(self.detail_packet.obstacles) if self.detail_packet else []
208+
203209
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
204210
"""Apply a ``dpCleanRecord`` push (a full list reply or a single notify)."""
205211
envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
B01Q10MapParserConfig,
2626
Q10MapPacket,
2727
Q10MapPacketKind,
28+
Q10Obstacle,
2829
Q10Point,
2930
Q10Room,
3031
Q10TracePacket,
@@ -139,6 +140,11 @@ def path(self) -> list[Q10Point]:
139140
"""Full path for live status and callers drawing their own map overlay."""
140141
return self._trace_packet.points if self._trace_packet else []
141142

143+
@property
144+
def obstacles(self) -> list[Q10Obstacle]:
145+
"""Position-only obstacle markers reported by the current map."""
146+
return list(self._map_packet.obstacles) if self._map_packet else []
147+
142148
@property
143149
def robot_position(self) -> Q10Point | None:
144150
"""Current position for live status and caller-rendered map overlays."""
@@ -199,6 +205,7 @@ def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
199205
exclude_set = exclude or set()
200206
data = {
201207
"rooms": [room.as_dict() for room in self.rooms],
208+
"obstacles": [obstacle.as_dict() for obstacle in self.obstacles],
202209
"path": [point.as_dict() for point in self.path],
203210
"robotPosition": self.robot_position.as_dict() if self.robot_position is not None else None,
204211
"robotHeading": self.robot_heading,

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from roborock.data.b01_q10.b01_q10_containers import Q10MapInfo, dpMultiMap
1010
from roborock.devices.traits.common import DpsDataConverter
1111
from roborock.exceptions import RoborockException
12-
from roborock.map.b01_q10_map_parser import B01Q10MapParserConfig, Q10MapPacket, Q10MapPacketKind
12+
from roborock.map.b01_q10_map_parser import (
13+
B01Q10MapParserConfig,
14+
Q10MapPacket,
15+
Q10MapPacketKind,
16+
Q10Obstacle,
17+
)
1318
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
1419

1520
from .command import CommandTrait
@@ -64,6 +69,11 @@ def __init__(
6469
"""Rendered saved-map detail image, if decoding succeeded."""
6570
self._pending_detail_map_id: str | None = None
6671

72+
@property
73+
def detail_obstacles(self) -> list[Q10Obstacle]:
74+
"""Obstacle markers embedded in the selected saved-map preview."""
75+
return list(self.detail_packet.obstacles) if self.detail_packet else []
76+
6777
async def refresh(self) -> None:
6878
"""Request a new saved-map list from the device."""
6979
await self._command.send(

roborock/map/b01_q10_map_parser.py

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
import statistics
2626
import struct
2727
from dataclasses import dataclass, field, replace
28+
from typing import TypeVar
2829

2930
from PIL import Image
3031
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
32+
from vacuum_map_parser_base.config.drawable import Drawable
3133
from vacuum_map_parser_base.config.image_config import ImageConfig
3234
from vacuum_map_parser_base.map_data import ImageData, MapData, Point
3335

@@ -234,6 +236,10 @@ class Q10MapPacket:
234236
carpet (the value is the carpet kind). ``None`` if the packet carried none."""
235237
historical_trace: "Q10HistoricalTracePacket | None" = None
236238
"""Cleaning path embedded in a clean-record detail packet, if present."""
239+
obstacles: list["Q10Obstacle"] = field(default_factory=list)
240+
"""Obstacle markers embedded after the carpet block (50 raw units/pixel)."""
241+
skip_cleaning_points: list["Q10Point"] = field(default_factory=list)
242+
"""Firmware skip-clean markers embedded after obstacles (10 raw units/pixel)."""
237243

238244
@property
239245
def layers(self) -> GridLayers:
@@ -251,6 +257,19 @@ class Q10Point(RoborockBase):
251257
y: int
252258

253259

260+
@dataclass
261+
class Q10Obstacle(Q10Point):
262+
"""A Q10 map obstacle marker in its raw map-package coordinate frame.
263+
264+
The map package supplies positions only: there is no validated type,
265+
confidence, or photo identifier on this model. Fifty raw units equal one
266+
occupancy-grid pixel; placement is anchored by the map header origin.
267+
"""
268+
269+
270+
_PointType = TypeVar("_PointType", bound=Q10Point)
271+
272+
254273
@dataclass
255274
class Q10TracePacket:
256275
"""Decoded contents of a Q10 ``02 01`` cleaning-path packet.
@@ -332,13 +351,12 @@ def robot_position(self) -> Q10Point | None:
332351
_TRACE_POINT_COUNT_OFFSET = 8
333352
_TRACE_HEADING_OFFSET = 10
334353

335-
_HISTORICAL_TRACE_HEADER_LENGTH = 14
336-
_HISTORICAL_TRACE_PREFIX_LENGTH = 1
354+
_HISTORICAL_TRACE_HEADER_LENGTH = 13
337355
_HISTORICAL_TRACE_VERSION = 1
338-
_HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 2
339-
_HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6
340-
_HISTORICAL_TRACE_HEADING_OFFSET = 10
341-
_HISTORICAL_TRACE_RESERVED_OFFSET = 12
356+
_HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 1
357+
_HISTORICAL_TRACE_POINT_COUNT_OFFSET = 5
358+
_HISTORICAL_TRACE_HEADING_OFFSET = 9
359+
_HISTORICAL_TRACE_RESERVED_OFFSET = 11
342360

343361
# Some cleans still prepend a single near-origin sentinel as the first real
344362
# point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800));
@@ -565,10 +583,18 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
565583
tail = payload[layout_end:]
566584
erase_zones = _parse_erase_zones(tail)
567585
carpet_mask, carpet_end = _parse_carpet_block(tail, width, height)
568-
if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL and carpet_end is not None:
569-
historical_trace, _ = _parse_clean_record_trace(tail, carpet_end)
570-
else:
571-
historical_trace = None
586+
obstacles: list[Q10Obstacle] = []
587+
skip_cleaning_points: list[Q10Point] = []
588+
historical_trace = None
589+
if carpet_end is not None:
590+
parsed_obstacles, obstacle_end = _parse_counted_points(tail, carpet_end, Q10Obstacle)
591+
if obstacle_end is not None:
592+
parsed_skip_points, skip_end = _parse_counted_points(tail, obstacle_end, Q10Point)
593+
if skip_end is not None:
594+
obstacles = parsed_obstacles
595+
skip_cleaning_points = parsed_skip_points
596+
if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL:
597+
historical_trace, _ = _parse_clean_record_trace(tail, skip_end)
572598
header_calibration = _parse_header_calibration(payload)
573599
return Q10MapPacket(
574600
kind=kind,
@@ -580,6 +606,8 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
580606
erase_zones=erase_zones,
581607
header_calibration=header_calibration,
582608
carpet_mask=carpet_mask,
609+
obstacles=obstacles,
610+
skip_cleaning_points=skip_cleaning_points,
583611
historical_trace=historical_trace,
584612
)
585613

@@ -701,6 +729,29 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
701729
return _parse_carpet_block(tail, width, height)[0]
702730

703731

732+
def _parse_counted_points(
733+
tail: bytes,
734+
offset: int,
735+
point_type: type[_PointType],
736+
) -> tuple[list[_PointType], int | None]:
737+
"""Decode one bounded ``u8 count`` + signed-BE ``(x, y)`` point table.
738+
739+
Obstacle and skip-clean sections use the same framing but different
740+
coordinate scales. The caller owns those semantics; this helper only
741+
validates and decodes the table atomically. A truncated table returns no
742+
points and no end offset, preventing later sections from being misaligned.
743+
"""
744+
if offset >= len(tail):
745+
return [], None
746+
count = tail[offset]
747+
points_start = offset + 1
748+
points_end = points_start + count * 4
749+
if points_end > len(tail):
750+
return [], None
751+
coordinates = struct.iter_unpack(">hh", memoryview(tail)[points_start:points_end])
752+
return ([point_type(x=x, y=y) for x, y in coordinates], points_end)
753+
754+
704755
def _parse_clean_record_trace(
705756
tail: bytes,
706757
offset: int,
@@ -709,20 +760,17 @@ def _parse_clean_record_trace(
709760
710761
The header and declared point count were validated against a physical ss07
711762
clean-record response and its point bytes match captured prefixes of the
712-
corresponding live trace exactly. One observed zero byte precedes the path;
713-
its meaning is unknown, so a non-zero value makes the entire section opaque.
714-
Any unsupported version, non-zero reserved word, or truncated point table is
715-
likewise left completely opaque. Bytes after the declared points are
716-
deliberately not consumed: the observed 12-byte suffix appears structured,
717-
but there is not enough controlled evidence to name or decode it safely.
763+
corresponding live trace exactly. The caller first consumes the obstacle
764+
and skip-clean point tables; ``offset`` therefore starts at the one-byte
765+
path version. Any unsupported version, non-zero reserved word, or truncated
766+
point table is left completely opaque. Bytes after the declared points are
767+
deliberately not consumed: the observed invariant 12-byte suffix appears
768+
structured, but controlled captures disprove it as per-clean obstacles.
718769
"""
719-
if offset >= len(tail) or tail[offset] != 0:
720-
return None, None
721-
offset += _HISTORICAL_TRACE_PREFIX_LENGTH
722770
header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH
723771
if header_end > len(tail):
724772
return None, None
725-
version = int.from_bytes(tail[offset : offset + 2], "big")
773+
version = tail[offset]
726774
reserved = int.from_bytes(
727775
tail[offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2],
728776
"big",
@@ -778,6 +826,8 @@ class B01Q10MapParserConfig:
778826

779827
map_scale: int = 4
780828
"""Scale factor for the rendered map image."""
829+
drawables: list[Drawable] | None = None
830+
"""Enabled map overlays, or ``None`` for the Q10 defaults."""
781831

782832

783833
class B01Q10MapParser:

roborock/map/b01_q10_render.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from vacuum_map_parser_base.config.drawable import Drawable
2222
from vacuum_map_parser_base.config.size import Size, Sizes
23-
from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
23+
from vacuum_map_parser_base.map_data import Area, MapData, Obstacle, ObstacleDetails, Path, Point, Wall
2424

2525
from roborock.exceptions import RoborockException
2626

@@ -67,6 +67,7 @@
6767
Drawable.CHARGER,
6868
Drawable.NO_GO_AREAS,
6969
Drawable.NO_MOPPING_AREAS,
70+
Drawable.OBSTACLES,
7071
Drawable.PATH,
7172
Drawable.VACUUM_POSITION,
7273
Drawable.VIRTUAL_WALLS,
@@ -114,7 +115,7 @@ def render_q10_map(
114115
raise RoborockException("Failed to render Q10 map image")
115116
map_data = parsed.map_data
116117

117-
has_drawables = False
118+
has_drawables = _place_obstacles(map_data, packet)
118119
if trace_calibration is not None and trace is not None:
119120
charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None
120121
_place_trace(map_data, trace_calibration, trace, charger_heading=charger_heading)
@@ -131,6 +132,30 @@ def render_q10_map(
131132
return parsed.image_content
132133

133134

135+
def _obstacle_calibration(packet: Q10MapPacket) -> GridCalibration | None:
136+
"""Build the obstacle-table transform from the map header.
137+
138+
Q10 obstacle positions use 50 raw units per occupancy-grid pixel, unlike
139+
the 5 mm restriction vectors and 2.5 mm cleaning traces.
140+
"""
141+
header = packet.header_calibration
142+
if header is None or (origin := header.origin_pixels()) is None:
143+
return None
144+
return GridCalibration(resolution=50.0, origin_x=origin[0], origin_y=origin[1], y_sign=1)
145+
146+
147+
def _place_obstacles(map_data: MapData, packet: Q10MapPacket) -> bool:
148+
"""Project position-only Q10 obstacle markers into shared ``MapData``."""
149+
calibration = _obstacle_calibration(packet)
150+
if calibration is None or not packet.obstacles:
151+
return False
152+
map_data.obstacles = [
153+
Obstacle(*calibration.world_to_pixel(obstacle.x, obstacle.y), ObstacleDetails())
154+
for obstacle in packet.obstacles
155+
]
156+
return True
157+
158+
134159
def solve_q10_calibration(
135160
packet: Q10MapPacket,
136161
trace: Q10TracePacket | Q10HistoricalTracePacket | None,
@@ -326,10 +351,10 @@ def _draw_map_content(
326351
"""Draw Q10 content with the shared V1 image generator."""
327352
if map_data.image is None:
328353
raise RoborockException("Failed to render Q10 map image")
329-
generator = _create_image_generator(
330-
MapParserConfig(map_scale=config.map_scale),
331-
drawables=_Q10_DRAWABLES,
354+
drawables = (
355+
_Q10_DRAWABLES if config.drawables is None else [d for d in config.drawables if d in _Q10_DRAWABLE_TYPES]
332356
)
357+
generator = _create_image_generator(MapParserConfig(map_scale=config.map_scale), drawables=drawables)
333358
generator.draw_map(map_data)
334359
buffer = io.BytesIO()
335360
map_data.image.data.save(buffer, format="PNG")

tests/devices/test_device_manager.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88

99
import pytest
1010
import syrupy
11+
from vacuum_map_parser_base.config.drawable import Drawable
1112

1213
from roborock.data import HomeData, UserData
1314
from roborock.data.containers import HomeDataDevice, HomeDataProduct, RoborockCategory
1415
from roborock.devices.cache import InMemoryCache
1516
from roborock.devices.device import RoborockDevice
1617
from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper
18+
from roborock.devices.traits.b01.q10 import create as create_q10
1719
from roborock.exceptions import RoborockException, RoborockInvalidCredentials
20+
from roborock.map.map_parser import MapParserConfig
1821
from roborock.testing import FakeRoborockCloud, Q10VacuumSimulator, V1VacuumSimulator
1922
from tests import mock_data
2023

@@ -85,7 +88,12 @@ async def test_with_q10_device(cloud: FakeRoborockCloud, patch_device_manager: N
8588
)
8689
cloud.add_device(q10_sim)
8790

88-
device_manager = await create_device_manager(USER_PARAMS)
91+
map_parser_config = MapParserConfig(drawables=[Drawable.OBSTACLES], map_scale=2)
92+
with patch("roborock.devices.device_manager.b01.q10.create", wraps=create_q10) as q10_create:
93+
device_manager = await create_device_manager(USER_PARAMS, map_parser_config=map_parser_config)
94+
q10_config = q10_create.call_args.kwargs["map_parser_config"]
95+
assert q10_config.map_scale == 2
96+
assert q10_config.drawables == [Drawable.OBSTACLES]
8997
devices = await device_manager.get_devices()
9098

9199
# The setup includes fake_device (V1) by default because of the fake_device fixture

tests/devices/traits/b01/q10/__snapshots__/test_status.ambr

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
'dustSwitch': True,
2525
}),
2626
'map': dict({
27+
'obstacles': list([
28+
]),
2729
'path': list([
2830
]),
2931
'robotHeading': None,
@@ -404,6 +406,8 @@
404406
'dustSwitch': True,
405407
}),
406408
'map': dict({
409+
'obstacles': list([
410+
]),
407411
'path': list([
408412
]),
409413
'robotHeading': None,
@@ -480,6 +484,8 @@
480484
'dust_collection': dict({
481485
}),
482486
'map': dict({
487+
'obstacles': list([
488+
]),
483489
'path': list([
484490
]),
485491
'robotHeading': None,

0 commit comments

Comments
 (0)