11"""Traits for Q10 B01 devices."""
22
3+ import asyncio
4+ import logging
35from base64 import b64encode
6+ from math import hypot
47from struct import error as StructError
58from struct import pack
69
710from 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
1419from .command import CommandTrait
1520from .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
2033def _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.
0 commit comments