Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,26 +594,28 @@ async def maps(ctx, device_id: str):
await _display_v1_trait(context, device_id, lambda v1: v1.maps)


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


async def _await_q10_map_push(
properties: Q10PropertiesApi,
predicate: Callable[[], bool],
add_source_listener: Callable[[Callable[[], None]], Callable[[], None]],
*,
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
allow_cached_on_timeout: bool = False,
) -> bool:
"""Nudge a Q10 to push its map/trace and wait for a fresh update.

The Q10 map API is entirely push-driven: there is no synchronous get-map
request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
which the device's subscribe loop feeds into the map trait. Here we register
an update listener, send the request, and wait for a newly pushed update to
satisfy ``predicate``. Returns whether it did within ``timeout``.
The Q10 map response remains asynchronous: ``refresh`` starts a
``dpMultiMap`` list/get exchange, after which the device publishes a
``MAP_RESPONSE`` that its subscribe loop feeds into the map trait. Here we
register a packet-specific listener, send the request, and wait for a newly
pushed update to satisfy ``predicate``. Returns whether it did within
``timeout``.
"""
loop = asyncio.get_running_loop()
updated: asyncio.Future[None] = loop.create_future()
Expand All @@ -622,7 +624,7 @@ def on_update() -> None:
if predicate() and not updated.done():
updated.set_result(None)

unsub = properties.map.add_update_listener(on_update)
unsub = add_source_listener(on_update)
try:
await properties.refresh()
await asyncio.wait_for(updated, timeout=timeout)
Expand All @@ -648,6 +650,7 @@ async def map_image(ctx, device_id: str, output_file: str):
await _await_q10_map_push(
properties,
lambda: properties.map.image_content is not None,
properties.map._add_map_packet_listener,
allow_cached_on_timeout=True,
)
image_content = properties.map.image_content
Expand Down Expand Up @@ -706,7 +709,11 @@ async def q10_position(ctx, device_id: str, include_path: bool):
click.echo("Feature not supported by device")
return
properties = device.b01_q10_properties
got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path))
got_trace = await _await_q10_map_push(
properties,
lambda: bool(properties.map.path),
properties.map._add_trace_packet_listener,
)
if not got_trace:
click.echo("No live trace available (the robot only reports position while cleaning).")
return
Expand Down Expand Up @@ -871,6 +878,7 @@ async def rooms(ctx, device_id: str):
await _await_q10_map_push(
properties,
lambda: properties.map.image_content is not None,
properties.map._add_map_packet_listener,
allow_cached_on_timeout=True,
)
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))
Expand Down
106 changes: 98 additions & 8 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import asyncio
import logging
from typing import Any

from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
from roborock.devices.traits import Trait
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message

Expand Down Expand Up @@ -38,6 +40,25 @@
]

_LOGGER = logging.getLogger(__name__)
_MAP_LIST_REQUEST_TIMEOUT = 30.0


def _map_id_from_list_response(response: Any) -> int | str | None:
"""Return the first usable map ID from a ``dpMultiMap`` list response."""
if not isinstance(response, dict) or response.get("op") != "list":
return None
data = response.get("data")
if not isinstance(data, list):
return None
for map_info in data:
if not isinstance(map_info, dict):
continue
map_id = map_info.get("id")
if isinstance(map_id, int) and not isinstance(map_id, bool):
return map_id
if isinstance(map_id, str) and map_id:
return map_id
return None


class Q10PropertiesApi(Trait):
Expand Down Expand Up @@ -115,6 +136,9 @@ def __init__(self, channel: B01Q10Channel) -> None:
self._map_dps,
]
self._subscribe_task: asyncio.Task[None] | None = None
self._map_request_lock = asyncio.Lock()
self._map_list_request_token: object | None = None
self._map_list_requested_at: float | None = None

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

async def refresh(self) -> None:
"""Refresh all traits."""
# Sending the REQUEST_DPS will cause the device to send all DPS values
# to the device. Updates will be received by the subscribe loop below.
# Status and map retrieval use separate Q10 requests. A bare REQUEST_DPS
# reliably refreshes status but does not reliably make every firmware
# publish its map.
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
await self.request_map()

async def request_map(self) -> None:
"""Request the current saved map through the Q10 multi-map protocol.

The list response arrives asynchronously on the subscribe stream.
``_handle_message`` extracts its first map ID and follows up with the
matching ``get`` command; the resulting protocol-301 map packet is
routed to :attr:`map`.
"""
async with self._map_request_lock:
now = asyncio.get_running_loop().time()
if (
self._map_list_request_token is not None
and self._map_list_requested_at is not None
and now - self._map_list_requested_at < _MAP_LIST_REQUEST_TIMEOUT
):
return
token = object()
self._map_list_request_token = token
self._map_list_requested_at = now
sent = False
try:
await self.command.send(
B01_Q10_DP.COMMON,
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
)
sent = True
finally:
if not sent and self._map_list_request_token is token:
self._map_list_request_token = None
self._map_list_requested_at = None

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

def _handle_message(self, message: Q10Message) -> None:
async def _handle_message(self, message: Q10Message) -> None:
"""Route a single decoded message to the trait responsible for it.

Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes (the
Q10 is entirely push-driven: there is no synchronous get-map request, a
``dpRequestDps`` just nudges the device to publish its current map). DPS
updates feed the read-model traits. More traits can be dispatched here below.
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes.
A ``dpMultiMap`` list response completes the asynchronous request flow
started by :meth:`request_map`; other DPS updates feed the read-model
traits.
"""
if isinstance(message, Q10MapPacket):
self.map.update_from_map_packet(message)
Expand All @@ -159,6 +216,39 @@ def _handle_message(self, message: Q10Message) -> None:
# only updates the fields that it is responsible for.
for trait in self._updatable_traits:
trait.update_from_dps(message.dps)
await self._request_map_from_list_response(message.dps)

async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
"""Request map content after receiving our pending map-list response."""
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
if self._map_list_request_token is None or not isinstance(response, dict) or response.get("op") != "list":
return

token = self._map_list_request_token
if (map_id := _map_id_from_list_response(response)) is None:
if self._map_list_request_token is token:
self._map_list_request_token = None
self._map_list_requested_at = None
_LOGGER.debug("Q10 map list response did not contain a usable map ID")
return

try:
await self.command.send(
B01_Q10_DP.COMMON,
{
str(B01_Q10_DP.MULTI_MAP.code): {
"op": "get",
"id": map_id,
}
},
)
except RoborockException as ex:
# A failed follow-up must not kill the persistent subscribe loop.
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
finally:
if self._map_list_request_token is token:
self._map_list_request_token = None
self._map_list_requested_at = None


def create(channel: B01Q10Channel) -> Q10PropertiesApi:
Expand Down
14 changes: 14 additions & 0 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
"""

import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any

from roborock.callbacks import CallbackList
from roborock.data import RoborockBase
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
Expand Down Expand Up @@ -89,6 +91,8 @@ def __init__(
self._map_packet: Q10MapPacket | None = None
self._trace_packet: Q10TracePacket | None = None
self._image_content: bytes | None = None
self._map_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
self._trace_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
self._map_dps.add_update_listener(self._map_dps_updated)

@property
Expand Down Expand Up @@ -121,12 +125,22 @@ def update_from_map_packet(self, packet: Q10MapPacket) -> None:
self._map_packet = packet
self._render()
self._notify_update()
self._map_packet_callbacks(None)

def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
"""Store a trace-protocol update and render the latest sources."""
self._trace_packet = packet
self._render()
self._notify_update()
self._trace_packet_callbacks(None)

def _add_map_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
"""Register an internal callback for decoded map packets."""
return self._map_packet_callbacks.add_callback(lambda _: callback())

def _add_trace_packet_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
"""Register an internal callback for decoded trace packets."""
return self._trace_packet_callbacks.add_callback(lambda _: callback())

def _map_dps_updated(self) -> None:
"""Render after the low-level DPS source changes."""
Expand Down
4 changes: 2 additions & 2 deletions roborock/map/b01_q10_map_parser.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Parser for Roborock Q10 (B01/ss07) map packets.

Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message (pushed a
few seconds after a ``dpRequestDps`` request). Unlike the Q7 ``SCMap`` protobuf
Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a
``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf
format, the Q10 uses a custom, unencrypted binary packet:

- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
Expand Down
Loading
Loading