Skip to content

Commit 4411412

Browse files
hmmbobhCoureau
andauthored
feat: add Q10 zone, position, and goto support (#908)
* feat: add Q10 zone cleaning and position coordinates * feat: add safe Q10 goto lifecycle * refactor: apply Q10 API review feedback Co-authored-by: Harry Coureau <harry@coureau.me> * fix: require confirmed Q10 goto ownership --------- Co-authored-by: Harry Coureau <harry@coureau.me>
1 parent 3e0bfe8 commit 4411412

12 files changed

Lines changed: 887 additions & 11 deletions

File tree

roborock/data/b01_q10/b01_q10_containers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,62 @@
2828
YXWaterLevel,
2929
)
3030

31+
_ROBOROCK_COORDINATE_OFFSET_MM = 25_500
32+
_Q10_TRACE_UNIT_MM = 2.5
33+
_Q10_VECTOR_UNIT_MM = 5
34+
35+
36+
@dataclass(frozen=True)
37+
class Q10RoborockPoint:
38+
"""A point in the common Roborock millimetre coordinate space.
39+
40+
Q10 trace and vector coordinates are firmware details. Public Q10 APIs use
41+
this coordinate system, matching other Roborock devices and placing the dock
42+
at ``(25500, 25500)``.
43+
"""
44+
45+
x: int
46+
y: int
47+
48+
@classmethod
49+
def from_trace(cls, x: int, y: int) -> "Q10RoborockPoint":
50+
"""Convert Q10 trace coordinates to common Roborock coordinates."""
51+
for value in (x, y):
52+
if isinstance(value, bool) or not isinstance(value, int):
53+
raise ValueError("trace coordinates must be integers")
54+
return cls(
55+
x=round(_ROBOROCK_COORDINATE_OFFSET_MM + x * _Q10_TRACE_UNIT_MM),
56+
y=round(_ROBOROCK_COORDINATE_OFFSET_MM + y * _Q10_TRACE_UNIT_MM),
57+
)
58+
59+
@classmethod
60+
def from_vector(cls, x: int, y: int) -> "Q10RoborockPoint":
61+
"""Convert Q10 vector coordinates to common Roborock coordinates."""
62+
for value in (x, y):
63+
if isinstance(value, bool) or not isinstance(value, int):
64+
raise ValueError("vector coordinates must be integers")
65+
if not -(2**15) <= value < 2**15:
66+
raise ValueError("vector coordinates are outside the Q10 map range")
67+
return cls(
68+
x=_ROBOROCK_COORDINATE_OFFSET_MM + x * _Q10_VECTOR_UNIT_MM,
69+
y=_ROBOROCK_COORDINATE_OFFSET_MM + y * _Q10_VECTOR_UNIT_MM,
70+
)
71+
72+
def to_vector(self) -> tuple[int, int]:
73+
"""Convert common Roborock coordinates to the Q10 vector grid."""
74+
coordinates: list[int] = []
75+
for value in (self.x, self.y):
76+
if isinstance(value, bool) or not isinstance(value, int):
77+
raise ValueError("coordinates must be integers")
78+
relative_mm = value - _ROBOROCK_COORDINATE_OFFSET_MM
79+
if relative_mm % _Q10_VECTOR_UNIT_MM:
80+
raise ValueError("coordinates must align to the Q10 5 mm grid")
81+
coordinate = relative_mm // _Q10_VECTOR_UNIT_MM
82+
if not -(2**15) <= coordinate < 2**15:
83+
raise ValueError("coordinates are outside the Q10 map range")
84+
coordinates.append(coordinate)
85+
return coordinates[0], coordinates[1]
86+
3187

3288
@dataclass
3389
class dpCleanRecord(RoborockBase):

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ def __init__(self, channel: B01Q10Channel) -> None:
9696
"""Initialize the B01Props API."""
9797
self._channel = channel
9898
self.command = CommandTrait(channel)
99-
self.vacuum = VacuumTrait(self.command)
10099
self.remote = RemoteTrait(self.command)
101100
self.status = StatusTrait()
102101
self.volume = SoundVolumeTrait(self.command)
@@ -109,6 +108,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
109108
self._map_dps = MapDpsTrait()
110109
self.maps = MapsTrait(self.command)
111110
self.map = MapContentTrait(self._map_dps, self.maps, self.command)
111+
self.vacuum = VacuumTrait(self.command, self.status, self.map)
112112
self.clean_history = CleanHistoryTrait(self.command)
113113
# Read-model traits updated from the device's DPS push stream.
114114
self._updatable_traits = [
@@ -131,6 +131,7 @@ async def start(self) -> None:
131131

132132
async def close(self) -> None:
133133
"""Close any resources held by the trait."""
134+
await self.vacuum.close()
134135
if self._subscribe_task is not None:
135136
self._subscribe_task.cancel()
136137
try:
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""State management for an emulated Q10 goto action."""
2+
3+
import logging
4+
from collections.abc import Callable
5+
from dataclasses import dataclass
6+
from enum import StrEnum
7+
from math import hypot
8+
9+
from roborock.callbacks import CallbackList
10+
from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceCleanTask, YXDeviceState
11+
from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
_TERMINAL_STATES = {
15+
YXDeviceState.IDLE,
16+
YXDeviceState.PAUSED,
17+
YXDeviceState.RETURNING_HOME,
18+
YXDeviceState.CHARGING,
19+
}
20+
21+
22+
class GotoActionCommand(StrEnum):
23+
"""A command requested by a Q10 goto action."""
24+
25+
PAUSE = "pause"
26+
STOP = "stop"
27+
COMPLETE = "complete"
28+
29+
30+
@dataclass(frozen=True)
31+
class GotoSnapshot:
32+
"""Device state needed to advance a goto action."""
33+
34+
position: Q10RoborockPoint | None
35+
trace_sequence: int | None
36+
clean_task_type: YXDeviceCleanTask | None
37+
status: YXDeviceState | None
38+
39+
40+
class GotoAction:
41+
"""Decide how one emulated goto should react to device updates.
42+
43+
The action owns no tasks and sends no device commands. ``VacuumTrait`` feeds
44+
it push-derived snapshots and performs commands requested by its callbacks.
45+
"""
46+
47+
def __init__(
48+
self,
49+
target: Q10RoborockPoint,
50+
previous_trace_sequence: int | None,
51+
*,
52+
tolerance: int,
53+
) -> None:
54+
"""Initialize a goto action waiting for a new trace session."""
55+
self._target = target
56+
self._previous_trace_sequence = previous_trace_sequence
57+
self._tolerance = tolerance
58+
self._owned_trace_sequence: int | None = None
59+
self._owned_task_seen = False
60+
self._command_pending = False
61+
self._timeout_requested = False
62+
self._finished = False
63+
self._latest_snapshot: GotoSnapshot | None = None
64+
self._callbacks: CallbackList[GotoActionCommand] = CallbackList(logger=_LOGGER)
65+
66+
def add_update_listener(self, callback: Callable[[GotoActionCommand], None]) -> Callable[[], None]:
67+
"""Register a callback for the next command requested by the action."""
68+
return self._callbacks.add_callback(callback)
69+
70+
def update(self, snapshot: GotoSnapshot) -> None:
71+
"""Process the latest push-derived device state."""
72+
self._latest_snapshot = snapshot
73+
self._evaluate(snapshot)
74+
75+
def retry(self) -> None:
76+
"""Re-evaluate the latest state after a requested command failed."""
77+
if self._finished or self._latest_snapshot is None:
78+
return
79+
self._command_pending = False
80+
if self._timeout_requested:
81+
self._evaluate_timeout(self._latest_snapshot)
82+
else:
83+
self._evaluate(self._latest_snapshot)
84+
85+
def timeout(self, snapshot: GotoSnapshot) -> None:
86+
"""Request a stop only if this action still owns the current zone task."""
87+
if self._finished:
88+
return
89+
self._latest_snapshot = snapshot
90+
self._timeout_requested = True
91+
if self._command_pending:
92+
return
93+
self._evaluate_timeout(snapshot)
94+
95+
def _evaluate_timeout(self, snapshot: GotoSnapshot) -> None:
96+
"""Derive the safe timeout command from the latest device state."""
97+
if self.owns(snapshot):
98+
self._emit(GotoActionCommand.STOP)
99+
else:
100+
self._emit(GotoActionCommand.COMPLETE)
101+
102+
def complete(self) -> None:
103+
"""Mark the action complete after its requested command succeeds."""
104+
self._finished = True
105+
self._command_pending = False
106+
107+
def owns(self, snapshot: GotoSnapshot) -> bool:
108+
"""Return whether this action owns the current Q10 zone-clean session."""
109+
return (
110+
self._owned_trace_sequence is not None
111+
and snapshot.trace_sequence == self._owned_trace_sequence
112+
and snapshot.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
113+
)
114+
115+
def _evaluate(self, snapshot: GotoSnapshot) -> None:
116+
"""Derive the next command from the latest device state."""
117+
if self._finished or self._command_pending:
118+
return
119+
120+
if self._owned_trace_sequence is None:
121+
if snapshot.trace_sequence is not None and snapshot.trace_sequence != self._previous_trace_sequence:
122+
self._owned_trace_sequence = snapshot.trace_sequence
123+
elif snapshot.trace_sequence != self._owned_trace_sequence:
124+
_LOGGER.debug("Q10 goto task was replaced by another cleaning session")
125+
self._emit(GotoActionCommand.COMPLETE)
126+
return
127+
128+
if (
129+
self._owned_trace_sequence is not None
130+
and snapshot.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
131+
and snapshot.status not in _TERMINAL_STATES
132+
):
133+
self._owned_task_seen = True
134+
135+
if self._owned_task_seen and (
136+
snapshot.clean_task_type is not YXDeviceCleanTask.DIVIDE_AREAS or snapshot.status in _TERMINAL_STATES
137+
):
138+
self._emit(GotoActionCommand.COMPLETE)
139+
return
140+
141+
if (
142+
self.owns(snapshot)
143+
and snapshot.position is not None
144+
and hypot(
145+
snapshot.position.x - self._target.x,
146+
snapshot.position.y - self._target.y,
147+
)
148+
<= self._tolerance
149+
):
150+
self._emit(GotoActionCommand.PAUSE)
151+
152+
def _emit(self, command: GotoActionCommand) -> None:
153+
"""Publish a requested command once until it is handled."""
154+
if command is GotoActionCommand.COMPLETE:
155+
self._finished = True
156+
else:
157+
self._command_pending = True
158+
self._callbacks(command)

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from roborock.data import RoborockBase
2121
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
22+
from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
2223
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2324
from roborock.exceptions import RoborockException
2425
from roborock.map.b01_q10_map_parser import (
@@ -127,13 +128,20 @@ def rooms(self) -> list[Q10Room]:
127128

128129
@property
129130
def path(self) -> list[Q10Point]:
130-
"""Full path for live status and callers drawing their own map overlay."""
131+
"""Full path in the Q10 trace coordinate space used by the map renderer."""
131132
return self._trace_packet.points if self._trace_packet else []
132133

133134
@property
134-
def robot_position(self) -> Q10Point | None:
135-
"""Current position for live status and caller-rendered map overlays."""
136-
return self._trace_packet.robot_position if self._trace_packet else None
135+
def robot_position(self) -> Q10RoborockPoint | None:
136+
"""Current position in the common Roborock millimetre coordinate space."""
137+
if self._trace_packet is None or (position := self._trace_packet.robot_position) is None:
138+
return None
139+
return position.to_roborock()
140+
141+
@property
142+
def trace_sequence(self) -> int | None:
143+
"""Current cleaning-session sequence from the trace stream."""
144+
return self._trace_packet.sequence if self._trace_packet else None
137145

138146
@property
139147
def robot_heading(self) -> int | None:
@@ -181,7 +189,9 @@ def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
181189
data = {
182190
"rooms": [room.as_dict() for room in self.rooms],
183191
"path": [point.as_dict() for point in self.path],
184-
"robotPosition": self.robot_position.as_dict() if self.robot_position is not None else None,
192+
"robotPosition": (
193+
{"x": position.x, "y": position.y} if (position := self.robot_position) is not None else None
194+
),
185195
"robotHeading": self.robot_heading,
186196
}
187197
for key in exclude_set:

0 commit comments

Comments
 (0)