python-roborock 7.6.0__py3-none-any.whl → 7.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.6.0
3
+ Version: 7.7.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -17,7 +17,7 @@ roborock/data/code_mappings.py,sha256=j-ZZRtQQdIMJQAgEwDAxWKreNhD6U36oIhBKr_nLbi
17
17
  roborock/data/containers.py,sha256=ePz1tmhNY1QpgiVlyc8j6PH5kjU6fkZYtBByCFj2wzU,17896
18
18
  roborock/data/b01_q10/__init__.py,sha256=E4lwJcz4AI1z92FcYOJ-cY3fcfHSoXqvYVgfA-TgaRk,71
19
19
  roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=UBFkxCWLYCGQatENmNwR0owkXrcB8AosnD-hICfS2AY,15203
20
- roborock/data/b01_q10/b01_q10_containers.py,sha256=HE1qAhYKDTz35oRgekYItNfD1iWTi7605-wpETcDzxU,11823
20
+ roborock/data/b01_q10/b01_q10_containers.py,sha256=SOubDoxjNfCd-8JHjZ4GQKqWObJeYm0ouiUvWpjPWcY,14199
21
21
  roborock/data/b01_q7/__init__.py,sha256=D-Cd8eB0njf1PQPM6Exl_9A0AhpzIjrL0hapGOCEkkA,69
22
22
  roborock/data/b01_q7/b01_q7_code_mappings.py,sha256=S2KiBIpkUniEnw0hSSLeLWHZMZvq4Ld6RXzbaXO43Zk,11274
23
23
  roborock/data/b01_q7/b01_q7_containers.py,sha256=h01GNZM-M4OAMBO3aqE0LHgvmsrirssMmjR3YWdZMqo,10421
@@ -51,7 +51,7 @@ roborock/devices/traits/traits_mixin.py,sha256=7t15l_Ty1cN-3-fKNtRH1XR6-mfadPnTB
51
51
  roborock/devices/traits/a01/__init__.py,sha256=rRrdXgSsolyNC6MOCyYIfW8oaUmzt-p_uppuhzfYYIU,18375
52
52
  roborock/devices/traits/a01/device_feature.py,sha256=tO7u1dvnwerj9IVq9je-p7tzyEqTQR7i59DEj57eHZk,14117
53
53
  roborock/devices/traits/b01/__init__.py,sha256=pGN6wEocE6Fr2BTasG8Z_75crD6OdTwpju59Vw7eTDk,202
54
- roborock/devices/traits/b01/q10/__init__.py,sha256=BpeCO7vrM11ISy8JZwBiSkHsTs5-vp68c9YwnWKEIDk,6747
54
+ roborock/devices/traits/b01/q10/__init__.py,sha256=fucVVugsvIHl09ypuVRnN5KeUgqFixngxMsm3ktxXts,6804
55
55
  roborock/devices/traits/b01/q10/button_light.py,sha256=_CCWSjFe_iZnnSXgBHOju31VUmwG1KKd_jGkmSpLew4,994
56
56
  roborock/devices/traits/b01/q10/child_lock.py,sha256=bTsbAMxn7AF1zpWteyZCkQm4jj3NiWD8ddZTM3tlG_0,1131
57
57
  roborock/devices/traits/b01/q10/clean_history.py,sha256=OsuzliXtWl2RDMAdKElCaebp9Pr2czVOex180k4jzVs,6672
@@ -60,12 +60,13 @@ roborock/devices/traits/b01/q10/common.py,sha256=Qxl3zBAUulqdrjFZJ0DPEFEIJLvlPqU
60
60
  roborock/devices/traits/b01/q10/consumable.py,sha256=MbtMgYKqzNdVn6M1iyOAqZPCYTbN_Q_Pr4h8EYGEiuY,669
61
61
  roborock/devices/traits/b01/q10/do_not_disturb.py,sha256=klyGQZ_nHyO6bhPlwBl8vElTnMwPf4A8VXwxkU8rMlM,1157
62
62
  roborock/devices/traits/b01/q10/dust_collection.py,sha256=LM-0YH4pMrEqLUCFP2g-DymhL5ZUQ4bHd-2EyKHmucY,1582
63
- roborock/devices/traits/b01/q10/map.py,sha256=xWODSaUo4LHOhmF89WLPHvxpLgBzlTt6pPmOVCP2Oyc,7276
63
+ roborock/devices/traits/b01/q10/goto.py,sha256=ipSY08ZTwRr-2vKgzyE2eV1YrY2QRpYKDVych9lu_Sk,5690
64
+ roborock/devices/traits/b01/q10/map.py,sha256=qy-V-sJ5bvAhjCpKZqBPU0NcOc9ZGqBudDvo4l-HA4o,7692
64
65
  roborock/devices/traits/b01/q10/maps.py,sha256=qWk1K47aIf3iJead3A2zQ3Lo0wZ1wcS2mo4qVfiiTDw,2101
65
66
  roborock/devices/traits/b01/q10/network_info.py,sha256=XiCr6YPu7xkvhgeaGwXli-_4SB88QW63ykiegyaau2k,656
66
67
  roborock/devices/traits/b01/q10/remote.py,sha256=8-fg4SA5M-tdMufHPeD9hIcax7y9Qh7pzcE7rBBVW_o,1267
67
68
  roborock/devices/traits/b01/q10/status.py,sha256=JAPixbsQ8jfN3OvI7o_NxISXFk8l7rGHFYcL4pH97Jk,859
68
- roborock/devices/traits/b01/q10/vacuum.py,sha256=w2FftNH5n-Tb-9drR3-5OiMy85FJfTDNmtt34LklC8w,4374
69
+ roborock/devices/traits/b01/q10/vacuum.py,sha256=uVw11UgumzqEqqAUCe8LybFX810pqSIMloFiaoRsXAw,11651
69
70
  roborock/devices/traits/b01/q10/volume.py,sha256=UnT0yYDQntxYwnpv4_NVychm0FnGAYRAWA_FJR4yJag,1001
70
71
  roborock/devices/traits/b01/q7/__init__.py,sha256=tbsZSe2VojCN7uRdHPm74on8WrexJePzQ2ewTLcFjWo,9124
71
72
  roborock/devices/traits/b01/q7/clean_summary.py,sha256=M43iJVeYjrkBV6ZxXdHtn4Rd5Mx-KAK7WLU5fABiN8I,2742
@@ -101,7 +102,7 @@ roborock/devices/transport/mqtt_channel.py,sha256=dpL1mZIoI4Glk3hfUO9JweWFHG1tZW
101
102
  roborock/map/__init__.py,sha256=L3WHujuxUHCTTPaOElkJsTN_FkYhw01drfR-W4blgFc,146
102
103
  roborock/map/b01_grid_layers.py,sha256=b87MsGXslqLR_5H_4z4aj1wQqYgeTuULWqt4_txOF-M,12303
103
104
  roborock/map/b01_map_parser.py,sha256=y4Qlgiwre3bn5_UpL6R-ytjg8kQqwkbvhIYFOtpAM3s,14814
104
- roborock/map/b01_q10_map_parser.py,sha256=Pt-goedXV60hg-mwHOgOF0f4TQBQmQXe6otpWaa99ps,29996
105
+ roborock/map/b01_q10_map_parser.py,sha256=i0jjQDb7pjlee3QCa1HlLKAv2t9_BtA-OYQUFT2g6RI,30262
105
106
  roborock/map/b01_q10_overlays.py,sha256=JmriUbT4-oe6NiE94VttMplFc1HHQrAGNQIiZHj5QKk,6640
106
107
  roborock/map/b01_q10_render.py,sha256=b7YUBr53WXxOJhXbfb5IVmgi82iGcC0lJ_j9Ef3kV7M,13502
107
108
  roborock/map/map_parser.py,sha256=EcJcz9yld_wu7d5QrmxR2BTfJ801F6SaEgJkbIIap4w,5111
@@ -115,7 +116,7 @@ roborock/mqtt/roborock_session.py,sha256=ir7HXs4gtoineiwlc6ixyAueBTCSPtNU8zPGgY9
115
116
  roborock/mqtt/session.py,sha256=eVW1o6PziXaRyNXviPgC_kIVuqhPSrloAWuP2RYj-uo,4096
116
117
  roborock/protocols/__init__.py,sha256=1ems3I1_KUQoMf2Hf6ZYDKFhu5Gnsc0PbfD-dkQJ7vA,82
117
118
  roborock/protocols/a01_protocol.py,sha256=nyUAn39CXH0xLqcRcr7JiORt2zFNgxP4F4ClVc3dvB4,2501
118
- roborock/protocols/b01_q10_protocol.py,sha256=Ac3_VHJ2DAwBiXFMPYOHExEV0prxS9Ju0djiC8nKtiI,4935
119
+ roborock/protocols/b01_q10_protocol.py,sha256=FTki6nPGscelhq7HLvylmJZMqs-o9g_gbOEDVePb5OU,6971
119
120
  roborock/protocols/b01_q7_protocol.py,sha256=2dk6nbDOhb0O-Q_UPKeZHjzdazbS-MA3PIqpXvqRqoo,4657
120
121
  roborock/protocols/v1_protocol.py,sha256=ntFQoGn1IF7v_BwjiGow478SJHBSKr6jq6M2s01OdaI,12918
121
122
  roborock/testing/__init__.py,sha256=rji4a-JBIWC_I6SUTG9j4r2bGxfhYUoopdDAzYQ1tmA,5086
@@ -125,8 +126,8 @@ roborock/testing/channel.py,sha256=vjulT5MHBe8lAq6XITUqcxJVcCILWx7ZEcw5imTx0Tk,7
125
126
  roborock/testing/cloud.py,sha256=L2L0KlZDYaMuoV19KUQUoq5EAUDeNQ1lJ8Ew5YaOgjo,11946
126
127
  roborock/testing/simulator.py,sha256=g8ZUWVSjgAzuRRxWOCeOq-7deodyEItwUpqEw9RzCmQ,4347
127
128
  roborock/testing/v1_simulator.py,sha256=_LX_vsBMcB2Uu2QDt8QnQymgtVtbnwD5Xa7tmhiPAnY,14439
128
- python_roborock-7.6.0.dist-info/METADATA,sha256=K-sGLp5G2PZElinVEK18ggGArG4cdD7GKeYfTJFJsLQ,5278
129
- python_roborock-7.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
130
- python_roborock-7.6.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
131
- python_roborock-7.6.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
132
- python_roborock-7.6.0.dist-info/RECORD,,
129
+ python_roborock-7.7.0.dist-info/METADATA,sha256=LuQWXmlZKWOMRGdAQWOrZqMm2Q9_5d3KtoxyN24BeaY,5278
130
+ python_roborock-7.7.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
131
+ python_roborock-7.7.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
132
+ python_roborock-7.7.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
133
+ python_roborock-7.7.0.dist-info/RECORD,,
@@ -28,6 +28,62 @@ from .b01_q10_code_mappings import (
28
28
  YXWaterLevel,
29
29
  )
30
30
 
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
+
31
87
 
32
88
  @dataclass
33
89
  class dpCleanRecord(RoborockBase):
@@ -96,7 +96,6 @@ class Q10PropertiesApi(Trait):
96
96
  """Initialize the B01Props API."""
97
97
  self._channel = channel
98
98
  self.command = CommandTrait(channel)
99
- self.vacuum = VacuumTrait(self.command)
100
99
  self.remote = RemoteTrait(self.command)
101
100
  self.status = StatusTrait()
102
101
  self.volume = SoundVolumeTrait(self.command)
@@ -109,6 +108,7 @@ class Q10PropertiesApi(Trait):
109
108
  self._map_dps = MapDpsTrait()
110
109
  self.maps = MapsTrait(self.command)
111
110
  self.map = MapContentTrait(self._map_dps, self.maps, self.command)
111
+ self.vacuum = VacuumTrait(self.command, self.status, self.map)
112
112
  self.clean_history = CleanHistoryTrait(self.command)
113
113
  # Read-model traits updated from the device's DPS push stream.
114
114
  self._updatable_traits = [
@@ -131,6 +131,7 @@ class Q10PropertiesApi(Trait):
131
131
 
132
132
  async def close(self) -> None:
133
133
  """Close any resources held by the trait."""
134
+ await self.vacuum.close()
134
135
  if self._subscribe_task is not None:
135
136
  self._subscribe_task.cancel()
136
137
  try:
@@ -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)
@@ -19,6 +19,7 @@ from typing import Any
19
19
 
20
20
  from roborock.data import RoborockBase
21
21
  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
22
23
  from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
23
24
  from roborock.exceptions import RoborockException
24
25
  from roborock.map.b01_q10_map_parser import (
@@ -127,13 +128,20 @@ class MapContentTrait(TraitUpdateListener):
127
128
 
128
129
  @property
129
130
  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."""
131
132
  return self._trace_packet.points if self._trace_packet else []
132
133
 
133
134
  @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
137
145
 
138
146
  @property
139
147
  def robot_heading(self) -> int | None:
@@ -181,7 +189,9 @@ class MapContentTrait(TraitUpdateListener):
181
189
  data = {
182
190
  "rooms": [room.as_dict() for room in self.rooms],
183
191
  "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
+ ),
185
195
  "robotHeading": self.robot_heading,
186
196
  }
187
197
  for key in exclude_set:
@@ -1,13 +1,31 @@
1
1
  """Traits for Q10 B01 devices."""
2
2
 
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from math import hypot
7
+
3
8
  from roborock.data.b01_q10.b01_q10_code_mappings import (
4
9
  B01_Q10_DP,
5
10
  YXCleanType,
6
11
  YXDeviceCleanTask,
7
12
  YXFanLevel,
8
13
  )
14
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
15
+ from roborock.exceptions import RoborockException
16
+ from roborock.protocols.b01_q10_protocol import CleanParams, encode_clean_params
9
17
 
10
18
  from .command import CommandTrait
19
+ from .goto import GotoAction, GotoActionCommand, GotoSnapshot
20
+ from .map import MapContentTrait
21
+ from .status import StatusTrait
22
+
23
+ _GOTO_HALF_ZONE_SIZE = 200
24
+ _GOTO_TOLERANCE = 200
25
+ _GOTO_TIMEOUT = 300
26
+ _GOTO_RETRY_INTERVAL = 1
27
+
28
+ _LOGGER = logging.getLogger(__name__)
11
29
 
12
30
 
13
31
  class VacuumTrait:
@@ -17,9 +35,100 @@ class VacuumTrait:
17
35
  commands to Q10 devices.
18
36
  """
19
37
 
20
- def __init__(self, command: CommandTrait) -> None:
38
+ def __init__(
39
+ self,
40
+ command: CommandTrait,
41
+ status: StatusTrait,
42
+ map_content: MapContentTrait,
43
+ ) -> None:
21
44
  """Initialize the VacuumTrait."""
22
45
  self._command = command
46
+ self._status = status
47
+ self._map = map_content
48
+ self._goto_action: GotoAction | None = None
49
+ self._goto_action_remove_listener: Callable[[], None] | None = None
50
+ self._goto_timeout_task: asyncio.Task[None] | None = None
51
+ self._goto_command_task: asyncio.Task[None] | None = None
52
+ self._remove_map_listener = self._map.add_update_listener(self._goto_source_updated)
53
+ self._remove_status_listener = self._status.add_update_listener(self._goto_source_updated)
54
+
55
+ async def close(self) -> None:
56
+ """Cancel background work owned by the trait."""
57
+ self.cancel_goto()
58
+ self._remove_map_listener()
59
+ self._remove_status_listener()
60
+
61
+ def cancel_goto(self) -> None:
62
+ """Cancel an emulated goto replaced by another command."""
63
+ if self._goto_action is not None:
64
+ self._goto_action.complete()
65
+ self._goto_action = None
66
+ if self._goto_action_remove_listener is not None:
67
+ self._goto_action_remove_listener()
68
+ self._goto_action_remove_listener = None
69
+ current_task = asyncio.current_task()
70
+ for task_name in ("_goto_timeout_task", "_goto_command_task"):
71
+ if (task := getattr(self, task_name)) is not None:
72
+ if task is not current_task:
73
+ task.cancel()
74
+ setattr(self, task_name, None)
75
+
76
+ def _goto_snapshot(self) -> GotoSnapshot:
77
+ """Return the latest state used by an active goto action."""
78
+ return GotoSnapshot(
79
+ position=self._map.robot_position,
80
+ trace_sequence=self._map.trace_sequence,
81
+ clean_task_type=self._status.clean_task_type,
82
+ status=self._status.status,
83
+ )
84
+
85
+ def _goto_source_updated(self) -> None:
86
+ """Feed push-derived map or status state to the active goto action."""
87
+ if self._goto_action is not None:
88
+ self._goto_action.update(self._goto_snapshot())
89
+
90
+ def _goto_action_updated(self, action: GotoAction, command: GotoActionCommand) -> None:
91
+ """Schedule a device command requested by the active goto action."""
92
+ if action is not self._goto_action:
93
+ return
94
+ if command is GotoActionCommand.COMPLETE:
95
+ self.cancel_goto()
96
+ return
97
+ if self._goto_command_task is None:
98
+ self._goto_command_task = asyncio.create_task(
99
+ self._async_handle_goto_command(action, command),
100
+ name="roborock_q10_goto_command",
101
+ )
102
+
103
+ async def _async_handle_goto_command(self, action: GotoAction, command: GotoActionCommand) -> None:
104
+ """Perform a pause or stop requested by the active goto action."""
105
+ current_task = asyncio.current_task()
106
+ dp_command = B01_Q10_DP.PAUSE if command is GotoActionCommand.PAUSE else B01_Q10_DP.STOP
107
+ try:
108
+ await self._command.send(command=dp_command, params=0)
109
+ except RoborockException as err:
110
+ if command is GotoActionCommand.PAUSE:
111
+ _LOGGER.warning("Failed to pause completed Q10 goto task; retrying: %s", err)
112
+ await asyncio.sleep(_GOTO_RETRY_INTERVAL)
113
+ if action is self._goto_action:
114
+ self._goto_command_task = None
115
+ action.retry()
116
+ return
117
+ _LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err)
118
+ if action is self._goto_action:
119
+ action.complete()
120
+ self.cancel_goto()
121
+ if self._goto_command_task is current_task:
122
+ self._goto_command_task = None
123
+
124
+ async def _async_timeout_goto(self, action: GotoAction) -> None:
125
+ """Tell the active goto action when its safety timeout expires."""
126
+ try:
127
+ await asyncio.sleep(_GOTO_TIMEOUT)
128
+ except asyncio.CancelledError:
129
+ return
130
+ if action is self._goto_action:
131
+ action.timeout(self._goto_snapshot())
23
132
 
24
133
  async def start_clean(self) -> None:
25
134
  """Start a whole-home clean.
@@ -34,6 +143,7 @@ class VacuumTrait:
34
143
  whole-home clean (clean_task_type -> 1).
35
144
  """
36
145
  await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
146
+ self.cancel_goto()
37
147
 
38
148
  async def clean_segments(self, segment_ids: list[int]) -> None:
39
149
  """Start a room / segment clean for the given segment (room) ids.
@@ -55,6 +165,73 @@ class VacuumTrait:
55
165
  # "parameters" -- the firmware only accepts that exact key.
56
166
  params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
57
167
  )
168
+ self.cancel_goto()
169
+
170
+ async def clean_zone(
171
+ self,
172
+ first_corner: Q10RoborockPoint,
173
+ second_corner: Q10RoborockPoint,
174
+ *,
175
+ clean_count: int = 1,
176
+ ) -> None:
177
+ """Clean one rectangular zone in the common Roborock coordinate space."""
178
+ encoded_zone = encode_clean_params(CleanParams(first_corner, second_corner, clean_count))
179
+ await self._command.send(
180
+ command=B01_Q10_DP.START_CLEAN,
181
+ params={
182
+ "cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
183
+ # "clean_paramters" is the spelling required by the firmware.
184
+ "clean_paramters": encoded_zone,
185
+ },
186
+ )
187
+ self.cancel_goto()
188
+
189
+ async def goto_position(self, target: Q10RoborockPoint) -> None:
190
+ """Move to a coordinate using an owned 40 cm zone-clean task."""
191
+ target.to_vector()
192
+ snapshot = self._goto_snapshot()
193
+ if (position := snapshot.position) is not None and hypot(
194
+ position.x - target.x, position.y - target.y
195
+ ) <= _GOTO_TOLERANCE:
196
+ if self._goto_action is not None and self._goto_action.owns(snapshot):
197
+ await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
198
+ self.cancel_goto()
199
+ return
200
+
201
+ encoded_zone = encode_clean_params(
202
+ CleanParams(
203
+ Q10RoborockPoint(
204
+ target.x - _GOTO_HALF_ZONE_SIZE,
205
+ target.y - _GOTO_HALF_ZONE_SIZE,
206
+ ),
207
+ Q10RoborockPoint(
208
+ target.x + _GOTO_HALF_ZONE_SIZE,
209
+ target.y + _GOTO_HALF_ZONE_SIZE,
210
+ ),
211
+ )
212
+ )
213
+ await self._command.send(
214
+ command=B01_Q10_DP.START_CLEAN,
215
+ params={
216
+ "cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
217
+ "clean_paramters": encoded_zone,
218
+ },
219
+ )
220
+ self.cancel_goto()
221
+ action = GotoAction(
222
+ target,
223
+ snapshot.trace_sequence,
224
+ tolerance=_GOTO_TOLERANCE,
225
+ )
226
+ self._goto_action = action
227
+ self._goto_action_remove_listener = action.add_update_listener(
228
+ lambda command: self._goto_action_updated(action, command)
229
+ )
230
+ self._goto_timeout_task = asyncio.create_task(
231
+ self._async_timeout_goto(action),
232
+ name="roborock_q10_goto_timeout",
233
+ )
234
+ action.update(self._goto_snapshot())
58
235
 
59
236
  async def spot_clean(self) -> None:
60
237
  """Start a spot / part clean around the robot's current position.
@@ -62,18 +239,22 @@ class VacuumTrait:
62
239
  Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5).
63
240
  """
64
241
  await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5)
242
+ self.cancel_goto()
65
243
 
66
244
  async def pause_clean(self) -> None:
67
245
  """Pause the current task. Verified live: ``{"dps": {"204": 0}}``."""
68
246
  await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
247
+ self.cancel_goto()
69
248
 
70
249
  async def resume_clean(self) -> None:
71
250
  """Resume a paused task. Verified live: ``{"dps": {"205": 0}}``."""
72
251
  await self._command.send(command=B01_Q10_DP.RESUME, params=0)
252
+ self.cancel_goto()
73
253
 
74
254
  async def stop_clean(self) -> None:
75
255
  """Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``."""
76
256
  await self._command.send(command=B01_Q10_DP.STOP, params=0)
257
+ self.cancel_goto()
77
258
 
78
259
  async def return_to_dock(self) -> None:
79
260
  """Send the robot back to the dock to charge.
@@ -84,6 +265,7 @@ class VacuumTrait:
84
265
  wash mop en route and ``4`` = collect dust en route.)
85
266
  """
86
267
  await self._command.send(command=B01_Q10_DP.START_BACK, params=5)
268
+ self.cancel_goto()
87
269
 
88
270
  async def empty_dustbin(self) -> None:
89
271
  """Empty the dustbin at the dock.
@@ -29,6 +29,7 @@ from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
29
29
  from vacuum_map_parser_base.config.image_config import ImageConfig
30
30
  from vacuum_map_parser_base.map_data import ImageData, MapData, Point
31
31
 
32
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
32
33
  from roborock.data.containers import RoborockBase
33
34
  from roborock.exceptions import RoborockException
34
35
 
@@ -222,11 +223,15 @@ class Q10MapPacket:
222
223
 
223
224
  @dataclass
224
225
  class Q10Point(RoborockBase):
225
- """A single point in Q10 map/trace coordinate space."""
226
+ """A point in the Q10 firmware's dock-relative trace coordinate space."""
226
227
 
227
228
  x: int
228
229
  y: int
229
230
 
231
+ def to_roborock(self) -> Q10RoborockPoint:
232
+ """Convert this trace point to common Roborock coordinates."""
233
+ return Q10RoborockPoint.from_trace(self.x, self.y)
234
+
230
235
 
231
236
  @dataclass
232
237
  class Q10TracePacket:
@@ -2,10 +2,13 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ from base64 import b64encode
5
6
  from dataclasses import dataclass
7
+ from struct import pack
6
8
  from typing import Any
7
9
 
8
10
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
11
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
9
12
  from roborock.exceptions import RoborockException
10
13
  from roborock.map.b01_q10_map_parser import (
11
14
  Q10MapPacket,
@@ -24,6 +27,51 @@ _LOGGER = logging.getLogger(__name__)
24
27
 
25
28
  B01_VERSION = b"B01"
26
29
  ParamsType = list | dict | int | None
30
+ _Q10_ZONE_NAME_FIELD_LENGTH = 19
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CleanParams:
35
+ """Parameters for one rectangular Q10 zone-clean task."""
36
+
37
+ first_corner: Q10RoborockPoint
38
+ second_corner: Q10RoborockPoint
39
+ clean_count: int = 1
40
+
41
+ @property
42
+ def points(self) -> tuple[Q10RoborockPoint, ...]:
43
+ """Return rectangle vertices sorted into canonical wire order."""
44
+ min_x, max_x = sorted((self.first_corner.x, self.second_corner.x))
45
+ min_y, max_y = sorted((self.first_corner.y, self.second_corner.y))
46
+ return (
47
+ Q10RoborockPoint(min_x, min_y),
48
+ Q10RoborockPoint(max_x, min_y),
49
+ Q10RoborockPoint(max_x, max_y),
50
+ Q10RoborockPoint(min_x, max_y),
51
+ )
52
+
53
+
54
+ def encode_clean_params(params: CleanParams) -> str:
55
+ """Encode Q10 zone-clean parameters for ``dpStartClean`` task type 3."""
56
+ if not isinstance(params, CleanParams):
57
+ raise ValueError("params must be CleanParams")
58
+ if not isinstance(params.first_corner, Q10RoborockPoint) or not isinstance(params.second_corner, Q10RoborockPoint):
59
+ raise ValueError("zone corners must be Q10RoborockPoint values")
60
+ if isinstance(params.clean_count, bool) or not 1 <= params.clean_count <= 3:
61
+ raise ValueError("clean_count must be between 1 and 3")
62
+ if params.first_corner.x == params.second_corner.x or params.first_corner.y == params.second_corner.y:
63
+ raise ValueError("zone corners must enclose an area")
64
+
65
+ points = params.points
66
+ payload = bytearray((1, params.clean_count, 1, len(points)))
67
+ for point in points:
68
+ payload.extend(pack(">hh", *point.to_vector()))
69
+
70
+ # The app protocol reserves a fixed 19-byte UTF-8 name field per zone. An
71
+ # unnamed zone is encoded as a zero length followed by zero padding.
72
+ payload.append(0)
73
+ payload.extend(bytes(_Q10_ZONE_NAME_FIELD_LENGTH))
74
+ return b64encode(payload).decode()
27
75
 
28
76
 
29
77
  def encode_mqtt_payload(command: B01_Q10_DP, params: ParamsType) -> RoborockMessage: