Skip to content

Commit 01d1fc5

Browse files
committed
feat: add safe Q10 goto lifecycle
1 parent 0ada4c3 commit 01d1fc5

5 files changed

Lines changed: 316 additions & 4 deletions

File tree

roborock/devices/traits/b01/q10/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ def __init__(self, channel: B01Q10Channel) -> None:
8989
"""Initialize the B01Props API."""
9090
self._channel = channel
9191
self.command = CommandTrait(channel)
92-
self.vacuum = VacuumTrait(self.command)
9392
self.remote = RemoteTrait(self.command)
9493
self.status = StatusTrait()
9594
self.volume = SoundVolumeTrait(self.command)
@@ -101,6 +100,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
101100
self.consumable = ConsumableTrait()
102101
self._map_dps = MapDpsTrait()
103102
self.map = MapContentTrait(self._map_dps)
103+
self.vacuum = VacuumTrait(self.command, self.status, self.map)
104104
self.clean_history = CleanHistoryTrait(self.command)
105105
# Read-model traits updated from the device's DPS push stream.
106106
self._updatable_traits = [
@@ -122,6 +122,7 @@ async def start(self) -> None:
122122

123123
async def close(self) -> None:
124124
"""Close any resources held by the trait."""
125+
await self.vacuum.close()
125126
if self._subscribe_task is not None:
126127
self._subscribe_task.cancel()
127128
try:

roborock/devices/traits/b01/q10/map.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ def roborock_position(self) -> Q10Point | None:
122122
y=trace_to_roborock_coordinate(position.y),
123123
)
124124

125+
@property
126+
def trace_sequence(self) -> int | None:
127+
"""Current cleaning-session sequence from the trace stream."""
128+
return self._trace_packet.sequence if self._trace_packet else None
129+
125130
@property
126131
def robot_heading(self) -> int | None:
127132
"""Current heading for orienting a robot marker on a caller-rendered map."""

roborock/devices/traits/b01/q10/vacuum.py

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
11
"""Traits for Q10 B01 devices."""
22

3+
import asyncio
4+
import logging
35
from base64 import b64encode
6+
from math import hypot
47
from struct import error as StructError
58
from struct import pack
69

710
from roborock.data.b01_q10.b01_q10_code_mappings import (
811
B01_Q10_DP,
912
YXCleanType,
1013
YXDeviceCleanTask,
14+
YXDeviceState,
1115
YXFanLevel,
1216
)
17+
from roborock.exceptions import RoborockException
1318

1419
from .command import CommandTrait
1520
from .coordinates import roborock_to_vector_coordinate
21+
from .map import MapContentTrait
22+
from .status import StatusTrait
1623

1724
_ZONE_NAME_FIELD_LENGTH = 19
25+
_GOTO_HALF_ZONE_SIZE = 200
26+
_GOTO_TOLERANCE = 200
27+
_GOTO_TIMEOUT = 300
28+
_GOTO_RETRY_INTERVAL = 1
29+
30+
_LOGGER = logging.getLogger(__name__)
1831

1932

2033
def _encode_zone(x1: int, y1: int, x2: int, y2: int, clean_count: int) -> str:
@@ -56,9 +69,128 @@ class VacuumTrait:
5669
commands to Q10 devices.
5770
"""
5871

59-
def __init__(self, command: CommandTrait) -> None:
72+
def __init__(
73+
self,
74+
command: CommandTrait,
75+
status: StatusTrait,
76+
map_content: MapContentTrait,
77+
) -> None:
6078
"""Initialize the VacuumTrait."""
6179
self._command = command
80+
self._status = status
81+
self._map = map_content
82+
self._goto_monitor_task: asyncio.Task[None] | None = None
83+
self._goto_trace_sequence: int | None = None
84+
85+
async def close(self) -> None:
86+
"""Cancel background work owned by the trait."""
87+
if (task := self._goto_monitor_task) is None:
88+
return
89+
task.cancel()
90+
try:
91+
await task
92+
except asyncio.CancelledError:
93+
pass
94+
self._goto_monitor_task = None
95+
self._goto_trace_sequence = None
96+
97+
def cancel_goto(self) -> None:
98+
"""Cancel monitoring for an emulated goto replaced by another command."""
99+
if self._goto_monitor_task is not None:
100+
self._goto_monitor_task.cancel()
101+
self._goto_monitor_task = None
102+
self._goto_trace_sequence = None
103+
104+
async def _async_monitor_goto_target(
105+
self,
106+
x: int,
107+
y: int,
108+
previous_trace_sequence: int | None,
109+
) -> None:
110+
"""Pause the owned mini-zone task after it reaches the target."""
111+
current_task = asyncio.current_task()
112+
owned_trace_sequence: int | None = None
113+
owned_task_seen = False
114+
update_event = asyncio.Event()
115+
remove_map_listener = self._map.add_update_listener(update_event.set)
116+
remove_status_listener = self._status.add_update_listener(update_event.set)
117+
try:
118+
async with asyncio.timeout(_GOTO_TIMEOUT):
119+
while True:
120+
trace_sequence = self._map.trace_sequence
121+
if owned_trace_sequence is None:
122+
if trace_sequence is not None and trace_sequence != previous_trace_sequence:
123+
owned_trace_sequence = trace_sequence
124+
self._goto_trace_sequence = trace_sequence
125+
elif trace_sequence != owned_trace_sequence:
126+
_LOGGER.debug("Q10 goto task was replaced by another cleaning session")
127+
return
128+
129+
if (
130+
owned_trace_sequence is not None
131+
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
132+
and self._status.status
133+
not in {
134+
YXDeviceState.IDLE,
135+
YXDeviceState.PAUSED,
136+
YXDeviceState.RETURNING_HOME,
137+
YXDeviceState.CHARGING,
138+
}
139+
):
140+
owned_task_seen = True
141+
142+
if owned_task_seen and self._status.clean_task_type is not YXDeviceCleanTask.DIVIDE_AREAS:
143+
_LOGGER.debug("Q10 goto task was replaced by another task type")
144+
return
145+
146+
if owned_task_seen and self._status.status in {
147+
YXDeviceState.IDLE,
148+
YXDeviceState.PAUSED,
149+
YXDeviceState.RETURNING_HOME,
150+
YXDeviceState.CHARGING,
151+
}:
152+
return
153+
154+
if (
155+
owned_trace_sequence is not None
156+
and (position := self._map.roborock_position) is not None
157+
and hypot(position.x - x, position.y - y) <= _GOTO_TOLERANCE
158+
):
159+
try:
160+
await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
161+
except RoborockException as err:
162+
_LOGGER.warning("Failed to pause completed Q10 goto task; retrying: %s", err)
163+
else:
164+
return
165+
166+
update_event.clear()
167+
try:
168+
async with asyncio.timeout(_GOTO_RETRY_INTERVAL):
169+
await update_event.wait()
170+
except TimeoutError:
171+
pass
172+
except TimeoutError:
173+
if (
174+
owned_trace_sequence is not None
175+
and self._map.trace_sequence == owned_trace_sequence
176+
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
177+
):
178+
_LOGGER.warning(
179+
"Q10 vacuum did not reach goto target (%s, %s) within %s seconds; stopping zone task",
180+
x,
181+
y,
182+
_GOTO_TIMEOUT,
183+
)
184+
try:
185+
await self._command.send(command=B01_Q10_DP.STOP, params=0)
186+
except RoborockException as err:
187+
_LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err)
188+
finally:
189+
remove_map_listener()
190+
remove_status_listener()
191+
if self._goto_monitor_task is current_task:
192+
self._goto_monitor_task = None
193+
self._goto_trace_sequence = None
62194

63195
async def start_clean(self) -> None:
64196
"""Start a whole-home clean.
@@ -73,6 +205,7 @@ async def start_clean(self) -> None:
73205
whole-home clean (clean_task_type -> 1).
74206
"""
75207
await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
208+
self.cancel_goto()
76209

77210
async def clean_segments(self, segment_ids: list[int]) -> None:
78211
"""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:
94227
# "parameters" -- the firmware only accepts that exact key.
95228
params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
96229
)
230+
self.cancel_goto()
97231

98232
async def clean_zone(
99233
self,
@@ -105,33 +239,75 @@ async def clean_zone(
105239
clean_count: int = 1,
106240
) -> None:
107241
"""Clean one rectangular zone in the common Roborock coordinate space."""
242+
encoded_zone = _encode_zone(x1, y1, x2, y2, clean_count)
108243
await self._command.send(
109244
command=B01_Q10_DP.START_CLEAN,
110245
params={
111246
"cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
112247
# "clean_paramters" is the spelling required by the firmware.
113-
"clean_paramters": _encode_zone(x1, y1, x2, y2, clean_count),
248+
"clean_paramters": encoded_zone,
249+
},
250+
)
251+
self.cancel_goto()
252+
253+
async def goto_position(self, x: int, y: int) -> None:
254+
"""Move to a coordinate using an owned 40 cm zone-clean task."""
255+
if (position := self._map.roborock_position) is not None and hypot(
256+
position.x - x, position.y - y
257+
) <= _GOTO_TOLERANCE:
258+
if (
259+
self._goto_monitor_task is not None
260+
and self._goto_trace_sequence is not None
261+
and self._map.trace_sequence == self._goto_trace_sequence
262+
and self._status.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
263+
):
264+
await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
265+
self.cancel_goto()
266+
return
267+
268+
previous_trace_sequence = self._map.trace_sequence
269+
encoded_zone = _encode_zone(
270+
x - _GOTO_HALF_ZONE_SIZE,
271+
y - _GOTO_HALF_ZONE_SIZE,
272+
x + _GOTO_HALF_ZONE_SIZE,
273+
y + _GOTO_HALF_ZONE_SIZE,
274+
1,
275+
)
276+
await self._command.send(
277+
command=B01_Q10_DP.START_CLEAN,
278+
params={
279+
"cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
280+
"clean_paramters": encoded_zone,
114281
},
115282
)
283+
self.cancel_goto()
284+
self._goto_monitor_task = asyncio.create_task(
285+
self._async_monitor_goto_target(x, y, previous_trace_sequence),
286+
name="roborock_q10_goto",
287+
)
116288

117289
async def spot_clean(self) -> None:
118290
"""Start a spot / part clean around the robot's current position.
119291
120292
Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5).
121293
"""
122294
await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5)
295+
self.cancel_goto()
123296

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

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

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

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

146323
async def empty_dustbin(self) -> None:
147324
"""Empty the dustbin at the dock.

tests/devices/traits/b01/q10/test_map.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def test_update_from_trace_packet_populates_path_and_position() -> None:
8181
assert (trait.robot_position.x, trait.robot_position.y) == (276, -1)
8282
assert trait.roborock_position is not None
8383
assert (trait.roborock_position.x, trait.roborock_position.y) == (26190, 25498)
84+
assert trait.trace_sequence == trace.sequence
8485
assert trait.robot_heading == -34
8586
assert len(updates) == 1
8687

0 commit comments

Comments
 (0)