From 0ada4c3922742d07c6530c5c87ed3aeacad9aa69 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:39:00 +0200 Subject: [PATCH 1/2] feat: add Q10 zone cleaning and position coordinates --- .../devices/traits/b01/q10/coordinates.py | 18 ++++++ roborock/devices/traits/b01/q10/map.py | 11 ++++ roborock/devices/traits/b01/q10/vacuum.py | 58 +++++++++++++++++++ tests/devices/traits/b01/q10/test_map.py | 2 + tests/devices/traits/b01/q10/test_vacuum.py | 49 ++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 roborock/devices/traits/b01/q10/coordinates.py diff --git a/roborock/devices/traits/b01/q10/coordinates.py b/roborock/devices/traits/b01/q10/coordinates.py new file mode 100644 index 00000000..7c48e932 --- /dev/null +++ b/roborock/devices/traits/b01/q10/coordinates.py @@ -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) diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index ee51352a..247af0af 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -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__) @@ -111,6 +112,16 @@ 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.""" + 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 robot_heading(self) -> int | None: """Current heading for orienting a robot marker on a caller-rendered map.""" diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index 2747e024..c1dc9610 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -1,5 +1,9 @@ """Traits for Q10 B01 devices.""" +from base64 import b64encode +from struct import error as StructError +from struct import pack + from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, YXCleanType, @@ -8,6 +12,41 @@ ) from .command import CommandTrait +from .coordinates import roborock_to_vector_coordinate + +_ZONE_NAME_FIELD_LENGTH = 19 + + +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: @@ -56,6 +95,25 @@ async def clean_segments(self, segment_ids: list[int]) -> None: params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids}, ) + async def clean_zone( + self, + x1: int, + y1: int, + x2: int, + y2: int, + *, + clean_count: int = 1, + ) -> None: + """Clean one rectangular zone in the common Roborock coordinate space.""" + 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": _encode_zone(x1, y1, x2, y2, clean_count), + }, + ) + async def spot_clean(self) -> None: """Start a spot / part clean around the robot's current position. diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 61120a7e..63ee7eb3 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -79,6 +79,8 @@ 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.robot_heading == -34 assert len(updates) == 1 diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 9ce448bf..240eeb2f 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -1,3 +1,4 @@ +from base64 import b64decode from collections.abc import Awaitable, Callable from typing import Any @@ -49,3 +50,51 @@ async def test_vacuum_commands( assert command.code == dp_code assert params == expected_params + + +async def test_clean_zone( + vacuum: VacuumTrait, + fake_channel: FakeB01Q10Channel, +) -> None: + """Test the source-verified Q10 zone payload.""" + await vacuum.clean_zone(25550, 25600, 25650, 25700, clean_count=2) + + command, params = fake_channel.published_commands[0] + assert command.code == 201 + assert params["cmd"] == 3 + assert b64decode(params["clean_paramters"]) == bytes( + ( + 1, + 2, + 1, + 4, + 0, + 10, + 0, + 20, + 0, + 30, + 0, + 20, + 0, + 30, + 0, + 40, + 0, + 10, + 0, + 40, + 0, + *([0] * 19), + ) + ) + + +@pytest.mark.parametrize("clean_count", [0, 4]) +async def test_clean_zone_rejects_invalid_clean_count( + vacuum: VacuumTrait, + clean_count: int, +) -> None: + """Test that the device clean-count range is validated.""" + with pytest.raises(ValueError, match="clean_count must be between 1 and 3"): + await vacuum.clean_zone(25550, 25600, 25650, 25700, clean_count=clean_count) From 01d1fc5d0f5550bfdbe16670263dace7282c3be7 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:07:04 +0200 Subject: [PATCH 2/2] feat: add safe Q10 goto lifecycle --- roborock/devices/traits/b01/q10/__init__.py | 3 +- roborock/devices/traits/b01/q10/map.py | 5 + roborock/devices/traits/b01/q10/vacuum.py | 181 +++++++++++++++++++- tests/devices/traits/b01/q10/test_map.py | 1 + tests/devices/traits/b01/q10/test_vacuum.py | 130 +++++++++++++- 5 files changed, 316 insertions(+), 4 deletions(-) diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 3c8c73ff..15215475 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -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) @@ -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 = [ @@ -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: diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index 247af0af..85dd7077 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -122,6 +122,11 @@ def roborock_position(self) -> Q10Point | None: 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.""" diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index c1dc9610..bd312eec 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -1,6 +1,9 @@ """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 @@ -8,13 +11,23 @@ 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: @@ -56,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 + + async def _async_monitor_goto_target( + 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. @@ -73,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. @@ -94,6 +227,7 @@ 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, @@ -105,14 +239,52 @@ async def clean_zone( 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": _encode_zone(x1, y1, x2, y2, clean_count), + "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( + 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. @@ -120,18 +292,22 @@ async def spot_clean(self) -> None: 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. @@ -142,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. diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 63ee7eb3..4f619827 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -81,6 +81,7 @@ def test_update_from_trace_packet_populates_path_and_position() -> 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 diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 240eeb2f..b7f53371 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -1,12 +1,23 @@ +import asyncio from base64 import b64decode from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import AsyncMock import pytest -from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType, YXFanLevel +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXCleanType, + YXDeviceCleanTask, + YXDeviceState, + YXFanLevel, +) from roborock.devices.traits.b01.q10 import Q10PropertiesApi +from roborock.devices.traits.b01.q10 import vacuum as vacuum_module from roborock.devices.traits.b01.q10.vacuum import VacuumTrait +from roborock.exceptions import RoborockException +from roborock.map.b01_q10_map_parser import Q10Point, Q10TracePacket from .conftest import FakeB01Q10Channel @@ -98,3 +109,120 @@ async def test_clean_zone_rejects_invalid_clean_count( """Test that the device clean-count range is validated.""" with pytest.raises(ValueError, match="clean_count must be between 1 and 3"): await vacuum.clean_zone(25550, 25600, 25650, 25700, clean_count=clean_count) + + +async def test_goto_position_pauses_owned_zone_at_target( + q10_api: Q10PropertiesApi, + fake_channel: FakeB01Q10Channel, +) -> None: + """A goto pauses after its own trace session reaches the target.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(0, 0)], sequence=1)) + q10_api.status.clean_task_type = YXDeviceCleanTask.DIVIDE_AREAS + q10_api.status.status = YXDeviceState.CLEANING + + await q10_api.vacuum.goto_position(29900, 28650) + monitor = q10_api.vacuum._goto_monitor_task + assert monitor is not None + + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1760, 1260)], sequence=2)) + await monitor + + assert [command for command, _ in fake_channel.published_commands] == [ + B01_Q10_DP.START_CLEAN, + B01_Q10_DP.PAUSE, + ] + + +async def test_goto_position_does_not_pause_replacement_session( + q10_api: Q10PropertiesApi, + fake_channel: FakeB01Q10Channel, +) -> None: + """A newer trace session is not controlled by an older goto monitor.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(0, 0)], sequence=1)) + q10_api.status.clean_task_type = YXDeviceCleanTask.DIVIDE_AREAS + q10_api.status.status = YXDeviceState.CLEANING + + await q10_api.vacuum.goto_position(29900, 28650) + monitor = q10_api.vacuum._goto_monitor_task + assert monitor is not None + + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(100, 100)], sequence=2)) + await asyncio.sleep(0) + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1760, 1260)], sequence=3)) + await monitor + + assert [command for command, _ in fake_channel.published_commands] == [B01_Q10_DP.START_CLEAN] + + +async def test_goto_position_retries_pause( + q10_api: Q10PropertiesApi, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A transient pause failure is retried while the goto is still owned.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(0, 0)], sequence=1)) + q10_api.status.clean_task_type = YXDeviceCleanTask.DIVIDE_AREAS + q10_api.status.status = YXDeviceState.CLEANING + send = AsyncMock(side_effect=[None, RoborockException("pause failed"), None]) + q10_api.vacuum._command.send = send + monkeypatch.setattr(vacuum_module, "_GOTO_RETRY_INTERVAL", 0) + + await q10_api.vacuum.goto_position(29900, 28650) + monitor = q10_api.vacuum._goto_monitor_task + assert monitor is not None + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(1760, 1260)], sequence=2)) + await monitor + + assert [call.kwargs["command"] for call in send.await_args_list] == [ + B01_Q10_DP.START_CLEAN, + B01_Q10_DP.PAUSE, + B01_Q10_DP.PAUSE, + ] + + +async def test_goto_position_stops_owned_zone_after_timeout( + q10_api: Q10PropertiesApi, + fake_channel: FakeB01Q10Channel, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The safety timeout stops only the zone session owned by the goto.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(0, 0)], sequence=1)) + q10_api.status.clean_task_type = YXDeviceCleanTask.DIVIDE_AREAS + q10_api.status.status = YXDeviceState.CLEANING + monkeypatch.setattr(vacuum_module, "_GOTO_TIMEOUT", 0.01) + monkeypatch.setattr(vacuum_module, "_GOTO_RETRY_INTERVAL", 0) + + await q10_api.vacuum.goto_position(29900, 28650) + monitor = q10_api.vacuum._goto_monitor_task + assert monitor is not None + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(100, 100)], sequence=2)) + await monitor + + assert [command for command, _ in fake_channel.published_commands] == [ + B01_Q10_DP.START_CLEAN, + B01_Q10_DP.STOP, + ] + + +async def test_goto_position_at_current_position_pauses_owned_zone( + q10_api: Q10PropertiesApi, + fake_channel: FakeB01Q10Channel, +) -> None: + """An early return pauses an active goto zone instead of orphaning it.""" + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(0, 0)], sequence=1)) + q10_api.status.clean_task_type = YXDeviceCleanTask.DIVIDE_AREAS + q10_api.status.status = YXDeviceState.CLEANING + await q10_api.vacuum.goto_position(29900, 28650) + monitor = q10_api.vacuum._goto_monitor_task + assert monitor is not None + + q10_api.map.update_from_trace_packet(Q10TracePacket(points=[Q10Point(100, 100)], sequence=2)) + await asyncio.sleep(0) + await q10_api.vacuum.goto_position(25750, 25750) + await asyncio.sleep(0) + + assert q10_api.vacuum._goto_monitor_task is None + assert monitor.cancelled() + assert [command for command, _ in fake_channel.published_commands] == [ + B01_Q10_DP.START_CLEAN, + B01_Q10_DP.PAUSE, + ]