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
42 changes: 16 additions & 26 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,50 +594,37 @@ async def maps(ctx, device_id: str):
await _display_v1_trait(context, device_id, lambda v1: v1.maps)


# 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
# The Q10 publishes its current map asynchronously after a REQUEST_DPS. 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 = 75.0


async def _await_q10_map_push(
properties: Q10PropertiesApi,
predicate: Callable[[], bool],
revision: Callable[[], int],
*,
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
allow_cached_on_timeout: bool = False,
) -> bool:
"""Request Q10 map content and wait for usable map-trait state.

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``
and is published through the standard trait update interface.
The read-only ``REQUEST_DPS`` request returns immediately; current map
content arrives as a later ``MAP_RESPONSE`` and is published through the
standard trait update interface.
"""
loop = asyncio.get_running_loop()
updated: asyncio.Future[None] = loop.create_future()
initial_revision = revision()

def on_update() -> None:
if predicate() and not updated.done():
if revision() > initial_revision and predicate() and not updated.done():
updated.set_result(None)

unsub = properties.map.add_update_listener(on_update)
try:
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
Expand All @@ -662,6 +649,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,
lambda: properties.map.map_revision,
allow_cached_on_timeout=True,
)
image_content = properties.map.image_content
Expand Down Expand Up @@ -710,8 +698,8 @@ async def map_data(ctx, device_id: str, include_path: bool):
async def q10_position(ctx, device_id: str, include_path: bool):
"""Get the current Q10 robot position and live cleaning path.

The Q10 only streams its position/path while it is actively cleaning, so this
will report that no live trace is available for an idle/docked robot.
The Q10 normally streams position/path while it is actively cleaning, so an
idle device may report that no fresh live trace is available.
"""
context: RoborockContext = ctx.obj
device_manager = await context.get_device_manager()
Expand All @@ -723,9 +711,10 @@ async def q10_position(ctx, device_id: str, include_path: bool):
got_trace = await _await_q10_map_push(
properties,
lambda: bool(properties.map.path),
lambda: properties.map.trace_revision,
)
if not got_trace:
click.echo("No live trace available (the robot only reports position while cleaning).")
click.echo("No fresh live trace available.")
return
map_trait = properties.map
position = map_trait.robot_position
Expand Down Expand Up @@ -888,6 +877,7 @@ async def rooms(ctx, device_id: str):
await _await_q10_map_push(
properties,
lambda: properties.map.image_content is not None,
lambda: properties.map.map_revision,
allow_cached_on_timeout=True,
)
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))
Expand Down
4 changes: 3 additions & 1 deletion roborock/data/b01_q10/b01_q10_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ 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.
value is sent back unchanged in a subsequent ``{"op": "select"}`` detail
request. On Q10 firmware, ``select`` previews a saved map without applying
it as the active map.
"""

id: str
Expand Down
13 changes: 12 additions & 1 deletion roborock/devices/device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from roborock.devices.device import DeviceReadyCallback, RoborockDevice
from roborock.diagnostics import Diagnostics, redact_device_data
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import B01Q10MapParserConfig
from roborock.map.map_parser import MapParserConfig
from roborock.mqtt.roborock_session import create_lazy_mqtt_session
from roborock.mqtt.session import MqttSession, SessionUnauthorizedHook
Expand Down Expand Up @@ -262,7 +263,17 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
if "ss" in model_part:
b01_q10_channel = create_b01_q10_channel(mqtt_channel)
channel = b01_q10_channel
trait = b01.q10.create(channel)
trait = b01.q10.create(
channel,
map_parser_config=(
B01Q10MapParserConfig(
map_scale=map_parser_config.map_scale,
drawables=map_parser_config.drawables,
)
if map_parser_config
else None
),
)
elif "sc" in model_part:
# Q7 devices start with 'sc' in their model naming.
b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
Expand Down
45 changes: 37 additions & 8 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from roborock.data.containers import RoborockBase
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
from roborock.devices.traits import Trait
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
from roborock.map.b01_q10_map_parser import (
B01Q10MapParserConfig,
Q10MapPacket,
Q10MapPacketKind,
Q10TracePacket,
)
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message

from .button_light import ButtonLightTrait
Expand Down Expand Up @@ -92,7 +97,12 @@ class Q10PropertiesApi(Trait):
clean_history: CleanHistoryTrait
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""

def __init__(self, channel: B01Q10Channel) -> None:
def __init__(
self,
channel: B01Q10Channel,
*,
map_parser_config: B01Q10MapParserConfig,
) -> None:
"""Initialize the B01Props API."""
self._channel = channel
self.command = CommandTrait(channel)
Expand All @@ -107,9 +117,16 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.network_info = NetworkInfoTrait()
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.maps = MapsTrait(self.command)
self.map = MapContentTrait(self._map_dps, self.maps, self.command)
self.clean_history = CleanHistoryTrait(self.command)
self.maps = MapsTrait(self.command, map_parser_config=map_parser_config)
self.map = MapContentTrait(
self._map_dps,
self.command,
map_parser_config=map_parser_config,
)
self.clean_history = CleanHistoryTrait(
self.command,
map_parser_config=map_parser_config,
)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
self.status,
Expand Down Expand Up @@ -157,7 +174,12 @@ def _handle_message(self, message: Q10Message) -> None:
Map-list DPS responses and other DPS updates feed the read-model traits.
"""
if isinstance(message, Q10MapPacket):
self.map.update_from_map_packet(message)
if message.kind is Q10MapPacketKind.CURRENT:
self.map.update_from_map_packet(message)
elif message.kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL:
self.clean_history.update_from_map_packet(message)
elif message.kind is Q10MapPacketKind.SAVED_MAP_DETAIL:
self.maps.update_from_map_packet(message)
elif isinstance(message, Q10TracePacket):
self.map.update_from_trace_packet(message)
elif isinstance(message, Q10DpsUpdate):
Expand All @@ -178,6 +200,13 @@ def as_dict(self) -> dict[str, Any]:
return result


def create(channel: B01Q10Channel) -> Q10PropertiesApi:
def create(
channel: B01Q10Channel,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> Q10PropertiesApi:
"""Create traits for B01 devices."""
return Q10PropertiesApi(channel)
return Q10PropertiesApi(
channel,
map_parser_config=map_parser_config or B01Q10MapParserConfig(),
)
90 changes: 87 additions & 3 deletions roborock/devices/traits/b01/q10/clean_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
YXStartMethod,
)
from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import (
B01Q10MapParserConfig,
Q10HistoricalTracePacket,
Q10MapPacket,
Q10MapPacketKind,
Q10Obstacle,
Q10Point,
)
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map

from .command import CommandTrait
from .common import UpdatableTrait
Expand Down Expand Up @@ -115,12 +125,28 @@ class CleanHistoryTrait(UpdatableTrait):
or a single ``op:"notify"`` record) rather than a flat data-point-to-field map.
"""

def __init__(self, command: CommandTrait) -> None:
_command: CommandTrait

def __init__(
self,
command: CommandTrait,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> None:
"""Initialize the clean history trait."""
UpdatableTrait.__init__(self, command, _LOGGER)
self._command = command
self._converter = CleanRecordConverter()
self._map_parser_config = map_parser_config or B01Q10MapParserConfig()
self.records: list[Q10CleanRecord] = []
"""Decoded clean records, most recent first."""
self.detail_packet: Q10MapPacket | None = None
"""Most recently pushed ``03 01`` clean-record map detail."""
self.detail_record: Q10CleanRecord | None = None
"""Record associated with :attr:`detail_packet`, when requested here."""
self.detail_image_content: bytes | None = None
"""Rendered clean-record detail image, if decoding succeeded."""
self._pending_detail_record: Q10CleanRecord | None = None

@property
def last_record(self) -> Q10CleanRecord | None:
Expand All @@ -134,13 +160,52 @@ async def refresh(self) -> None:
asynchronously on the device stream and populate :attr:`records` once
:meth:`update_from_dps` processes the ``dpCleanRecord`` push.
"""
if self._command is None:
raise ValueError("Trait is read-only; no command channel was provided")
await self._command.send(
B01_Q10_DP.COMMON,
params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}},
)

async def refresh_detail(self, record: Q10CleanRecord) -> None:
"""Request the saved map and path for one clean record.

The complete 12-field raw record is the firmware's detail identifier;
the shorter human-facing record ID is not accepted. Only one request
may be outstanding because ``03 01`` responses carry no correlation ID.
"""
if not record.raw or not record.map_len:
raise RoborockException("The Q10 clean record has no saved map detail")
if self._pending_detail_record is not None:
raise RoborockException("A Q10 clean-record detail request is already pending")
self._pending_detail_record = record
try:
await self._command.send(
B01_Q10_DP.COMMON,
params={
str(B01_Q10_DP.CLEAN_RECORD.code): {
"op": "select",
"id": record.raw,
}
},
)
except RoborockException:
self._pending_detail_record = None
raise

@property
def detail_trace(self) -> Q10HistoricalTracePacket | None:
"""Historical path embedded in the selected clean-record detail."""
return self.detail_packet.historical_trace if self.detail_packet else None

@property
def detail_path(self) -> list[Q10Point]:
"""Historical path points for the selected clean record."""
return self.detail_trace.points if self.detail_trace else []

@property
def detail_obstacles(self) -> list[Q10Obstacle]:
"""Obstacle markers embedded in the selected clean-record map."""
return list(self.detail_packet.obstacles) if self.detail_packet else []

def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
"""Apply a ``dpCleanRecord`` push (a full list reply or a single notify)."""
envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD)
Expand All @@ -151,6 +216,25 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
return
self._apply(push)

def update_from_map_packet(self, packet: Q10MapPacket) -> None:
"""Store and render a pushed clean-record detail map."""
if packet.kind is not Q10MapPacketKind.CLEAN_RECORD_DETAIL:
raise ValueError(f"Expected a Q10 clean-record detail packet, got {packet.kind.value}")
self.detail_record = self._pending_detail_record
self._pending_detail_record = None
self.detail_packet = packet
try:
self.detail_image_content = render_q10_map(
packet,
packet.historical_trace,
Q10MapOverlays(),
config=self._map_parser_config,
)
except RoborockException:
_LOGGER.debug("Failed to render Q10 clean-record detail", exc_info=True)
self.detail_image_content = None
self._notify_update()

def _apply(self, push: CleanRecordPush) -> None:
"""Merge or replace the records from ``push``, then sort newest-first and notify."""
if push.replace:
Expand Down
Loading
Loading