Skip to content

Commit 6c667d5

Browse files
fix: request Q10 maps through dpMultiMap
1 parent ccf1dbc commit 6c667d5

7 files changed

Lines changed: 373 additions & 33 deletions

File tree

roborock/cli.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -594,26 +594,28 @@ 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

603603
async def _await_q10_map_push(
604604
properties: Q10PropertiesApi,
605605
predicate: Callable[[], bool],
606+
add_source_listener: Callable[[Callable[[], None]], Callable[[], None]],
606607
*,
607608
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
608609
allow_cached_on_timeout: bool = False,
609610
) -> bool:
610611
"""Nudge a Q10 to push its map/trace and wait for a fresh update.
611612
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``.
613+
The Q10 map response remains asynchronous: ``refresh`` starts a
614+
``dpMultiMap`` list/get exchange, after which the device publishes a
615+
``MAP_RESPONSE`` that its subscribe loop feeds into the map trait. Here we
616+
register a packet-specific listener, send the request, and wait for a newly
617+
pushed update to satisfy ``predicate``. Returns whether it did within
618+
``timeout``.
617619
"""
618620
loop = asyncio.get_running_loop()
619621
updated: asyncio.Future[None] = loop.create_future()
@@ -622,7 +624,7 @@ def on_update() -> None:
622624
if predicate() and not updated.done():
623625
updated.set_result(None)
624626

625-
unsub = properties.map.add_update_listener(on_update)
627+
unsub = add_source_listener(on_update)
626628
try:
627629
await properties.refresh()
628630
await asyncio.wait_for(updated, timeout=timeout)
@@ -648,6 +650,7 @@ async def map_image(ctx, device_id: str, output_file: str):
648650
await _await_q10_map_push(
649651
properties,
650652
lambda: properties.map.image_content is not None,
653+
properties.map._add_map_packet_listener,
651654
allow_cached_on_timeout=True,
652655
)
653656
image_content = properties.map.image_content
@@ -706,7 +709,11 @@ async def q10_position(ctx, device_id: str, include_path: bool):
706709
click.echo("Feature not supported by device")
707710
return
708711
properties = device.b01_q10_properties
709-
got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path))
712+
got_trace = await _await_q10_map_push(
713+
properties,
714+
lambda: bool(properties.map.path),
715+
properties.map._add_trace_packet_listener,
716+
)
710717
if not got_trace:
711718
click.echo("No live trace available (the robot only reports position while cleaning).")
712719
return
@@ -871,6 +878,7 @@ async def rooms(ctx, device_id: str):
871878
await _await_q10_map_push(
872879
properties,
873880
lambda: properties.map.image_content is not None,
881+
properties.map._add_map_packet_listener,
874882
allow_cached_on_timeout=True,
875883
)
876884
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))

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

Lines changed: 98 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

@@ -38,6 +40,25 @@
3840
]
3941

4042
_LOGGER = logging.getLogger(__name__)
43+
_MAP_LIST_REQUEST_TIMEOUT = 30.0
44+
45+
46+
def _map_id_from_list_response(response: Any) -> int | str | None:
47+
"""Return the first usable map ID from a ``dpMultiMap`` list response."""
48+
if not isinstance(response, dict) or response.get("op") != "list":
49+
return None
50+
data = response.get("data")
51+
if not isinstance(data, list):
52+
return None
53+
for map_info in data:
54+
if not isinstance(map_info, dict):
55+
continue
56+
map_id = map_info.get("id")
57+
if isinstance(map_id, int) and not isinstance(map_id, bool):
58+
return map_id
59+
if isinstance(map_id, str) and map_id:
60+
return map_id
61+
return None
4162

4263

4364
class Q10PropertiesApi(Trait):
@@ -115,6 +136,9 @@ def __init__(self, channel: B01Q10Channel) -> None:
115136
self._map_dps,
116137
]
117138
self._subscribe_task: asyncio.Task[None] | None = None
139+
self._map_request_lock = asyncio.Lock()
140+
self._map_list_request_token: object | None = None
141+
self._map_list_requested_at: float | None = None
118142

119143
async def start(self) -> None:
120144
"""Start any necessary subscriptions for the trait."""
@@ -132,22 +156,55 @@ async def close(self) -> None:
132156

133157
async def refresh(self) -> None:
134158
"""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.
159+
# Status and map retrieval use separate Q10 requests. A bare REQUEST_DPS
160+
# reliably refreshes status but does not reliably make every firmware
161+
# publish its map.
137162
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
163+
await self.request_map()
164+
165+
async def request_map(self) -> None:
166+
"""Request the current saved map through the Q10 multi-map protocol.
167+
168+
The list response arrives asynchronously on the subscribe stream.
169+
``_handle_message`` extracts its first map ID and follows up with the
170+
matching ``get`` command; the resulting protocol-301 map packet is
171+
routed to :attr:`map`.
172+
"""
173+
async with self._map_request_lock:
174+
now = asyncio.get_running_loop().time()
175+
if (
176+
self._map_list_request_token is not None
177+
and self._map_list_requested_at is not None
178+
and now - self._map_list_requested_at < _MAP_LIST_REQUEST_TIMEOUT
179+
):
180+
return
181+
token = object()
182+
self._map_list_request_token = token
183+
self._map_list_requested_at = now
184+
sent = False
185+
try:
186+
await self.command.send(
187+
B01_Q10_DP.COMMON,
188+
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
189+
)
190+
sent = True
191+
finally:
192+
if not sent and self._map_list_request_token is token:
193+
self._map_list_request_token = None
194+
self._map_list_requested_at = None
138195

139196
async def _subscribe_loop(self) -> None:
140197
"""Persistent loop dispatching decoded messages to the read-model traits."""
141198
async for message in self._channel.subscribe_stream():
142-
self._handle_message(message)
199+
await self._handle_message(message)
143200

144-
def _handle_message(self, message: Q10Message) -> None:
201+
async def _handle_message(self, message: Q10Message) -> None:
145202
"""Route a single decoded message to the trait responsible for it.
146203
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.
204+
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes.
205+
A ``dpMultiMap`` list response completes the asynchronous request flow
206+
started by :meth:`request_map`; other DPS updates feed the read-model
207+
traits.
151208
"""
152209
if isinstance(message, Q10MapPacket):
153210
self.map.update_from_map_packet(message)
@@ -159,6 +216,39 @@ def _handle_message(self, message: Q10Message) -> None:
159216
# only updates the fields that it is responsible for.
160217
for trait in self._updatable_traits:
161218
trait.update_from_dps(message.dps)
219+
await self._request_map_from_list_response(message.dps)
220+
221+
async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
222+
"""Request map content after receiving our pending map-list response."""
223+
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
224+
if self._map_list_request_token is None or not isinstance(response, dict) or response.get("op") != "list":
225+
return
226+
227+
token = self._map_list_request_token
228+
if (map_id := _map_id_from_list_response(response)) is None:
229+
if self._map_list_request_token is token:
230+
self._map_list_request_token = None
231+
self._map_list_requested_at = None
232+
_LOGGER.debug("Q10 map list response did not contain a usable map ID")
233+
return
234+
235+
try:
236+
await self.command.send(
237+
B01_Q10_DP.COMMON,
238+
{
239+
str(B01_Q10_DP.MULTI_MAP.code): {
240+
"op": "get",
241+
"id": map_id,
242+
}
243+
},
244+
)
245+
except RoborockException as ex:
246+
# A failed follow-up must not kill the persistent subscribe loop.
247+
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
248+
finally:
249+
if self._map_list_request_token is token:
250+
self._map_list_request_token = None
251+
self._map_list_requested_at = None
162252

163253

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

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
"""
1515

1616
import logging
17+
from collections.abc import Callable
1718
from dataclasses import dataclass, field
1819
from typing import Any
1920

21+
from roborock.callbacks import CallbackList
2022
from roborock.data import RoborockBase
2123
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
2224
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
@@ -89,6 +91,8 @@ def __init__(
8991
self._map_packet: Q10MapPacket | None = None
9092
self._trace_packet: Q10TracePacket | None = None
9193
self._image_content: bytes | None = None
94+
self._map_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
95+
self._trace_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
9296
self._map_dps.add_update_listener(self._map_dps_updated)
9397

9498
@property
@@ -121,12 +125,22 @@ def update_from_map_packet(self, packet: Q10MapPacket) -> None:
121125
self._map_packet = packet
122126
self._render()
123127
self._notify_update()
128+
self._map_packet_callbacks(None)
124129

125130
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
126131
"""Store a trace-protocol update and render the latest sources."""
127132
self._trace_packet = packet
128133
self._render()
129134
self._notify_update()
135+
self._trace_packet_callbacks(None)
136+
137+
def _add_map_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
138+
"""Register an internal callback for decoded map packets."""
139+
return self._map_packet_callbacks.add_callback(lambda _: callback())
140+
141+
def _add_trace_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
142+
"""Register an internal callback for decoded trace packets."""
143+
return self._trace_packet_callbacks.add_callback(lambda _: callback())
130144

131145
def _map_dps_updated(self) -> None:
132146
"""Render after the low-level DPS source changes."""

roborock/map/b01_q10_map_parser.py

Lines changed: 2 additions & 2 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

0 commit comments

Comments
 (0)