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
46 changes: 33 additions & 13 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,26 +594,25 @@ 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.
"""Request Q10 map content and wait for a fresh map or trace packet.

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``.
A Q10 needs a saved-map ID before it can request content. The map list and
content have independent refresh schedules, so the list is requested only
when no ID is stored. The content then arrives as a later ``MAP_RESPONSE``.
"""
loop = asyncio.get_running_loop()
updated: asyncio.Future[None] = loop.create_future()
Expand All @@ -622,10 +621,25 @@ 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)
async with asyncio.timeout(timeout):
if properties.maps.current_map_id is None:
map_list_updated: asyncio.Future[None] = loop.create_future()

def on_map_list_update() -> None:
if properties.maps.current_map_id is not None and not map_list_updated.done():
map_list_updated.set_result(None)

unsub_maps = properties.maps.add_update_listener(on_map_list_update)
try:
await properties.maps.refresh()
if properties.maps.current_map_id is None:
await map_list_updated
finally:
unsub_maps()
await properties.map.refresh()
await updated
return True
except TimeoutError:
return allow_cached_on_timeout and predicate()
Expand All @@ -648,6 +662,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 +721,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 +890,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
19 changes: 12 additions & 7 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .do_not_disturb import DoNotDisturbTrait
from .dust_collection import DustCollectionTrait
from .map import MapContentTrait, MapDpsTrait
from .maps import MapsTrait
from .network_info import NetworkInfoTrait
from .remote import RemoteTrait
from .status import StatusTrait
Expand All @@ -32,6 +33,7 @@
"DoNotDisturbTrait",
"DustCollectionTrait",
"MapContentTrait",
"MapsTrait",
"NetworkInfoTrait",
"SoundVolumeTrait",
"StatusTrait",
Expand Down Expand Up @@ -79,6 +81,9 @@ class Q10PropertiesApi(Trait):
map: MapContentTrait
"""Composed map image plus caller-facing map and trace data."""

maps: MapsTrait
"""Saved-map list metadata."""

_map_dps: MapDpsTrait
"""Private source of restricted zones and virtual walls received through DPS."""

Expand All @@ -100,7 +105,8 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.network_info = NetworkInfoTrait()
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.map = MapContentTrait(self._map_dps)
self.maps = MapsTrait(self.command)
self.map = MapContentTrait(self._map_dps, self.maps, self.command)
self.clean_history = CleanHistoryTrait(self.command)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
Expand All @@ -113,6 +119,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.consumable,
self.clean_history,
self._map_dps,
self.maps,
]
self._subscribe_task: asyncio.Task[None] | None = None

Expand All @@ -132,8 +139,8 @@ 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-list and map-content refreshes have separate schedules.
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})

async def _subscribe_loop(self) -> None:
Expand All @@ -144,10 +151,8 @@ async def _subscribe_loop(self) -> None:
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 and other DPS updates feed the read-model traits.
"""
if isinstance(message, Q10MapPacket):
self.map.update_from_map_packet(message)
Expand Down
50 changes: 44 additions & 6 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
* restricted zones, virtual walls and dock state arrive as ordinary DPS values.

``MapDpsTrait`` owns the low-level map-specific DPS read model.
``MapContentTrait`` combines that state with the latest map/trace packets
through the pure functions in :mod:`roborock.map.b01_q10_render`. The high-level
trait keeps only the latest value from each source and one replace-whole image;
calibration, path placement and overlay placement remain inside the renderer.
``MapContentTrait`` uses a stored ID from ``MapsTrait`` only when it requests
content. It combines the latest map and trace packets with the map DPS state
through the pure functions in :mod:`roborock.map.b01_q10_render`. Map-list
updates do not refresh content.
"""

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, YXDeviceState
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
Expand All @@ -31,7 +33,9 @@
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
from .maps import MapsTrait

_LOGGER = logging.getLogger(__name__)
_DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}
Expand Down Expand Up @@ -80,24 +84,48 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
class MapContentTrait(TraitUpdateListener):
"""High-level composed Q10 map view.

The latest map and trace packets are combined with the injected map DPS
whenever any source changes.
The latest map and trace packets are combined with the injected
:class:`MapDpsTrait` whenever a source changes. The
:class:`MapsTrait` supplies a stored ID only when this trait requests
content.
"""

def __init__(
self,
map_dps: MapDpsTrait,
maps: MapsTrait,
command: CommandTrait,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> None:
TraitUpdateListener.__init__(self, logger=_LOGGER)
self._config = map_parser_config or B01Q10MapParserConfig()
self._map_dps = map_dps
self._maps = maps
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 content for the first map in the latest saved-map list."""
if (map_id := self._maps.current_map_id) is None:
raise RoborockException("Cannot request Q10 map content before the map list is available")
# Map lists and map content can change at different times. Reuse the
# stored ID so a content refresh does not also refresh the list.
await self._command.send(
B01_Q10_DP.COMMON,
{
str(B01_Q10_DP.MULTI_MAP.code): {
"op": "get",
"id": map_id,
}
},
)

@property
def image_content(self) -> bytes | None:
"""The composed map PNG, if the latest map rendered successfully."""
Expand Down Expand Up @@ -128,12 +156,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 map DPS source changes."""
Expand Down
58 changes: 58 additions & 0 deletions roborock/devices/traits/b01/q10/maps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Trait for Q10 saved-map list data."""

import logging
from dataclasses import dataclass, field
from typing import Any

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

from .command import CommandTrait
from .common import UpdatableTrait

_LOGGER = logging.getLogger(__name__)


@dataclass
class Maps(RoborockBase):
"""Saved-map list data from the Q10 DPS stream."""

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

@property
def current_map_id(self) -> str | None:
"""Return the first saved-map ID for a content request, if available."""
if self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1:
return None
return self.multi_map.current_map_id


class MapsTrait(Maps, UpdatableTrait):
"""Request and store the Q10 saved-map list."""

_CONVERTER = DpsDataConverter.from_dataclass(Maps)
_command: CommandTrait

def __init__(self, command: CommandTrait) -> None:
"""Initialize the saved-map list trait."""
Maps.__init__(self)
UpdatableTrait.__init__(self, command, _LOGGER)
self._command = command

async def refresh(self) -> None:
"""Request a new saved-map list from the device."""
await self._command.send(
B01_Q10_DP.COMMON,
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
)

def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
"""Store a successful saved-map list response."""
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
# DP 61 also carries map-content acknowledgements. Ignore them so they
# cannot replace a usable map list with an unrelated response.
if not isinstance(response, dict) or response.get("op") != "list" or response.get("result") != 1:
return
super().update_from_dps(decoded_dps)
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