Skip to content

Commit 09d3295

Browse files
committed
Add Q10 zone cleaning and position coordinates
1 parent 347a8f6 commit 09d3295

5 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Coordinate conversion helpers for Q10 B01 devices."""
2+
3+
# Q10 trace coordinates are relative to the dock and use 2.5 mm units. The
4+
# public Roborock actions use millimetres with the dock at (25500, 25500).
5+
ROBOROCK_COORDINATE_OFFSET = 25500
6+
Q10_TRACE_UNIT_MM = 2.5
7+
# Zone and restriction vectors use 5 mm units in the same dock-relative frame.
8+
Q10_VECTOR_UNIT_MM = 5
9+
10+
11+
def trace_to_roborock_coordinate(value: int) -> int:
12+
"""Convert a Q10 trace value to the common Roborock coordinate space."""
13+
return round(ROBOROCK_COORDINATE_OFFSET + value * Q10_TRACE_UNIT_MM)
14+
15+
16+
def roborock_to_vector_coordinate(value: int) -> int:
17+
"""Convert a common Roborock coordinate to the Q10 vector format."""
18+
return round((value - ROBOROCK_COORDINATE_OFFSET) / Q10_VECTOR_UNIT_MM)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3333

3434
from .common import UpdatableTrait
35+
from .coordinates import trace_to_roborock_coordinate
3536

3637
_LOGGER = logging.getLogger(__name__)
3738

@@ -111,6 +112,16 @@ def robot_position(self) -> Q10Point | None:
111112
"""Current position for live status and caller-rendered map overlays."""
112113
return self._trace_packet.robot_position if self._trace_packet else None
113114

115+
@property
116+
def roborock_position(self) -> Q10Point | None:
117+
"""Current position in the common Roborock millimetre coordinate space."""
118+
if (position := self.robot_position) is None:
119+
return None
120+
return Q10Point(
121+
x=trace_to_roborock_coordinate(position.x),
122+
y=trace_to_roborock_coordinate(position.y),
123+
)
124+
114125
@property
115126
def robot_heading(self) -> int | None:
116127
"""Current heading for orienting a robot marker on a caller-rendered map."""

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Traits for Q10 B01 devices."""
22

3+
from base64 import b64encode
4+
from struct import error as StructError
5+
from struct import pack
6+
37
from roborock.data.b01_q10.b01_q10_code_mappings import (
48
B01_Q10_DP,
59
YXCleanType,
@@ -8,6 +12,41 @@
812
)
913

1014
from .command import CommandTrait
15+
from .coordinates import roborock_to_vector_coordinate
16+
17+
_ZONE_NAME_FIELD_LENGTH = 19
18+
19+
20+
def _encode_zone(x1: int, y1: int, x2: int, y2: int, clean_count: int) -> str:
21+
"""Encode one rectangular Q10 cleaning zone."""
22+
if not 1 <= clean_count <= 3:
23+
raise ValueError("clean_count must be between 1 and 3")
24+
25+
min_x, max_x = sorted((x1, x2))
26+
min_y, max_y = sorted((y1, y2))
27+
points = (
28+
(min_x, min_y),
29+
(max_x, min_y),
30+
(max_x, max_y),
31+
(min_x, max_y),
32+
)
33+
payload = bytearray((1, clean_count, 1, len(points)))
34+
try:
35+
for point_x, point_y in points:
36+
payload.extend(
37+
pack(
38+
">hh",
39+
roborock_to_vector_coordinate(point_x),
40+
roborock_to_vector_coordinate(point_y),
41+
)
42+
)
43+
except StructError as err:
44+
raise ValueError("zone coordinates are outside the supported range") from err
45+
46+
# The app protocol reserves a fixed 19-byte UTF-8 name field per zone.
47+
payload.append(0)
48+
payload.extend(bytes(_ZONE_NAME_FIELD_LENGTH))
49+
return b64encode(payload).decode()
1150

1251

1352
class VacuumTrait:
@@ -56,6 +95,25 @@ async def clean_segments(self, segment_ids: list[int]) -> None:
5695
params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
5796
)
5897

98+
async def clean_zone(
99+
self,
100+
x1: int,
101+
y1: int,
102+
x2: int,
103+
y2: int,
104+
*,
105+
clean_count: int = 1,
106+
) -> None:
107+
"""Clean one rectangular zone in the common Roborock coordinate space."""
108+
await self._command.send(
109+
command=B01_Q10_DP.START_CLEAN,
110+
params={
111+
"cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
112+
# "clean_paramters" is the spelling required by the firmware.
113+
"clean_paramters": _encode_zone(x1, y1, x2, y2, clean_count),
114+
},
115+
)
116+
59117
async def spot_clean(self) -> None:
60118
"""Start a spot / part clean around the robot's current position.
61119

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def test_update_from_trace_packet_populates_path_and_position() -> None:
7979
assert (trait.path[0].x, trait.path[0].y) == (41, 64)
8080
assert trait.robot_position is not None
8181
assert (trait.robot_position.x, trait.robot_position.y) == (276, -1)
82+
assert trait.roborock_position is not None
83+
assert (trait.roborock_position.x, trait.roborock_position.y) == (26190, 25498)
8284
assert trait.robot_heading == -34
8385
assert len(updates) == 1
8486

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from base64 import b64decode
12
from collections.abc import Awaitable, Callable
23
from typing import Any
34

@@ -49,3 +50,51 @@ async def test_vacuum_commands(
4950

5051
assert command.code == dp_code
5152
assert params == expected_params
53+
54+
55+
async def test_clean_zone(
56+
vacuum: VacuumTrait,
57+
fake_channel: FakeB01Q10Channel,
58+
) -> None:
59+
"""Test the source-verified Q10 zone payload."""
60+
await vacuum.clean_zone(25550, 25600, 25650, 25700, clean_count=2)
61+
62+
command, params = fake_channel.published_commands[0]
63+
assert command.code == 201
64+
assert params["cmd"] == 3
65+
assert b64decode(params["clean_paramters"]) == bytes(
66+
(
67+
1,
68+
2,
69+
1,
70+
4,
71+
0,
72+
10,
73+
0,
74+
20,
75+
0,
76+
30,
77+
0,
78+
20,
79+
0,
80+
30,
81+
0,
82+
40,
83+
0,
84+
10,
85+
0,
86+
40,
87+
0,
88+
*([0] * 19),
89+
)
90+
)
91+
92+
93+
@pytest.mark.parametrize("clean_count", [0, 4])
94+
async def test_clean_zone_rejects_invalid_clean_count(
95+
vacuum: VacuumTrait,
96+
clean_count: int,
97+
) -> None:
98+
"""Test that the device clean-count range is validated."""
99+
with pytest.raises(ValueError, match="clean_count must be between 1 and 3"):
100+
await vacuum.clean_zone(25550, 25600, 25650, 25700, clean_count=clean_count)

0 commit comments

Comments
 (0)