Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
30 changes: 19 additions & 11 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,9 +624,9 @@ 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 properties.map.refresh()
await asyncio.wait_for(updated, timeout=timeout)
return True
except TimeoutError:
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
23 changes: 22 additions & 1 deletion roborock/data/b01_q10/b01_q10_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,32 @@ def start_datetime(self) -> datetime.datetime | None:
return None


@dataclass
class Q10MapInfo(RoborockBase):
"""A saved map reported by ``dpMultiMap``.

Q10 firmware represents the map identifier as a string on the wire. The
value is sent back unchanged in a subsequent ``{"op": "get"}`` request.
"""

id: str
name: str | None = None
timestamp: int | None = None


@dataclass
class dpMultiMap(RoborockBase):
"""Response envelope for the Q10 ``dpMultiMap`` data point."""

op: str
result: int
data: list
data: list[Q10MapInfo] = field(default_factory=list)

@property
def current_map_id(self) -> str | None:
"""Return the first saved-map identifier, if one was reported."""
first = next((map_info for map_info in self.data if isinstance(map_info, Q10MapInfo) and map_info.id), None)
return first.id if first else None


@dataclass
Expand Down
18 changes: 9 additions & 9 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.network_info = NetworkInfoTrait()
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.map = MapContentTrait(self._map_dps)
self.map = MapContentTrait(self._map_dps, self.command)
self.clean_history = CleanHistoryTrait(self.command)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
Expand Down Expand Up @@ -132,22 +132,21 @@ 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.
# Sending REQUEST_DPS causes the device to publish its ordinary status
# values. Map refreshes have their own cadence through ``map.refresh()``.
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})

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.
Map-list DPS responses are handed to the map trait; other DPS updates
feed the read-model traits.
"""
if isinstance(message, Q10MapPacket):
self.map.update_from_map_packet(message)
Expand All @@ -159,6 +158,7 @@ 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.map.update_from_dps(message.dps)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense for map to be at the end of the list of _updatable_traits?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its DPS update is synchronous. Q10PropertiesApi can then request the map content after the list update.



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

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.data.b01_q10.b01_q10_containers import dpMultiMap
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import (
Expand All @@ -31,6 +34,7 @@
from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map

from .command import CommandTrait
from .common import UpdatableTrait

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -70,27 +74,73 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
self._notify_update()


class MapContentTrait(TraitUpdateListener):
@dataclass
class MapListDps(RoborockBase):
"""Typed ``dpMultiMap`` state delivered through the Q10 DPS stream."""

multi_map: dpMultiMap | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP})


class MapContentTrait(MapListDps, TraitUpdateListener):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My previous comment to move things here may have been based on a misunderstanding of what is happening. Perhaps you can explain the overview of whats happening here. I see multiple requests happening... One is to refresh the list of maps and one is to get the map content?

In v1 we use separate traits for these separate concepts:

  • maps: gets the list of maps
  • map_content: gets the image byte content of for the current map
  • home: combines all the data from rooms/maps/etc to hold state e.g.in case different map flags are loaded etc

It seems like we're moving to do something more like that here were we need muliple things, but i'm not sure so I don't want to give you bad advice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code combines two separate operations:
dpMultiMap with op: list requests the map list. The list arrives later in the DPS stream.
The code reads the first string map ID and sends dpMultiMap with op: get.
The get command does not contain the map data. The map data arrives later as a protocol-301 MAP_RESPONSE.
I combined these operations in MapContentTrait because the first response is asynchronous. However, this mixes the map-list state with the map-content state.
I propose this split:
MapsTrait owns the map list, the typed converter, and the list request.
MapContentTrait owns the get request, map packets, and rendered content.
Q10PropertiesApi connects the asynchronous map-list response to the map-content request. This has the same role as the V1 home-level coordination.
Does this match the design that you expect?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having split responsibilities like that seems good to me.

However on implementation details: I don't think we necessarily need to coordinate a map list with async updating map content. My take is that map lists may or may not change over time independent of map content. There may be a dependency, but only initially? I do understand that loading a new map can invalidate the map image which can be handled when accessing the map content.

Thank you for the thoughtful consideration on approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The map list is only a dependency when no map ID is available or when the selected map changes. Map content can otherwise refresh independently with the stored map ID.

So let's split the responsibilities:

  • MapsTrait owns the map list
  • MapContentTrait gets content for the stored map ID
  • Q10PropertiesApi does not request content after each list update
  • I will also require CommandTrait and add MapsTrait to _updatable_traits

"""High-level composed Q10 map view.

The latest map and trace packets are combined with the injected
:class:`MapDpsTrait` whenever any of those three sources changes.
"""

_CONVERTER = DpsDataConverter.from_dataclass(MapListDps)

def __init__(
self,
map_dps: MapDpsTrait,
command: CommandTrait | None = None,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> None:
MapListDps.__init__(self)
TraitUpdateListener.__init__(self, logger=_LOGGER)
self._config = map_parser_config or B01Q10MapParserConfig()
self._map_dps = map_dps
self._command = command
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)

async def refresh(self) -> None:
"""Request the current saved map independently of general status."""
if self._command is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a case that would never happen give it is always passed into the constructor. Update the typing to be CommandTrait?

raise ValueError("Trait is read-only; no command channel was provided")
await self._command.send(
B01_Q10_DP.COMMON,
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
)

async def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
"""Request map content when a typed ``dpMultiMap`` list response arrives."""
if not self._CONVERTER.update_from_dps(self, decoded_dps):
return
if self._command is None or self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1:
return
if (map_id := self.multi_map.current_map_id) is None:
_LOGGER.debug("Q10 map list response did not contain a 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)

@property
def image_content(self) -> bytes | None:
"""The composed map PNG, if the latest map rendered successfully."""
Expand Down Expand Up @@ -121,12 +171,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