Skip to content

Commit fdb3a39

Browse files
feat: align Q10 map rendering with V1
1 parent 3c5f788 commit fdb3a39

12 files changed

Lines changed: 472 additions & 164 deletions

File tree

roborock/cli.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,9 @@ async def maps(ctx, device_id: str):
594594
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
595595

596596

597-
# The Q10 pushes its map ~9s after a dpRequestDps; firmware throttles pushes to
598-
# ~once per 60-70s, so a single request is answered quickly but rapid re-requests
599-
# may not be. This bounds how long a one-shot CLI command waits for that push.
597+
# The Q10 publishes its map asynchronously after a dpMultiMap list/get request.
598+
# Firmware throttles pushes to ~once per 60-70s, so rapid re-requests may not be
599+
# answered immediately. This bounds how long a one-shot CLI command waits.
600600
_Q10_MAP_PUSH_TIMEOUT = 30.0
601601

602602

@@ -609,11 +609,11 @@ async def _await_q10_map_push(
609609
) -> bool:
610610
"""Nudge a Q10 to push its map/trace and wait for a fresh update.
611611
612-
The Q10 map API is entirely push-driven: there is no synchronous get-map
613-
request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
614-
which the device's subscribe loop feeds into the map trait. Here we register
615-
an update listener, send the request, and wait for a newly pushed update to
616-
satisfy ``predicate``. Returns whether it did within ``timeout``.
612+
The Q10 map response remains asynchronous: ``refresh`` starts a
613+
``dpMultiMap`` list/get exchange, after which the device publishes a
614+
``MAP_RESPONSE`` that its subscribe loop feeds into the map trait. Here we
615+
register an update listener, send the request, and wait for a newly pushed
616+
update to satisfy ``predicate``. Returns whether it did within ``timeout``.
617617
"""
618618
loop = asyncio.get_running_loop()
619619
updated: asyncio.Future[None] = loop.create_future()

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

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import asyncio
44
import logging
5+
from typing import Any
56

67
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
78
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
89
from roborock.devices.traits import Trait
10+
from roborock.exceptions import RoborockException
911
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
1012
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
1113

@@ -40,6 +42,23 @@
4042
_LOGGER = logging.getLogger(__name__)
4143

4244

45+
def _map_id_from_list_response(response: Any) -> int | str | None:
46+
"""Return the first usable map ID from a ``dpMultiMap`` list response."""
47+
if not isinstance(response, dict) or response.get("op") != "list":
48+
return None
49+
data = response.get("data")
50+
if not isinstance(data, list):
51+
return None
52+
for map_info in data:
53+
if isinstance(map_info, dict):
54+
map_id = map_info.get("id")
55+
else:
56+
map_id = map_info
57+
if isinstance(map_id, (int, str)) and not isinstance(map_id, bool):
58+
return map_id
59+
return None
60+
61+
4362
class Q10PropertiesApi(Trait):
4463
"""API for interacting with B01 devices."""
4564

@@ -115,6 +134,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
115134
self._map_dps,
116135
]
117136
self._subscribe_task: asyncio.Task[None] | None = None
137+
self._map_list_requested = False
118138

119139
async def start(self) -> None:
120140
"""Start any necessary subscriptions for the trait."""
@@ -132,22 +152,42 @@ async def close(self) -> None:
132152

133153
async def refresh(self) -> None:
134154
"""Refresh all traits."""
135-
# Sending the REQUEST_DPS will cause the device to send all DPS values
136-
# to the device. Updates will be received by the subscribe loop below.
155+
# Status and map retrieval use separate Q10 requests. A bare REQUEST_DPS
156+
# reliably refreshes status but does not reliably make every firmware
157+
# publish its map.
137158
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
159+
await self.request_map()
160+
161+
async def request_map(self) -> None:
162+
"""Request the current saved map through the Q10 multi-map protocol.
163+
164+
The list response arrives asynchronously on the subscribe stream.
165+
``_handle_message`` extracts its first map ID and follows up with the
166+
matching ``get`` command; the resulting protocol-301 map packet is
167+
routed to :attr:`map`.
168+
"""
169+
self._map_list_requested = True
170+
try:
171+
await self.command.send(
172+
B01_Q10_DP.COMMON,
173+
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
174+
)
175+
except RoborockException:
176+
self._map_list_requested = False
177+
raise
138178

139179
async def _subscribe_loop(self) -> None:
140180
"""Persistent loop dispatching decoded messages to the read-model traits."""
141181
async for message in self._channel.subscribe_stream():
142-
self._handle_message(message)
182+
await self._handle_message(message)
143183

144-
def _handle_message(self, message: Q10Message) -> None:
184+
async def _handle_message(self, message: Q10Message) -> None:
145185
"""Route a single decoded message to the trait responsible for it.
146186
147-
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes (the
148-
Q10 is entirely push-driven: there is no synchronous get-map request, a
149-
``dpRequestDps`` just nudges the device to publish its current map). DPS
150-
updates feed the read-model traits. More traits can be dispatched here below.
187+
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes.
188+
A ``dpMultiMap`` list response completes the asynchronous request flow
189+
started by :meth:`request_map`; other DPS updates feed the read-model
190+
traits.
151191
"""
152192
if isinstance(message, Q10MapPacket):
153193
self.map.update_from_map_packet(message)
@@ -159,6 +199,32 @@ def _handle_message(self, message: Q10Message) -> None:
159199
# only updates the fields that it is responsible for.
160200
for trait in self._updatable_traits:
161201
trait.update_from_dps(message.dps)
202+
await self._request_map_from_list_response(message.dps)
203+
204+
async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
205+
"""Request map content after receiving our pending map-list response."""
206+
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
207+
if not self._map_list_requested or not isinstance(response, dict) or response.get("op") != "list":
208+
return
209+
210+
self._map_list_requested = False
211+
if (map_id := _map_id_from_list_response(response)) is None:
212+
_LOGGER.debug("Q10 map list response did not contain a usable map ID")
213+
return
214+
215+
try:
216+
await self.command.send(
217+
B01_Q10_DP.COMMON,
218+
{
219+
str(B01_Q10_DP.MULTI_MAP.code): {
220+
"op": "get",
221+
"id": map_id,
222+
}
223+
},
224+
)
225+
except RoborockException as ex:
226+
# A failed follow-up must not kill the persistent subscribe loop.
227+
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
162228

163229

164230
def create(channel: B01Q10Channel) -> Q10PropertiesApi:

roborock/map/b01_q10_map_parser.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Parser for Roborock Q10 (B01/ss07) map packets.
22
3-
Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message (pushed a
4-
few seconds after a ``dpRequestDps`` request). Unlike the Q7 ``SCMap`` protobuf
3+
Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a
4+
``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf
55
format, the Q10 uses a custom, unencrypted binary packet:
66
77
- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
@@ -19,15 +19,15 @@
1919
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
2020
"""
2121

22-
import colorsys
2322
import io
2423
import math
2524
import statistics
2625
from dataclasses import dataclass, field, replace
2726

2827
from PIL import Image
28+
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
2929
from vacuum_map_parser_base.config.image_config import ImageConfig
30-
from vacuum_map_parser_base.map_data import ImageData, MapData
30+
from vacuum_map_parser_base.map_data import ImageData, MapData, Point
3131

3232
from roborock.exceptions import RoborockException
3333

@@ -129,7 +129,8 @@ class Q10EraseZone:
129129
130130
These are the app's *Erase* tool rectangles -- regions the user marked to be
131131
removed from the map (e.g. phantom floor the lidar mapped through windows).
132-
Coordinates are world units (millimetres), same frame as the path/zones.
132+
Coordinates are 5 mm world units, the same frame as restriction zones.
133+
Trace points use a separate 2.5 mm scale.
133134
134135
Confirmed by a controlled diff: removing the two erase zones on a live device
135136
dropped this section's count from 2 to 0 while the grid and the trailing
@@ -639,7 +640,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
639640
width=packet.width,
640641
image_config=ImageConfig(scale=self._config.map_scale),
641642
data=image,
642-
img_transformation=lambda p: p,
643+
# ImageDimensions uses V1's bottom-up map convention before
644+
# projecting into the top-down PNG. Q10 points are already
645+
# top-down grid pixels, so this adapter cancels that standard flip
646+
# and lets Q10 use the shared V1 ImageGenerator without moving
647+
# overlays vertically.
648+
img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
643649
)
644650
room_names = {room.id: room.name for room in packet.rooms}
645651
if room_names:
@@ -655,12 +661,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
655661
return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)
656662

657663
def _render(self, packet: Q10MapPacket) -> Image.Image:
658-
"""Render the Q10 grid: rooms get distinct colors, walls white, rest dark."""
664+
"""Render the Q10 grid with the V1 map palette."""
659665
palette = _build_palette(packet.grid)
660-
rgb = bytearray()
666+
rgba = bytearray()
661667
for value in packet.grid:
662-
rgb.extend(palette[value])
663-
img = Image.frombytes("RGB", (packet.width, packet.height), bytes(rgb))
668+
rgba.extend(palette[value])
669+
img = Image.frombytes("RGBA", (packet.width, packet.height), bytes(rgba))
664670
# The ss07 grid is stored top-down (row 0 = top of the home), so it is
665671
# rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
666672
scale = self._config.map_scale
@@ -669,15 +675,22 @@ def _render(self, packet: Q10MapPacket) -> Image.Image:
669675
return img
670676

671677

672-
def _build_palette(grid: bytes) -> list[tuple[int, int, int]]:
673-
"""Map each grid value to an RGB color (rooms distinct, walls white)."""
674-
palette: list[tuple[int, int, int]] = [(28, 30, 38)] * 256 # default: unknown/outside
675-
room_values = sorted({v for v in set(grid) if 0 < v < _WALL_THRESHOLD})
676-
for index, value in enumerate(room_values):
677-
hue = (index * 0.139) % 1.0
678-
r, g, b = colorsys.hsv_to_rgb(hue, 0.5, 0.95)
679-
palette[value] = (int(r * 255), int(g * 255), int(b * 255))
678+
def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
679+
"""Return a palette color as RGBA."""
680+
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
681+
682+
683+
def _build_palette(grid: bytes) -> list[tuple[int, int, int, int]]:
684+
"""Map Q10 cells onto the same colors used by the V1 map renderer."""
685+
colors = ColorsPalette()
686+
outside = (0, 0, 0, 0)
687+
palette = [outside] * 256
688+
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
689+
palette[value] = _opaque(colors.get_room_color(max(1, value // 4)))
690+
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
680691
for value in range(_WALL_THRESHOLD, 256):
681-
palette[value] = (235, 235, 240) # walls / borders
682-
palette[0] = (28, 30, 38)
692+
palette[value] = wall
693+
palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
694+
palette[_BACKGROUND_VALUE] = outside
695+
palette[0] = outside
683696
return palette

roborock/map/b01_q10_overlays.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
zones (first wire word = x), confirmed against the app. Provenance and the
2020
byte-level breakdown are in PR #850's review thread.
2121
22-
Coordinates are in the device's world units (the same space as the cleaning
23-
path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
24-
map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
25-
threshold, anything else -- incl. 0 -- a no-go zone); it is preserved verbatim
26-
so callers can route polygons to the right ``MapData`` layer.
22+
Coordinates are in 5 mm device world units, the same space as erase polygons.
23+
Cleaning trace points use a separate 2.5 mm scale, so callers must not project
24+
both through the same resolution. ``type`` distinguishes the restriction kind
25+
(2 = no-mop, 3 = door threshold, anything else -- incl. 0 -- a no-go zone); it
26+
is preserved verbatim so callers can route polygons to the right ``MapData``
27+
layer.
2728
"""
2829

2930
import base64

0 commit comments

Comments
 (0)