python-roborock 2.34.2__tar.gz → 2.36.0__tar.gz
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-2.34.2 → python_roborock-2.36.0}/PKG-INFO +1 -1
- {python_roborock-2.34.2 → python_roborock-2.36.0}/pyproject.toml +1 -1
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/api.py +1 -6
- python_roborock-2.36.0/roborock/clean_modes.py +113 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/containers.py +23 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/device_features.py +1 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/local_channel.py +6 -17
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/mqtt_channel.py +6 -16
- python_roborock-2.36.0/roborock/devices/pending.py +45 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocol.py +20 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/v1_protocol.py +39 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_message.py +0 -20
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_client_v1.py +19 -17
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_local_client_v1.py +26 -26
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +18 -28
- {python_roborock-2.34.2 → python_roborock-2.36.0}/LICENSE +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/README.md +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/cli.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/const.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/a01_channel.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/b01_channel.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/cache.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/channel.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/device.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/device_manager.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/b01/props.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/dyad.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/status.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/trait.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/zeo.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/v1_channel.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/v1_rpc_channel.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/b01_protocol.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/py.typed +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/util.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
- {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/web_api.py +0 -0
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import base64
|
|
7
6
|
import logging
|
|
8
|
-
import secrets
|
|
9
7
|
import time
|
|
10
8
|
from abc import ABC, abstractmethod
|
|
11
9
|
from typing import Any
|
|
@@ -37,14 +35,11 @@ class RoborockClient(ABC):
|
|
|
37
35
|
def __init__(self, device_info: DeviceData) -> None:
|
|
38
36
|
"""Initialize RoborockClient."""
|
|
39
37
|
self.device_info = device_info
|
|
40
|
-
self._nonce = secrets.token_bytes(16)
|
|
41
38
|
self._waiting_queue: dict[int, RoborockFuture] = {}
|
|
42
39
|
self._last_device_msg_in = time.monotonic()
|
|
43
40
|
self._last_disconnection = time.monotonic()
|
|
44
41
|
self.keep_alive = KEEPALIVE
|
|
45
|
-
self._diagnostic_data: dict[str, dict[str, Any]] = {
|
|
46
|
-
"misc_info": {"Nonce": base64.b64encode(self._nonce).decode("utf-8")}
|
|
47
|
-
}
|
|
42
|
+
self._diagnostic_data: dict[str, dict[str, Any]] = {}
|
|
48
43
|
self.is_available: bool = True
|
|
49
44
|
|
|
50
45
|
async def async_release(self) -> None:
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from roborock import DeviceFeatures
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RoborockModeEnum(StrEnum):
|
|
9
|
+
"""A custom StrEnum that also stores an integer code for each member."""
|
|
10
|
+
|
|
11
|
+
code: int
|
|
12
|
+
|
|
13
|
+
def __new__(cls, value: str, code: int) -> RoborockModeEnum:
|
|
14
|
+
"""Creates a new enum member."""
|
|
15
|
+
member = str.__new__(cls, value)
|
|
16
|
+
member._value_ = value
|
|
17
|
+
member.code = code
|
|
18
|
+
return member
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CleanModes(RoborockModeEnum):
|
|
22
|
+
GENTLE = ("gentle", 105)
|
|
23
|
+
OFF = ("off", 105)
|
|
24
|
+
QUIET = ("quiet", 101)
|
|
25
|
+
BALANCED = ("balanced", 102)
|
|
26
|
+
TURBO = ("turbo", 103)
|
|
27
|
+
MAX = ("max", 104)
|
|
28
|
+
MAX_PLUS = ("max_plus", 108)
|
|
29
|
+
CUSTOMIZED = ("custom", 106)
|
|
30
|
+
SMART_MODE = ("smart_mode", 110)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CleanRoutes(RoborockModeEnum):
|
|
34
|
+
STANDARD = ("standard", 300)
|
|
35
|
+
DEEP = ("deep", 301)
|
|
36
|
+
DEEP_PLUS = ("deep_plus", 303)
|
|
37
|
+
FAST = ("fast", 304)
|
|
38
|
+
DEEP_PLUS_CN = ("deep_plus", 305)
|
|
39
|
+
SMART_MODE = ("smart_mode", 306)
|
|
40
|
+
CUSTOMIZED = ("custom", 302)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CleanModesOld(RoborockModeEnum):
|
|
44
|
+
QUIET = ("quiet", 38)
|
|
45
|
+
BALANCED = ("balanced", 60)
|
|
46
|
+
TURBO = ("turbo", 75)
|
|
47
|
+
MAX = ("max", 100)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WaterModes(RoborockModeEnum):
|
|
51
|
+
OFF = ("off", 200)
|
|
52
|
+
LOW = ("low", 201)
|
|
53
|
+
MILD = ("mild", 201)
|
|
54
|
+
MEDIUM = ("medium", 202)
|
|
55
|
+
STANDARD = ("standard", 202)
|
|
56
|
+
HIGH = ("high", 203)
|
|
57
|
+
INTENSE = ("intense", 203)
|
|
58
|
+
CUSTOMIZED = ("custom", 204)
|
|
59
|
+
CUSTOM = ("custom_water_flow", 207)
|
|
60
|
+
EXTREME = ("extreme", 208)
|
|
61
|
+
SMART_MODE = ("smart_mode", 209)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_clean_modes(features: DeviceFeatures) -> list[CleanModes]:
|
|
65
|
+
"""Get the valid clean modes for the device - also known as 'fan power' or 'suction mode'"""
|
|
66
|
+
modes = [CleanModes.QUIET, CleanModes.BALANCED, CleanModes.TURBO, CleanModes.MAX]
|
|
67
|
+
if features.is_max_plus_mode_supported or features.is_none_pure_clean_mop_with_max_plus:
|
|
68
|
+
# If the vacuum has max plus mode supported
|
|
69
|
+
modes.append(CleanModes.MAX_PLUS)
|
|
70
|
+
if features.is_pure_clean_mop_supported:
|
|
71
|
+
# If the vacuum is capable of 'pure mop clean' aka no vacuum
|
|
72
|
+
modes.append(CleanModes.OFF)
|
|
73
|
+
else:
|
|
74
|
+
# If not, we can add gentle
|
|
75
|
+
modes.append(CleanModes.GENTLE)
|
|
76
|
+
return modes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_clean_routes(features: DeviceFeatures, region: str) -> list[CleanRoutes]:
|
|
80
|
+
"""The routes that the vacuum will take while mopping"""
|
|
81
|
+
if features.is_none_pure_clean_mop_with_max_plus:
|
|
82
|
+
return [CleanRoutes.FAST, CleanRoutes.STANDARD]
|
|
83
|
+
supported = [CleanRoutes.STANDARD, CleanRoutes.DEEP]
|
|
84
|
+
if features.is_careful_slow_mop_supported:
|
|
85
|
+
if not (
|
|
86
|
+
features.is_corner_clean_mode_supported
|
|
87
|
+
and features.is_clean_route_deep_slow_plus_supported
|
|
88
|
+
and region == "CN"
|
|
89
|
+
):
|
|
90
|
+
# for some reason there is a china specific deep plus mode
|
|
91
|
+
supported.append(CleanRoutes.DEEP_PLUS_CN)
|
|
92
|
+
else:
|
|
93
|
+
supported.append(CleanRoutes.DEEP_PLUS)
|
|
94
|
+
|
|
95
|
+
if features.is_clean_route_fast_mode_supported:
|
|
96
|
+
supported.append(CleanRoutes.FAST)
|
|
97
|
+
return supported
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_water_modes(features: DeviceFeatures) -> list[WaterModes]:
|
|
101
|
+
"""Get the valid water modes for the device - also known as 'water flow' or 'water level'"""
|
|
102
|
+
supported_modes = [WaterModes.OFF]
|
|
103
|
+
if features.is_mop_shake_module_supported:
|
|
104
|
+
# For mops that have the vibrating mop pad, they do mild standard intense
|
|
105
|
+
supported_modes.extend([WaterModes.MILD, WaterModes.STANDARD, WaterModes.INTENSE])
|
|
106
|
+
else:
|
|
107
|
+
supported_modes.extend([WaterModes.LOW, WaterModes.MEDIUM, WaterModes.HIGH])
|
|
108
|
+
if features.is_custom_water_box_distance_supported:
|
|
109
|
+
# This is for devices that allow you to set a custom water flow from 0-100
|
|
110
|
+
supported_modes.append(WaterModes.CUSTOM)
|
|
111
|
+
if features.is_mop_shake_module_supported and features.is_mop_shake_water_max_supported:
|
|
112
|
+
supported_modes.append(WaterModes.EXTREME)
|
|
113
|
+
return supported_modes
|
|
@@ -725,6 +725,29 @@ class NetworkInfo(RoborockBase):
|
|
|
725
725
|
rssi: int | None = None
|
|
726
726
|
|
|
727
727
|
|
|
728
|
+
@dataclass
|
|
729
|
+
class AppInitStatusLocalInfo(RoborockBase):
|
|
730
|
+
location: str
|
|
731
|
+
bom: str | None = None
|
|
732
|
+
featureset: int | None = None
|
|
733
|
+
language: str | None = None
|
|
734
|
+
logserver: str | None = None
|
|
735
|
+
wifiplan: str | None = None
|
|
736
|
+
timezone: str | None = None
|
|
737
|
+
name: str | None = None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@dataclass
|
|
741
|
+
class AppInitStatus(RoborockBase):
|
|
742
|
+
local_info: AppInitStatusLocalInfo
|
|
743
|
+
feature_info: list[int]
|
|
744
|
+
new_feature_info: int
|
|
745
|
+
new_feature_info_str: str
|
|
746
|
+
new_feature_info_2: int | None = None
|
|
747
|
+
carriage_type: int | None = None
|
|
748
|
+
dsp_version: int | None = None
|
|
749
|
+
|
|
750
|
+
|
|
728
751
|
@dataclass
|
|
729
752
|
class DeviceData(RoborockBase):
|
|
730
753
|
device: HomeDataDevice
|
|
@@ -423,6 +423,7 @@ class DeviceFeatures:
|
|
|
423
423
|
is_clean_route_setting_supported: bool = field(
|
|
424
424
|
metadata={"product_features": [ProductFeatures.MOP_SHAKE_MODULE, ProductFeatures.MOP_SPIN_MODULE]}
|
|
425
425
|
)
|
|
426
|
+
is_mop_shake_module_supported: bool = field(metadata={"product_features": [ProductFeatures.MOP_SHAKE_MODULE]})
|
|
426
427
|
|
|
427
428
|
@classmethod
|
|
428
429
|
def from_feature_flags(
|
|
@@ -11,6 +11,7 @@ from roborock.protocol import Decoder, Encoder, create_local_decoder, create_loc
|
|
|
11
11
|
from roborock.roborock_message import RoborockMessage
|
|
12
12
|
|
|
13
13
|
from .channel import Channel
|
|
14
|
+
from .pending import PendingRpcs
|
|
14
15
|
|
|
15
16
|
_LOGGER = logging.getLogger(__name__)
|
|
16
17
|
_PORT = 58867
|
|
@@ -47,10 +48,9 @@ class LocalChannel(Channel):
|
|
|
47
48
|
self._is_connected = False
|
|
48
49
|
|
|
49
50
|
# RPC support
|
|
50
|
-
self.
|
|
51
|
+
self._pending_rpcs: PendingRpcs[int, RoborockMessage] = PendingRpcs()
|
|
51
52
|
self._decoder: Decoder = create_local_decoder(local_key)
|
|
52
53
|
self._encoder: Encoder = create_local_encoder(local_key)
|
|
53
|
-
self._queue_lock = asyncio.Lock()
|
|
54
54
|
|
|
55
55
|
@property
|
|
56
56
|
def is_connected(self) -> bool:
|
|
@@ -114,11 +114,7 @@ class LocalChannel(Channel):
|
|
|
114
114
|
if (request_id := message.get_request_id()) is None:
|
|
115
115
|
_LOGGER.debug("Received message with no request_id")
|
|
116
116
|
return
|
|
117
|
-
|
|
118
|
-
if (future := self._waiting_queue.pop(request_id, None)) is not None:
|
|
119
|
-
future.set_result(message)
|
|
120
|
-
else:
|
|
121
|
-
_LOGGER.debug("Received message with no waiting handler: request_id=%s", request_id)
|
|
117
|
+
await self._pending_rpcs.resolve(request_id, message)
|
|
122
118
|
|
|
123
119
|
async def send_message(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
|
|
124
120
|
"""Send a command message and wait for the response message."""
|
|
@@ -132,24 +128,17 @@ class LocalChannel(Channel):
|
|
|
132
128
|
_LOGGER.exception("Error getting request_id from message: %s", err)
|
|
133
129
|
raise RoborockException(f"Invalid message format, Message must have a request_id: {err}") from err
|
|
134
130
|
|
|
135
|
-
future: asyncio.Future[RoborockMessage] =
|
|
136
|
-
async with self._queue_lock:
|
|
137
|
-
if request_id in self._waiting_queue:
|
|
138
|
-
raise RoborockException(f"Request ID {request_id} already pending, cannot send command")
|
|
139
|
-
self._waiting_queue[request_id] = future
|
|
140
|
-
|
|
131
|
+
future: asyncio.Future[RoborockMessage] = await self._pending_rpcs.start(request_id)
|
|
141
132
|
try:
|
|
142
133
|
encoded_msg = self._encoder(message)
|
|
143
134
|
self._transport.write(encoded_msg)
|
|
144
135
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
145
136
|
except asyncio.TimeoutError as ex:
|
|
146
|
-
|
|
147
|
-
self._waiting_queue.pop(request_id, None)
|
|
137
|
+
await self._pending_rpcs.pop(request_id)
|
|
148
138
|
raise RoborockException(f"Command timed out after {timeout}s") from ex
|
|
149
139
|
except Exception:
|
|
150
140
|
logging.exception("Uncaught error sending command")
|
|
151
|
-
|
|
152
|
-
self._waiting_queue.pop(request_id, None)
|
|
141
|
+
await self._pending_rpcs.pop(request_id)
|
|
153
142
|
raise
|
|
154
143
|
|
|
155
144
|
|
|
@@ -12,6 +12,7 @@ from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
|
|
|
12
12
|
from roborock.roborock_message import RoborockMessage
|
|
13
13
|
|
|
14
14
|
from .channel import Channel
|
|
15
|
+
from .pending import PendingRpcs
|
|
15
16
|
|
|
16
17
|
_LOGGER = logging.getLogger(__name__)
|
|
17
18
|
|
|
@@ -31,10 +32,9 @@ class MqttChannel(Channel):
|
|
|
31
32
|
self._mqtt_params = mqtt_params
|
|
32
33
|
|
|
33
34
|
# RPC support
|
|
34
|
-
self.
|
|
35
|
+
self._pending_rpcs: PendingRpcs[int, RoborockMessage] = PendingRpcs()
|
|
35
36
|
self._decoder = create_mqtt_decoder(local_key)
|
|
36
37
|
self._encoder = create_mqtt_encoder(local_key)
|
|
37
|
-
self._queue_lock = asyncio.Lock()
|
|
38
38
|
self._mqtt_unsub: Callable[[], None] | None = None
|
|
39
39
|
|
|
40
40
|
@property
|
|
@@ -89,11 +89,7 @@ class MqttChannel(Channel):
|
|
|
89
89
|
if (request_id := message.get_request_id()) is None:
|
|
90
90
|
_LOGGER.debug("Received message with no request_id")
|
|
91
91
|
return
|
|
92
|
-
|
|
93
|
-
if (future := self._waiting_queue.pop(request_id, None)) is not None:
|
|
94
|
-
future.set_result(message)
|
|
95
|
-
else:
|
|
96
|
-
_LOGGER.debug("Received message with no waiting handler: request_id=%s", request_id)
|
|
92
|
+
await self._pending_rpcs.resolve(request_id, message)
|
|
97
93
|
|
|
98
94
|
async def send_message(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
|
|
99
95
|
"""Send a command message and wait for the response message.
|
|
@@ -107,11 +103,7 @@ class MqttChannel(Channel):
|
|
|
107
103
|
_LOGGER.exception("Error getting request_id from message: %s", err)
|
|
108
104
|
raise RoborockException(f"Invalid message format, Message must have a request_id: {err}") from err
|
|
109
105
|
|
|
110
|
-
future: asyncio.Future[RoborockMessage] =
|
|
111
|
-
async with self._queue_lock:
|
|
112
|
-
if request_id in self._waiting_queue:
|
|
113
|
-
raise RoborockException(f"Request ID {request_id} already pending, cannot send command")
|
|
114
|
-
self._waiting_queue[request_id] = future
|
|
106
|
+
future: asyncio.Future[RoborockMessage] = await self._pending_rpcs.start(request_id)
|
|
115
107
|
|
|
116
108
|
try:
|
|
117
109
|
encoded_msg = self._encoder(message)
|
|
@@ -120,13 +112,11 @@ class MqttChannel(Channel):
|
|
|
120
112
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
121
113
|
|
|
122
114
|
except asyncio.TimeoutError as ex:
|
|
123
|
-
|
|
124
|
-
self._waiting_queue.pop(request_id, None)
|
|
115
|
+
await self._pending_rpcs.pop(request_id)
|
|
125
116
|
raise RoborockException(f"Command timed out after {timeout}s") from ex
|
|
126
117
|
except Exception:
|
|
127
118
|
logging.exception("Uncaught error sending command")
|
|
128
|
-
|
|
129
|
-
self._waiting_queue.pop(request_id, None)
|
|
119
|
+
await self._pending_rpcs.pop(request_id)
|
|
130
120
|
raise
|
|
131
121
|
|
|
132
122
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Module for managing pending RPCs."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from roborock.exceptions import RoborockException
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
K = TypeVar("K")
|
|
13
|
+
V = TypeVar("V")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PendingRpcs(Generic[K, V]):
|
|
17
|
+
"""Manage pending RPCs."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
"""Initialize the pending RPCs."""
|
|
21
|
+
self._queue_lock = asyncio.Lock()
|
|
22
|
+
self._waiting_queue: dict[K, asyncio.Future[V]] = {}
|
|
23
|
+
|
|
24
|
+
async def start(self, key: K) -> asyncio.Future[V]:
|
|
25
|
+
"""Start the pending RPCs."""
|
|
26
|
+
future: asyncio.Future[V] = asyncio.Future()
|
|
27
|
+
async with self._queue_lock:
|
|
28
|
+
if key in self._waiting_queue:
|
|
29
|
+
raise RoborockException(f"Request ID {key} already pending, cannot send command")
|
|
30
|
+
self._waiting_queue[key] = future
|
|
31
|
+
return future
|
|
32
|
+
|
|
33
|
+
async def pop(self, key: K) -> None:
|
|
34
|
+
"""Pop a pending RPC."""
|
|
35
|
+
async with self._queue_lock:
|
|
36
|
+
if (future := self._waiting_queue.pop(key, None)) is not None:
|
|
37
|
+
future.cancel()
|
|
38
|
+
|
|
39
|
+
async def resolve(self, key: K, value: V) -> None:
|
|
40
|
+
"""Resolve waiting future with proper locking."""
|
|
41
|
+
async with self._queue_lock:
|
|
42
|
+
if (future := self._waiting_queue.pop(key, None)) is not None:
|
|
43
|
+
future.set_result(value)
|
|
44
|
+
else:
|
|
45
|
+
_LOGGER.debug("Received unsolicited message: %s", key)
|
|
@@ -147,6 +147,26 @@ class Utils:
|
|
|
147
147
|
return unpad(decipher.decrypt(ciphertext), AES.block_size)
|
|
148
148
|
return ciphertext
|
|
149
149
|
|
|
150
|
+
@staticmethod
|
|
151
|
+
def encrypt_cbc(plaintext: bytes, token: bytes) -> bytes:
|
|
152
|
+
"""Encrypt plaintext with a given token using cbc mode.
|
|
153
|
+
|
|
154
|
+
This is currently used for testing purposes only.
|
|
155
|
+
|
|
156
|
+
:param bytes plaintext: Plaintext (json) to encrypt
|
|
157
|
+
:param bytes token: Token to use
|
|
158
|
+
:return: Encrypted bytes
|
|
159
|
+
"""
|
|
160
|
+
if not isinstance(plaintext, bytes):
|
|
161
|
+
raise TypeError("plaintext requires bytes")
|
|
162
|
+
Utils.verify_token(token)
|
|
163
|
+
iv = bytes(AES.block_size)
|
|
164
|
+
cipher = AES.new(token, AES.MODE_CBC, iv)
|
|
165
|
+
if plaintext:
|
|
166
|
+
plaintext = pad(plaintext, AES.block_size)
|
|
167
|
+
return cipher.encrypt(plaintext)
|
|
168
|
+
return plaintext
|
|
169
|
+
|
|
150
170
|
@staticmethod
|
|
151
171
|
def decrypt_cbc(ciphertext: bytes, token: bytes) -> bytes:
|
|
152
172
|
"""Decrypt ciphertext with a given token using cbc mode.
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import math
|
|
9
9
|
import secrets
|
|
10
|
+
import struct
|
|
10
11
|
import time
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from dataclasses import dataclass, field
|
|
@@ -44,6 +45,10 @@ class SecurityData:
|
|
|
44
45
|
"""Convert security data to a dictionary for sending in the payload."""
|
|
45
46
|
return {"security": {"endpoint": self.endpoint, "nonce": self.nonce.hex().lower()}}
|
|
46
47
|
|
|
48
|
+
def to_diagnostic_data(self) -> dict[str, Any]:
|
|
49
|
+
"""Convert security data to a dictionary for debugging purposes."""
|
|
50
|
+
return {"nonce": self.nonce.hex().lower()}
|
|
51
|
+
|
|
47
52
|
|
|
48
53
|
def create_security_data(rriot: RRiot) -> SecurityData:
|
|
49
54
|
"""Create a SecurityData instance for the given endpoint and nonce."""
|
|
@@ -142,3 +147,37 @@ def decode_rpc_response(message: RoborockMessage) -> dict[str, Any]:
|
|
|
142
147
|
if not isinstance(result, dict):
|
|
143
148
|
raise RoborockException(f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}")
|
|
144
149
|
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class MapResponse:
|
|
154
|
+
"""Data structure for the V1 Map response."""
|
|
155
|
+
|
|
156
|
+
request_id: int
|
|
157
|
+
"""The request ID of the map response."""
|
|
158
|
+
|
|
159
|
+
data: bytes
|
|
160
|
+
"""The map data, decrypted and decompressed."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse]:
|
|
164
|
+
"""Create a decoder for V1 map response messages."""
|
|
165
|
+
|
|
166
|
+
def _decode_map_response(message: RoborockMessage) -> MapResponse:
|
|
167
|
+
"""Decode a V1 map response message."""
|
|
168
|
+
if not message.payload or len(message.payload) < 24:
|
|
169
|
+
raise RoborockException("Invalid V1 map response format: missing payload")
|
|
170
|
+
header, body = message.payload[:24], message.payload[24:]
|
|
171
|
+
[endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header)
|
|
172
|
+
if not endpoint.decode().startswith(security_data.endpoint):
|
|
173
|
+
raise RoborockException(
|
|
174
|
+
f"Invalid V1 map response endpoint: {endpoint!r}, expected {security_data.endpoint!r}"
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
decrypted = Utils.decrypt_cbc(body, security_data.nonce)
|
|
178
|
+
except ValueError as err:
|
|
179
|
+
raise RoborockException("Failed to decode map message payload") from err
|
|
180
|
+
decompressed = Utils.decompress(decrypted)
|
|
181
|
+
return MapResponse(request_id=request_id, data=decompressed)
|
|
182
|
+
|
|
183
|
+
return _decode_map_response
|
|
@@ -256,23 +256,3 @@ class RoborockMessage:
|
|
|
256
256
|
data_point_response = json.loads(data_point)
|
|
257
257
|
return data_point_response.get("id")
|
|
258
258
|
return None
|
|
259
|
-
|
|
260
|
-
def get_method(self) -> str | None:
|
|
261
|
-
protocol = self.protocol
|
|
262
|
-
if self.payload and protocol in [4, 5, 101, 102]:
|
|
263
|
-
payload = json.loads(self.payload.decode())
|
|
264
|
-
for data_point_number, data_point in payload.get("dps").items():
|
|
265
|
-
if data_point_number in ["101", "102"]:
|
|
266
|
-
data_point_response = json.loads(data_point)
|
|
267
|
-
return data_point_response.get("method")
|
|
268
|
-
return None
|
|
269
|
-
|
|
270
|
-
def get_params(self) -> list | dict | None:
|
|
271
|
-
protocol = self.protocol
|
|
272
|
-
if self.payload and protocol in [4, 101, 102]:
|
|
273
|
-
payload = json.loads(self.payload.decode())
|
|
274
|
-
for data_point_number, data_point in payload.get("dps").items():
|
|
275
|
-
if data_point_number in ["101", "102"]:
|
|
276
|
-
data_point_response = json.loads(data_point)
|
|
277
|
-
return data_point_response.get("params")
|
|
278
|
-
return None
|
{python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import dataclasses
|
|
3
3
|
import json
|
|
4
|
-
import struct
|
|
5
4
|
import time
|
|
6
5
|
from abc import ABC, abstractmethod
|
|
7
6
|
from collections.abc import Callable, Coroutine
|
|
8
7
|
from typing import Any, TypeVar, final
|
|
9
8
|
|
|
10
9
|
from roborock import (
|
|
10
|
+
AppInitStatus,
|
|
11
11
|
DeviceProp,
|
|
12
12
|
DockSummary,
|
|
13
13
|
RoborockCommand,
|
|
@@ -45,7 +45,7 @@ from roborock.containers import (
|
|
|
45
45
|
ValleyElectricityTimer,
|
|
46
46
|
WashTowelMode,
|
|
47
47
|
)
|
|
48
|
-
from roborock.
|
|
48
|
+
from roborock.protocols.v1_protocol import MapResponse, SecurityData, create_map_response_decoder
|
|
49
49
|
from roborock.roborock_message import (
|
|
50
50
|
ROBOROCK_DATA_CONSUMABLE_PROTOCOL,
|
|
51
51
|
ROBOROCK_DATA_STATUS_PROTOCOL,
|
|
@@ -150,10 +150,15 @@ class RoborockClientV1(RoborockClient, ABC):
|
|
|
150
150
|
"""Roborock client base class for version 1 devices."""
|
|
151
151
|
|
|
152
152
|
_listeners: dict[str, ListenerModel] = {}
|
|
153
|
+
_map_response_decoder: Callable[[RoborockMessage], MapResponse] | None = None
|
|
153
154
|
|
|
154
|
-
def __init__(self, device_info: DeviceData,
|
|
155
|
+
def __init__(self, device_info: DeviceData, security_data: SecurityData | None) -> None:
|
|
155
156
|
"""Initializes the Roborock client."""
|
|
156
157
|
super().__init__(device_info)
|
|
158
|
+
if security_data is not None:
|
|
159
|
+
self._diagnostic_data.update({"misc_info": security_data.to_diagnostic_data()})
|
|
160
|
+
self._map_response_decoder = create_map_response_decoder(security_data)
|
|
161
|
+
|
|
157
162
|
self._status_type: type[Status] = ModelStatus.get(device_info.model, S7MaxVStatus)
|
|
158
163
|
self.cache: dict[CacheableAttribute, AttributeCache] = {
|
|
159
164
|
cacheable_attribute: AttributeCache(attr, self._send_command)
|
|
@@ -162,7 +167,6 @@ class RoborockClientV1(RoborockClient, ABC):
|
|
|
162
167
|
if device_info.device.duid not in self._listeners:
|
|
163
168
|
self._listeners[device_info.device.duid] = ListenerModel({}, self.cache)
|
|
164
169
|
self.listener_model = self._listeners[device_info.device.duid]
|
|
165
|
-
self._endpoint = endpoint
|
|
166
170
|
|
|
167
171
|
async def async_release(self) -> None:
|
|
168
172
|
await super().async_release()
|
|
@@ -339,6 +343,10 @@ class RoborockClientV1(RoborockClient, ABC):
|
|
|
339
343
|
"""Load the map into the vacuum's memory."""
|
|
340
344
|
await self.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
|
|
341
345
|
|
|
346
|
+
async def get_app_init_status(self) -> AppInitStatus:
|
|
347
|
+
"""Gets the app init status (needed for determining vacuum capabilities)."""
|
|
348
|
+
return await self.send_command(RoborockCommand.APP_GET_INIT_STATUS, return_type=AppInitStatus)
|
|
349
|
+
|
|
342
350
|
@abstractmethod
|
|
343
351
|
async def _send_command(
|
|
344
352
|
self,
|
|
@@ -429,21 +437,15 @@ class RoborockClientV1(RoborockClient, ABC):
|
|
|
429
437
|
dps = {data_point_number: data_point}
|
|
430
438
|
self._logger.debug(f"Got unknown data point {dps}")
|
|
431
439
|
elif data.payload and protocol == RoborockMessageProtocol.MAP_RESPONSE:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
try:
|
|
436
|
-
decrypted = Utils.decrypt_cbc(data.payload[24:], self._nonce)
|
|
437
|
-
except ValueError as err:
|
|
438
|
-
raise RoborockException(f"Failed to decode {data.payload!r} for {data.protocol}") from err
|
|
439
|
-
decompressed = Utils.decompress(decrypted)
|
|
440
|
-
queue = self._waiting_queue.get(request_id)
|
|
440
|
+
if self._map_response_decoder is not None:
|
|
441
|
+
map_response = self._map_response_decoder(data)
|
|
442
|
+
queue = self._waiting_queue.get(map_response.request_id)
|
|
441
443
|
if queue:
|
|
442
|
-
|
|
443
|
-
decompressed = decompressed[0]
|
|
444
|
-
queue.set_result(decompressed)
|
|
444
|
+
queue.set_result(map_response.data)
|
|
445
445
|
else:
|
|
446
|
-
self._logger.debug(
|
|
446
|
+
self._logger.debug(
|
|
447
|
+
"Received unsolicited map response for request_id %s", map_response.request_id
|
|
448
|
+
)
|
|
447
449
|
else:
|
|
448
450
|
queue = self._waiting_queue.get(data.seq)
|
|
449
451
|
if queue:
|
|
@@ -18,6 +18,19 @@ from .roborock_client_v1 import CLOUD_REQUIRED, RoborockClientV1
|
|
|
18
18
|
_LOGGER = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
_HELLO_REQUEST_MESSAGE = RoborockMessage(
|
|
22
|
+
protocol=RoborockMessageProtocol.HELLO_REQUEST,
|
|
23
|
+
seq=1,
|
|
24
|
+
random=22,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_PING_REQUEST_MESSAGE = RoborockMessage(
|
|
28
|
+
protocol=RoborockMessageProtocol.PING_REQUEST,
|
|
29
|
+
seq=2,
|
|
30
|
+
random=23,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
21
34
|
@dataclass
|
|
22
35
|
class _LocalProtocol(asyncio.Protocol):
|
|
23
36
|
"""Callbacks for the Roborock local client transport."""
|
|
@@ -47,7 +60,7 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
|
|
|
47
60
|
self.transport: Transport | None = None
|
|
48
61
|
self._mutex = Lock()
|
|
49
62
|
self.keep_alive_task: TimerHandle | None = None
|
|
50
|
-
RoborockClientV1.__init__(self, device_data,
|
|
63
|
+
RoborockClientV1.__init__(self, device_data, security_data=None)
|
|
51
64
|
RoborockClient.__init__(self, device_data)
|
|
52
65
|
self._local_protocol = _LocalProtocol(self._data_received, self._connection_lost)
|
|
53
66
|
self._encoder: Encoder = create_local_encoder(device_data.device.local_key)
|
|
@@ -109,29 +122,13 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
|
|
|
109
122
|
self._sync_disconnect()
|
|
110
123
|
|
|
111
124
|
async def hello(self):
|
|
112
|
-
request_id = 1
|
|
113
|
-
protocol = RoborockMessageProtocol.HELLO_REQUEST
|
|
114
125
|
try:
|
|
115
|
-
return await self._send_message(
|
|
116
|
-
RoborockMessage(
|
|
117
|
-
protocol=protocol,
|
|
118
|
-
seq=request_id,
|
|
119
|
-
random=22,
|
|
120
|
-
)
|
|
121
|
-
)
|
|
126
|
+
return await self._send_message(_HELLO_REQUEST_MESSAGE)
|
|
122
127
|
except Exception as e:
|
|
123
128
|
self._logger.error(e)
|
|
124
129
|
|
|
125
130
|
async def ping(self) -> None:
|
|
126
|
-
|
|
127
|
-
protocol = RoborockMessageProtocol.PING_REQUEST
|
|
128
|
-
return await self._send_message(
|
|
129
|
-
RoborockMessage(
|
|
130
|
-
protocol=protocol,
|
|
131
|
-
seq=request_id,
|
|
132
|
-
random=23,
|
|
133
|
-
)
|
|
134
|
-
)
|
|
131
|
+
await self._send_message(_PING_REQUEST_MESSAGE)
|
|
135
132
|
|
|
136
133
|
def _send_msg_raw(self, data: bytes):
|
|
137
134
|
try:
|
|
@@ -151,12 +148,15 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
|
|
|
151
148
|
|
|
152
149
|
roborock_message = encode_local_payload(method, params)
|
|
153
150
|
self._logger.debug("Building message id %s for method %s", roborock_message.get_request_id(), method)
|
|
154
|
-
return await self._send_message(roborock_message)
|
|
151
|
+
return await self._send_message(roborock_message, method, params)
|
|
155
152
|
|
|
156
|
-
async def _send_message(
|
|
153
|
+
async def _send_message(
|
|
154
|
+
self,
|
|
155
|
+
roborock_message: RoborockMessage,
|
|
156
|
+
method: str | None = None,
|
|
157
|
+
params: list | dict | int | None = None,
|
|
158
|
+
) -> RoborockMessage:
|
|
157
159
|
await self.validate_connection()
|
|
158
|
-
method = roborock_message.get_method()
|
|
159
|
-
params = roborock_message.get_params()
|
|
160
160
|
request_id: int | None
|
|
161
161
|
if not method or not method.startswith("get"):
|
|
162
162
|
request_id = roborock_message.seq
|
|
@@ -177,16 +177,16 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
|
|
|
177
177
|
response = await async_response
|
|
178
178
|
except VacuumError as err:
|
|
179
179
|
self._diagnostic_data[diagnostic_key] = {
|
|
180
|
-
"params":
|
|
180
|
+
"params": params,
|
|
181
181
|
"error": err,
|
|
182
182
|
}
|
|
183
183
|
raise CommandVacuumError(method, err) from err
|
|
184
184
|
self._diagnostic_data[diagnostic_key] = {
|
|
185
|
-
"params":
|
|
185
|
+
"params": params,
|
|
186
186
|
"response": response,
|
|
187
187
|
}
|
|
188
188
|
if roborock_message.protocol == RoborockMessageProtocol.GENERAL_REQUEST:
|
|
189
|
-
self._logger.debug(f"id={request_id} Response from method {
|
|
189
|
+
self._logger.debug(f"id={request_id} Response from method {method}: {response}")
|
|
190
190
|
if response == "retry":
|
|
191
191
|
raise RoborockException(f"Command {method} failed with 'retry' message; Device is busy, try again later")
|
|
192
192
|
return response
|
{python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import base64
|
|
2
1
|
import logging
|
|
3
2
|
|
|
4
3
|
from vacuum_map_parser_base.config.color import ColorsPalette
|
|
@@ -10,10 +9,8 @@ from roborock.cloud_api import RoborockMqttClient
|
|
|
10
9
|
|
|
11
10
|
from ..containers import DeviceData, UserData
|
|
12
11
|
from ..exceptions import CommandVacuumError, RoborockException, VacuumError
|
|
13
|
-
from ..
|
|
14
|
-
from ..protocols.v1_protocol import SecurityData, create_mqtt_payload_encoder
|
|
12
|
+
from ..protocols.v1_protocol import create_mqtt_payload_encoder, create_security_data
|
|
15
13
|
from ..roborock_message import (
|
|
16
|
-
RoborockMessage,
|
|
17
14
|
RoborockMessageProtocol,
|
|
18
15
|
)
|
|
19
16
|
from ..roborock_typing import RoborockCommand
|
|
@@ -31,20 +28,26 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
|
|
|
31
28
|
rriot = user_data.rriot
|
|
32
29
|
if rriot is None:
|
|
33
30
|
raise RoborockException("Got no rriot data from user_data")
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
security_data = create_security_data(rriot)
|
|
36
32
|
RoborockMqttClient.__init__(self, user_data, device_info)
|
|
37
|
-
RoborockClientV1.__init__(self, device_info,
|
|
33
|
+
RoborockClientV1.__init__(self, device_info, security_data=security_data)
|
|
38
34
|
self.queue_timeout = queue_timeout
|
|
39
35
|
self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
|
|
40
|
-
self._payload_encoder = create_mqtt_payload_encoder(
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
self._payload_encoder = create_mqtt_payload_encoder(security_data)
|
|
37
|
+
|
|
38
|
+
async def _send_command(
|
|
39
|
+
self,
|
|
40
|
+
method: RoborockCommand | str,
|
|
41
|
+
params: list | dict | int | None = None,
|
|
42
|
+
):
|
|
43
|
+
if method in CUSTOM_COMMANDS:
|
|
44
|
+
# When we have more custom commands do something more complicated here
|
|
45
|
+
return await self._get_calibration_points()
|
|
46
|
+
|
|
47
|
+
roborock_message = self._payload_encoder(method, params)
|
|
48
|
+
self._logger.debug("Building message id %s for method %s", roborock_message.get_request_id, method)
|
|
43
49
|
|
|
44
|
-
async def send_message(self, roborock_message: RoborockMessage):
|
|
45
50
|
await self.validate_connection()
|
|
46
|
-
method = roborock_message.get_method()
|
|
47
|
-
params = roborock_message.get_params()
|
|
48
51
|
request_id = roborock_message.get_request_id()
|
|
49
52
|
if request_id is None:
|
|
50
53
|
raise RoborockException(f"Failed build message {roborock_message}")
|
|
@@ -60,12 +63,12 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
|
|
|
60
63
|
response = await async_response
|
|
61
64
|
except VacuumError as err:
|
|
62
65
|
self._diagnostic_data[diagnostic_key] = {
|
|
63
|
-
"params":
|
|
66
|
+
"params": params,
|
|
64
67
|
"error": err,
|
|
65
68
|
}
|
|
66
69
|
raise CommandVacuumError(method, err) from err
|
|
67
70
|
self._diagnostic_data[diagnostic_key] = {
|
|
68
|
-
"params":
|
|
71
|
+
"params": params,
|
|
69
72
|
"response": response,
|
|
70
73
|
}
|
|
71
74
|
if response_protocol == RoborockMessageProtocol.MAP_RESPONSE:
|
|
@@ -74,19 +77,6 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
|
|
|
74
77
|
self._logger.debug(f"id={request_id} Response from {method}: {response}")
|
|
75
78
|
return response
|
|
76
79
|
|
|
77
|
-
async def _send_command(
|
|
78
|
-
self,
|
|
79
|
-
method: RoborockCommand | str,
|
|
80
|
-
params: list | dict | int | None = None,
|
|
81
|
-
):
|
|
82
|
-
if method in CUSTOM_COMMANDS:
|
|
83
|
-
# When we have more custom commands do something more complicated here
|
|
84
|
-
return await self._get_calibration_points()
|
|
85
|
-
|
|
86
|
-
roborock_message = self._payload_encoder(method, params)
|
|
87
|
-
self._logger.debug("Building message id %s for method %s", roborock_message.get_request_id, method)
|
|
88
|
-
return await self.send_message(roborock_message)
|
|
89
|
-
|
|
90
80
|
async def _get_calibration_points(self):
|
|
91
81
|
map: bytes = await self.send_command(RoborockCommand.GET_MAP_V1)
|
|
92
82
|
parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/roborock_client_a01.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|