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.
Files changed (54) hide show
  1. {python_roborock-2.34.2 → python_roborock-2.36.0}/PKG-INFO +1 -1
  2. {python_roborock-2.34.2 → python_roborock-2.36.0}/pyproject.toml +1 -1
  3. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/api.py +1 -6
  4. python_roborock-2.36.0/roborock/clean_modes.py +113 -0
  5. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/containers.py +23 -0
  6. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/device_features.py +1 -0
  7. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/local_channel.py +6 -17
  8. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/mqtt_channel.py +6 -16
  9. python_roborock-2.36.0/roborock/devices/pending.py +45 -0
  10. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocol.py +20 -0
  11. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/v1_protocol.py +39 -0
  12. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_message.py +0 -20
  13. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_client_v1.py +19 -17
  14. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_local_client_v1.py +26 -26
  15. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +18 -28
  16. {python_roborock-2.34.2 → python_roborock-2.36.0}/LICENSE +0 -0
  17. {python_roborock-2.34.2 → python_roborock-2.36.0}/README.md +0 -0
  18. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/__init__.py +0 -0
  19. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/cli.py +0 -0
  20. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/cloud_api.py +0 -0
  21. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/code_mappings.py +0 -0
  22. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/command_cache.py +0 -0
  23. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/const.py +0 -0
  24. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/README.md +0 -0
  25. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/__init__.py +0 -0
  26. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/a01_channel.py +0 -0
  27. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/b01_channel.py +0 -0
  28. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/cache.py +0 -0
  29. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/channel.py +0 -0
  30. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/device.py +0 -0
  31. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/device_manager.py +0 -0
  32. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/b01/__init__.py +0 -0
  33. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/b01/props.py +0 -0
  34. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/dyad.py +0 -0
  35. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/status.py +0 -0
  36. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/trait.py +0 -0
  37. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/traits/zeo.py +0 -0
  38. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/v1_channel.py +0 -0
  39. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/devices/v1_rpc_channel.py +0 -0
  40. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/exceptions.py +0 -0
  41. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/__init__.py +0 -0
  42. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/roborock_session.py +0 -0
  43. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/mqtt/session.py +0 -0
  44. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/a01_protocol.py +0 -0
  45. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/protocols/b01_protocol.py +0 -0
  46. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/py.typed +0 -0
  47. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_future.py +0 -0
  48. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/roborock_typing.py +0 -0
  49. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/util.py +0 -0
  50. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_1_apis/__init__.py +0 -0
  51. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/__init__.py +0 -0
  52. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  53. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  54. {python_roborock-2.34.2 → python_roborock-2.36.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.34.2
3
+ Version: 2.36.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.34.2"
3
+ version = "2.36.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -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._waiting_queue: dict[int, asyncio.Future[RoborockMessage]] = {}
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
- async with self._queue_lock:
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] = asyncio.Future()
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
- async with self._queue_lock:
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
- async with self._queue_lock:
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._waiting_queue: dict[int, asyncio.Future[RoborockMessage]] = {}
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
- async with self._queue_lock:
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] = asyncio.Future()
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
- async with self._queue_lock:
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
- async with self._queue_lock:
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
@@ -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.protocol import Utils
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, endpoint: str):
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
- payload = data.payload[0:24]
433
- [endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", payload)
434
- if endpoint.decode().startswith(self._endpoint):
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
- if isinstance(decompressed, list):
443
- decompressed = decompressed[0]
444
- queue.set_result(decompressed)
444
+ queue.set_result(map_response.data)
445
445
  else:
446
- self._logger.debug("Received response for unknown request id %s", request_id)
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, "abc")
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
- request_id = 2
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(self, roborock_message: RoborockMessage):
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": roborock_message.get_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": roborock_message.get_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 {roborock_message.get_method()}: {response}")
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
@@ -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 ..protocol import Utils
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
- endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
35
-
31
+ security_data = create_security_data(rriot)
36
32
  RoborockMqttClient.__init__(self, user_data, device_info)
37
- RoborockClientV1.__init__(self, device_info, endpoint)
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
- SecurityData(endpoint=self._endpoint, nonce=self._nonce),
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": roborock_message.get_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": roborock_message.get_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(), [])