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.
- {python_roborock-7.6.0.dist-info → python_roborock-7.7.0.dist-info}/METADATA +1 -1
- {python_roborock-7.6.0.dist-info → python_roborock-7.7.0.dist-info}/RECORD +12 -11
- roborock/data/b01_q10/b01_q10_containers.py +56 -0
- roborock/devices/traits/b01/q10/__init__.py +2 -1
- roborock/devices/traits/b01/q10/goto.py +158 -0
- roborock/devices/traits/b01/q10/map.py +15 -5
- roborock/devices/traits/b01/q10/vacuum.py +183 -1
- roborock/map/b01_q10_map_parser.py +6 -1
- roborock/protocols/b01_q10_protocol.py +48 -0
- {python_roborock-7.6.0.dist-info → python_roborock-7.7.0.dist-info}/WHEEL +0 -0
- {python_roborock-7.6.0.dist-info → python_roborock-7.7.0.dist-info}/entry_points.txt +0 -0
- {python_roborock-7.6.0.dist-info → python_roborock-7.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 7.
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
129
|
-
python_roborock-7.
|
|
130
|
-
python_roborock-7.
|
|
131
|
-
python_roborock-7.
|
|
132
|
-
python_roborock-7.
|
|
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
|
|
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) ->
|
|
135
|
-
"""Current position
|
|
136
|
-
|
|
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":
|
|
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__(
|
|
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
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|