-
Notifications
You must be signed in to change notification settings - Fork 94
feat: add Q10 zone, position, and goto support #908
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| 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: | ||
|
|
@@ -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 | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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. | ||
|
|
@@ -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. | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should:
|
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will _async_monitor_goto_target effectively do the same thing?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
|
@@ -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. | ||
|
|
||
There was a problem hiding this comment.
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_positiionandroborock_positionfrom 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.