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
3 changes: 2 additions & 1 deletion roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ def __init__(self, channel: B01Q10Channel) -> None:
"""Initialize the B01Props API."""
self._channel = channel
self.command = CommandTrait(channel)
self.vacuum = VacuumTrait(self.command)
self.remote = RemoteTrait(self.command)
self.status = StatusTrait()
self.volume = SoundVolumeTrait(self.command)
Expand All @@ -101,6 +100,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.map = MapContentTrait(self._map_dps)
self.vacuum = VacuumTrait(self.command, self.status, self.map)
self.clean_history = CleanHistoryTrait(self.command)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
Expand All @@ -122,6 +122,7 @@ async def start(self) -> None:

async def close(self) -> None:
"""Close any resources held by the trait."""
await self.vacuum.close()
if self._subscribe_task is not None:
self._subscribe_task.cancel()
try:
Expand Down
18 changes: 18 additions & 0 deletions roborock/devices/traits/b01/q10/coordinates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Coordinate conversion helpers for Q10 B01 devices."""

# Q10 trace coordinates are relative to the dock and use 2.5 mm units. The
# public Roborock actions use millimetres with the dock at (25500, 25500).
ROBOROCK_COORDINATE_OFFSET = 25500
Q10_TRACE_UNIT_MM = 2.5
# Zone and restriction vectors use 5 mm units in the same dock-relative frame.
Q10_VECTOR_UNIT_MM = 5


def trace_to_roborock_coordinate(value: int) -> int:
"""Convert a Q10 trace value to the common Roborock coordinate space."""
return round(ROBOROCK_COORDINATE_OFFSET + value * Q10_TRACE_UNIT_MM)


def roborock_to_vector_coordinate(value: int) -> int:
"""Convert a common Roborock coordinate to the Q10 vector format."""
return round((value - ROBOROCK_COORDINATE_OFFSET) / Q10_VECTOR_UNIT_MM)
16 changes: 16 additions & 0 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map

from .common import UpdatableTrait
from .coordinates import trace_to_roborock_coordinate

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -111,6 +112,21 @@ def robot_position(self) -> Q10Point | None:
"""Current position for live status and caller-rendered map overlays."""
return self._trace_packet.robot_position if self._trace_packet else None

@property
def roborock_position(self) -> Q10Point | None:
"""Current position in the common Roborock millimetre coordinate space."""

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.

You have information written in the PR, but it should also be here (e.g. Whats the difference between roborock_positiion and roborock_position from an end user perspective? how should I be using either of these?)

But really, I would advocate for taking a more opinionated stance: Should we make a breaking change here and always use the common coordinate system? We can consider the Q10 specific thing an implementation.

if (position := self.robot_position) is None:
return None
return Q10Point(
x=trace_to_roborock_coordinate(position.x),
y=trace_to_roborock_coordinate(position.y),
)

@property
def trace_sequence(self) -> int | None:
"""Current cleaning-session sequence from the trace stream."""
return self._trace_packet.sequence if self._trace_packet else None

@property
def robot_heading(self) -> int | None:
"""Current heading for orienting a robot marker on a caller-rendered map."""
Expand Down
237 changes: 236 additions & 1 deletion roborock/devices/traits/b01/q10/vacuum.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
"""Traits for Q10 B01 devices."""

import asyncio
import logging
from base64 import b64encode
from math import hypot
from struct import error as StructError
from struct import pack

from roborock.data.b01_q10.b01_q10_code_mappings import (
B01_Q10_DP,
YXCleanType,
YXDeviceCleanTask,
YXDeviceState,
YXFanLevel,
)
from roborock.exceptions import RoborockException

from .command import CommandTrait
from .coordinates import roborock_to_vector_coordinate
from .map import MapContentTrait
from .status import StatusTrait

_ZONE_NAME_FIELD_LENGTH = 19
_GOTO_HALF_ZONE_SIZE = 200
_GOTO_TOLERANCE = 200
_GOTO_TIMEOUT = 300
_GOTO_RETRY_INTERVAL = 1

_LOGGER = logging.getLogger(__name__)


def _encode_zone(x1: int, y1: int, x2: int, y2: int, clean_count: int) -> str:
"""Encode one rectangular Q10 cleaning zone."""
if not 1 <= clean_count <= 3:
raise ValueError("clean_count must be between 1 and 3")

min_x, max_x = sorted((x1, x2))
min_y, max_y = sorted((y1, y2))
points = (
(min_x, min_y),
(max_x, min_y),
(max_x, max_y),
(min_x, max_y),
)
payload = bytearray((1, clean_count, 1, len(points)))
try:
for point_x, point_y in points:
payload.extend(
pack(
">hh",
roborock_to_vector_coordinate(point_x),
roborock_to_vector_coordinate(point_y),
)
)
except StructError as err:
raise ValueError("zone coordinates are outside the supported range") from err

# The app protocol reserves a fixed 19-byte UTF-8 name field per zone.
payload.append(0)
payload.extend(bytes(_ZONE_NAME_FIELD_LENGTH))
return b64encode(payload).decode()


class VacuumTrait:
Expand All @@ -17,9 +69,128 @@ class VacuumTrait:
commands to Q10 devices.
"""

def __init__(self, command: CommandTrait) -> None:
def __init__(
self,
command: CommandTrait,
status: StatusTrait,
map_content: MapContentTrait,
) -> None:
"""Initialize the VacuumTrait."""
self._command = command
self._status = status
self._map = map_content
self._goto_monitor_task: asyncio.Task[None] | None = None
self._goto_trace_sequence: int | None = None

async def close(self) -> None:
"""Cancel background work owned by the trait."""
if (task := self._goto_monitor_task) is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._goto_monitor_task = None
self._goto_trace_sequence = None

def cancel_goto(self) -> None:
"""Cancel monitoring for an emulated goto replaced by another command."""
if self._goto_monitor_task is not None:
self._goto_monitor_task.cancel()
self._goto_monitor_task = None
self._goto_trace_sequence = 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.

Overall I like this approach of listening for map and status events. Are you finding that results are streamed back reliably such that it works without additional polling?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, at least on my ss07 running firmware 03.11.24. During the live goto tests, the trace and status updates arrived reliably enough to detect arrival and pause the robot. We did not use periodic polling or call refresh() from the goto monitor.

async def _async_monitor_goto_target(

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.

A lot of the logic in this method is related to understanding if the current task is allowed to change state -- and it's intermixed with the other business logic for trying to coordinate the vacuum move. I think we can simplify this by having a separate standalone object that manages a single goto lifecycle (e.g. GotoAction. The action can handle the logic for the next step to take given the information it has (e.g. checking current status of the vaccum, and deciding when to stop) but keep the actions handled by vacuum.py which manages the lifecycle of GotoAction -- it would register listeners/callbacks on the GotoAction then do the work to call the "pause" or "stop" command.

self,
x: int,
y: int,
previous_trace_sequence: int | None,
) -> None:
"""Pause the owned mini-zone task after it reaches the target."""
current_task = asyncio.current_task()
owned_trace_sequence: int | None = None
owned_task_seen = False
update_event = asyncio.Event()
remove_map_listener = self._map.add_update_listener(update_event.set)
remove_status_listener = self._status.add_update_listener(update_event.set)
try:
async with asyncio.timeout(_GOTO_TIMEOUT):
while True:
trace_sequence = self._map.trace_sequence
if owned_trace_sequence is None:
if trace_sequence is not None and trace_sequence != previous_trace_sequence:
owned_trace_sequence = trace_sequence
self._goto_trace_sequence = trace_sequence
elif trace_sequence != owned_trace_sequence:
_LOGGER.debug("Q10 goto task was replaced by another cleaning session")
return

if (
owned_trace_sequence is not None
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
and self._status.status
not in {
YXDeviceState.IDLE,
YXDeviceState.PAUSED,
YXDeviceState.RETURNING_HOME,
YXDeviceState.CHARGING,
}
):
owned_task_seen = True

if owned_task_seen and self._status.clean_task_type is not YXDeviceCleanTask.DIVIDE_AREAS:
_LOGGER.debug("Q10 goto task was replaced by another task type")
return

if owned_task_seen and self._status.status in {
YXDeviceState.IDLE,
YXDeviceState.PAUSED,
YXDeviceState.RETURNING_HOME,
YXDeviceState.CHARGING,
}:
return

if (
owned_trace_sequence is not None
and (position := self._map.roborock_position) is not None
and hypot(position.x - x, position.y - y) <= _GOTO_TOLERANCE
):
try:
await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
except RoborockException as err:
_LOGGER.warning("Failed to pause completed Q10 goto task; retrying: %s", err)
else:
return

update_event.clear()
try:
async with asyncio.timeout(_GOTO_RETRY_INTERVAL):
await update_event.wait()
except TimeoutError:
pass
except TimeoutError:
if (
owned_trace_sequence is not None
and self._map.trace_sequence == owned_trace_sequence
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
):
_LOGGER.warning(
"Q10 vacuum did not reach goto target (%s, %s) within %s seconds; stopping zone task",
x,
y,
_GOTO_TIMEOUT,
)
try:
await self._command.send(command=B01_Q10_DP.STOP, params=0)
except RoborockException as err:
_LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err)
finally:
remove_map_listener()
remove_status_listener()
if self._goto_monitor_task is current_task:
self._goto_monitor_task = None
self._goto_trace_sequence = None

async def start_clean(self) -> None:
"""Start a whole-home clean.
Expand All @@ -34,6 +205,7 @@ async def start_clean(self) -> None:
whole-home clean (clean_task_type -> 1).
"""
await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
self.cancel_goto()

async def clean_segments(self, segment_ids: list[int]) -> None:
"""Start a room / segment clean for the given segment (room) ids.
Expand All @@ -55,25 +227,87 @@ async def clean_segments(self, segment_ids: list[int]) -> None:
# "parameters" -- the firmware only accepts that exact key.
params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
)
self.cancel_goto()

async def clean_zone(
self,
x1: int,

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.

I think we should:

  • Define a new variation of Q10Point that is in roborock coordinate space (and we say Q10 is in trace space) e.g. Q10RoborockPoint (or some better name). This can be used for input here and output (e.g. exposed current position)
  • Accept Q10RoborockPoint (or new name here).
  • Q10Point and Q10RoborockPoint can have functions for converting between each other (e.g. handling the roborock to vector coordinate functions)
  • Then for our internal start command params add a CleanParams dataclass object that holds the roborock points or q10 points, and handles sorting them.
  • Move the struct packing in roborock/protocols/b01_q10_protocol.py e.g. def encode_clean_params(params: CleanParams) -> str

y1: int,
x2: int,
y2: int,
*,
clean_count: int = 1,
) -> None:
"""Clean one rectangular zone in the common Roborock coordinate space."""
encoded_zone = _encode_zone(x1, y1, x2, y2, clean_count)
await self._command.send(
command=B01_Q10_DP.START_CLEAN,
params={
"cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
# "clean_paramters" is the spelling required by the firmware.
"clean_paramters": encoded_zone,
},
)
self.cancel_goto()

async def goto_position(self, x: int, y: int) -> None:
"""Move to a coordinate using an owned 40 cm zone-clean task."""
if (position := self._map.roborock_position) is not None and hypot(

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.

Will _async_monitor_goto_target effectively do the same thing?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not entirely, as I understand it. The monitor pauses the goto it already owns when the robot reaches that goto’s original target. This early check also avoids starting a new zone when the robot is already at the requested position. It additionally handles a new goto request for the robot’s current position while another owned goto is still active, even if the previous target was different.

I think the comment below addresses this as well, right?

position.x - x, position.y - y
) <= _GOTO_TOLERANCE:
if (
self._goto_monitor_task is not None
and self._goto_trace_sequence is not None
and self._map.trace_sequence == self._goto_trace_sequence
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
):
await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
self.cancel_goto()
return

previous_trace_sequence = self._map.trace_sequence
encoded_zone = _encode_zone(
x - _GOTO_HALF_ZONE_SIZE,
y - _GOTO_HALF_ZONE_SIZE,
x + _GOTO_HALF_ZONE_SIZE,
y + _GOTO_HALF_ZONE_SIZE,
1,
)
await self._command.send(
command=B01_Q10_DP.START_CLEAN,
params={
"cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
"clean_paramters": encoded_zone,
},
)
self.cancel_goto()
self._goto_monitor_task = asyncio.create_task(
self._async_monitor_goto_target(x, y, previous_trace_sequence),
name="roborock_q10_goto",
)

async def spot_clean(self) -> None:
"""Start a spot / part clean around the robot's current position.

Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5).
"""
await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5)
self.cancel_goto()

async def pause_clean(self) -> None:
"""Pause the current task. Verified live: ``{"dps": {"204": 0}}``."""
await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
self.cancel_goto()

async def resume_clean(self) -> None:
"""Resume a paused task. Verified live: ``{"dps": {"205": 0}}``."""
await self._command.send(command=B01_Q10_DP.RESUME, params=0)
self.cancel_goto()

async def stop_clean(self) -> None:
"""Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``."""
await self._command.send(command=B01_Q10_DP.STOP, params=0)
self.cancel_goto()

async def return_to_dock(self) -> None:
"""Send the robot back to the dock to charge.
Expand All @@ -84,6 +318,7 @@ async def return_to_dock(self) -> None:
wash mop en route and ``4`` = collect dust en route.)
"""
await self._command.send(command=B01_Q10_DP.START_BACK, params=5)
self.cancel_goto()

async def empty_dustbin(self) -> None:
"""Empty the dustbin at the dock.
Expand Down
3 changes: 3 additions & 0 deletions tests/devices/traits/b01/q10/test_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ def test_update_from_trace_packet_populates_path_and_position() -> None:
assert (trait.path[0].x, trait.path[0].y) == (41, 64)
assert trait.robot_position is not None
assert (trait.robot_position.x, trait.robot_position.y) == (276, -1)
assert trait.roborock_position is not None
assert (trait.roborock_position.x, trait.roborock_position.y) == (26190, 25498)
assert trait.trace_sequence == trace.sequence
assert trait.robot_heading == -34
assert len(updates) == 1

Expand Down
Loading
Loading