python-roborock 2.44.1__tar.gz → 2.46.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 (59) hide show
  1. {python_roborock-2.44.1 → python_roborock-2.46.0}/PKG-INFO +1 -1
  2. {python_roborock-2.44.1 → python_roborock-2.46.0}/pyproject.toml +1 -1
  3. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/api.py +1 -7
  4. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/cloud_api.py +92 -13
  5. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/exceptions.py +1 -0
  6. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_1_apis/roborock_local_client_v1.py +7 -1
  7. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +1 -1
  8. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +1 -1
  9. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/web_api.py +108 -0
  10. {python_roborock-2.44.1 → python_roborock-2.46.0}/LICENSE +0 -0
  11. {python_roborock-2.44.1 → python_roborock-2.46.0}/README.md +0 -0
  12. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/__init__.py +0 -0
  13. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/b01_containers.py +0 -0
  14. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/broadcast_protocol.py +0 -0
  15. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/callbacks.py +0 -0
  16. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/clean_modes.py +0 -0
  17. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/cli.py +0 -0
  18. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/code_mappings.py +0 -0
  19. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/command_cache.py +0 -0
  20. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/const.py +0 -0
  21. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/containers.py +0 -0
  22. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/device_features.py +0 -0
  23. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/README.md +0 -0
  24. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/__init__.py +0 -0
  25. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/a01_channel.py +0 -0
  26. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/b01_channel.py +0 -0
  27. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/cache.py +0 -0
  28. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/channel.py +0 -0
  29. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/device.py +0 -0
  30. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/device_manager.py +0 -0
  31. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/local_channel.py +0 -0
  32. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/mqtt_channel.py +0 -0
  33. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/b01/__init__.py +0 -0
  34. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/b01/props.py +0 -0
  35. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/clean_summary.py +0 -0
  36. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/dnd.py +0 -0
  37. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/dyad.py +0 -0
  38. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/sound_volume.py +0 -0
  39. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/status.py +0 -0
  40. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/trait.py +0 -0
  41. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/traits/zeo.py +0 -0
  42. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/v1_channel.py +0 -0
  43. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/devices/v1_rpc_channel.py +0 -0
  44. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/mqtt/__init__.py +0 -0
  45. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/mqtt/roborock_session.py +0 -0
  46. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/mqtt/session.py +0 -0
  47. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/protocol.py +0 -0
  48. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/protocols/a01_protocol.py +0 -0
  49. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/protocols/b01_protocol.py +0 -0
  50. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/protocols/v1_protocol.py +0 -0
  51. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/py.typed +0 -0
  52. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/roborock_future.py +0 -0
  53. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/roborock_message.py +0 -0
  54. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/roborock_typing.py +0 -0
  55. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/util.py +0 -0
  56. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_1_apis/__init__.py +0 -0
  57. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  58. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_a01_apis/__init__.py +0 -0
  59. {python_roborock-2.44.1 → python_roborock-2.46.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.44.1
3
+ Version: 2.46.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.44.1"
3
+ version = "2.46.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"
@@ -23,7 +23,7 @@ from .roborock_message import (
23
23
  from .util import get_next_int
24
24
 
25
25
  _LOGGER = logging.getLogger(__name__)
26
- KEEPALIVE = 60
26
+ KEEPALIVE = 70
27
27
 
28
28
 
29
29
  class RoborockClient(ABC):
@@ -78,12 +78,6 @@ class RoborockClient(ABC):
78
78
  return False
79
79
  return True
80
80
 
81
- async def validate_connection(self) -> None:
82
- if not self.should_keepalive():
83
- self._logger.info("Resetting Roborock connection due to keepalive timeout")
84
- await self.async_disconnect()
85
- await self.async_connect()
86
-
87
81
  async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any:
88
82
  try:
89
83
  response = await queue.async_get(self.queue_timeout)
@@ -8,6 +8,10 @@ from asyncio import Lock
8
8
  from typing import Any
9
9
 
10
10
  import paho.mqtt.client as mqtt
11
+ from paho.mqtt.enums import MQTTErrorCode
12
+
13
+ # Mypy is not seeing this for some reason. It wants me to use the depreciated ReasonCodes
14
+ from paho.mqtt.reasoncodes import ReasonCode # type: ignore
11
15
 
12
16
  from .api import KEEPALIVE, RoborockClient
13
17
  from .containers import DeviceData, UserData
@@ -67,7 +71,8 @@ class RoborockMqttClient(RoborockClient, ABC):
67
71
  self._mqtt_client = _Mqtt()
68
72
  self._mqtt_client.on_connect = self._mqtt_on_connect
69
73
  self._mqtt_client.on_message = self._mqtt_on_message
70
- self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
74
+ # Due to the incorrect ReasonCode, it is confused by typing
75
+ self._mqtt_client.on_disconnect = self._mqtt_on_disconnect # type: ignore
71
76
  if mqtt_params.tls:
72
77
  self._mqtt_client.tls_set()
73
78
 
@@ -76,12 +81,20 @@ class RoborockMqttClient(RoborockClient, ABC):
76
81
  self._mutex = Lock()
77
82
  self._decoder: Decoder = create_mqtt_decoder(device_info.device.local_key)
78
83
  self._encoder: Encoder = create_mqtt_encoder(device_info.device.local_key)
84
+ self.received_message_since_last_disconnect = False
85
+ self._topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
79
86
 
80
- def _mqtt_on_connect(self, *args, **kwargs):
81
- _, __, ___, rc, ____ = args
87
+ def _mqtt_on_connect(
88
+ self,
89
+ client: mqtt.Client,
90
+ userdata: object,
91
+ flags: dict[str, int],
92
+ rc: ReasonCode,
93
+ properties: mqtt.Properties | None = None,
94
+ ):
82
95
  connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID)
83
- if rc != mqtt.MQTT_ERR_SUCCESS:
84
- message = f"Failed to connect ({mqtt.error_string(rc)})"
96
+ if rc.is_failure:
97
+ message = f"Failed to connect ({rc})"
85
98
  self._logger.error(message)
86
99
  if connection_queue:
87
100
  connection_queue.set_exception(VacuumError(message))
@@ -89,19 +102,19 @@ class RoborockMqttClient(RoborockClient, ABC):
89
102
  self._logger.debug("Failed to notify connect future, not in queue")
90
103
  return
91
104
  self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}")
92
- topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
93
- (result, mid) = self._mqtt_client.subscribe(topic)
105
+ (result, mid) = self._mqtt_client.subscribe(self._topic)
94
106
  if result != 0:
95
- message = f"Failed to subscribe ({mqtt.error_string(rc)})"
107
+ message = f"Failed to subscribe ({str(rc)})"
96
108
  self._logger.error(message)
97
109
  if connection_queue:
98
110
  connection_queue.set_exception(VacuumError(message))
99
111
  return
100
- self._logger.info(f"Subscribed to topic {topic}")
112
+ self._logger.info(f"Subscribed to topic {self._topic}")
101
113
  if connection_queue:
102
114
  connection_queue.set_result(True)
103
115
 
104
116
  def _mqtt_on_message(self, *args, **kwargs):
117
+ self.received_message_since_last_disconnect = True
105
118
  client, __, msg = args
106
119
  try:
107
120
  messages = self._decoder(msg.payload)
@@ -109,10 +122,16 @@ class RoborockMqttClient(RoborockClient, ABC):
109
122
  except Exception as ex:
110
123
  self._logger.exception(ex)
111
124
 
112
- def _mqtt_on_disconnect(self, *args, **kwargs):
113
- _, __, rc, ___ = args
125
+ def _mqtt_on_disconnect(
126
+ self,
127
+ client: mqtt.Client,
128
+ data: object,
129
+ flags: dict[str, int],
130
+ rc: ReasonCode | None,
131
+ properties: mqtt.Properties | None = None,
132
+ ):
114
133
  try:
115
- exc = RoborockException(mqtt.error_string(rc)) if rc != mqtt.MQTT_ERR_SUCCESS else None
134
+ exc = RoborockException(str(rc)) if rc is not None and rc.is_failure else None
116
135
  super().on_connection_lost(exc)
117
136
  connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID)
118
137
  if connection_queue:
@@ -138,7 +157,7 @@ class RoborockMqttClient(RoborockClient, ABC):
138
157
 
139
158
  if rc != mqtt.MQTT_ERR_SUCCESS:
140
159
  disconnected_future.cancel()
141
- raise RoborockException(f"Failed to disconnect ({mqtt.error_string(rc)})")
160
+ raise RoborockException(f"Failed to disconnect ({str(rc)})")
142
161
 
143
162
  return disconnected_future
144
163
 
@@ -178,3 +197,63 @@ class RoborockMqttClient(RoborockClient, ABC):
178
197
  )
179
198
  if info.rc != mqtt.MQTT_ERR_SUCCESS:
180
199
  raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
200
+
201
+ async def _unsubscribe(self) -> MQTTErrorCode:
202
+ """Unsubscribe from the topic."""
203
+ loop = asyncio.get_running_loop()
204
+ (result, mid) = await loop.run_in_executor(None, self._mqtt_client.unsubscribe, self._topic)
205
+
206
+ if result != 0:
207
+ message = f"Failed to unsubscribe ({mqtt.error_string(result)})"
208
+ self._logger.error(message)
209
+ else:
210
+ self._logger.info(f"Unsubscribed from topic {self._topic}")
211
+ return result
212
+
213
+ async def _subscribe(self) -> MQTTErrorCode:
214
+ """Subscribe to the topic."""
215
+ loop = asyncio.get_running_loop()
216
+ (result, mid) = await loop.run_in_executor(None, self._mqtt_client.subscribe, self._topic)
217
+
218
+ if result != 0:
219
+ message = f"Failed to subscribe ({mqtt.error_string(result)})"
220
+ self._logger.error(message)
221
+ else:
222
+ self._logger.info(f"Subscribed to topic {self._topic}")
223
+ return result
224
+
225
+ async def _reconnect(self) -> None:
226
+ """Reconnect to the MQTT broker."""
227
+ await self.async_disconnect()
228
+ await self.async_connect()
229
+
230
+ async def _validate_connection(self) -> None:
231
+ """Override the default validate connection to try to re-subscribe rather than disconnect.
232
+ When something seems to be wrong with our connection, we should follow the following steps:
233
+ 1. Try to unsubscribe and resubscribe from the topic.
234
+ 2. If we don't end up getting a message, we should completely disconnect and reconnect to the MQTT broker.
235
+ 3. We will continue to try to disconnect and reconnect until we get a message.
236
+ 4. If we get a message, the next time connection is lost, We will go back to step 1.
237
+ """
238
+ # If we should no longer keep the current connection alive...
239
+ if not self.should_keepalive():
240
+ self._logger.info("Resetting Roborock connection due to keepalive timeout")
241
+ if not self.received_message_since_last_disconnect:
242
+ # If we have already tried to unsub and resub, and we are still in this state,
243
+ # we should try to reconnect.
244
+ return await self._reconnect()
245
+ try:
246
+ # Mark that we have tried to unsubscribe and resubscribe
247
+ self.received_message_since_last_disconnect = False
248
+ if await self._unsubscribe() != 0:
249
+ # If we fail to unsubscribe, reconnect to the broker
250
+ return await self._reconnect()
251
+ if await self._subscribe() != 0:
252
+ # If we fail to subscribe, reconnected to the broker.
253
+ return await self._reconnect()
254
+
255
+ except Exception: # noqa
256
+ # If we get any errors at all, we should just reconnect.
257
+ return await self._reconnect()
258
+ # Call connect to make sure everything is still in a good state.
259
+ await self.async_connect()
@@ -1,4 +1,5 @@
1
1
  """Roborock exceptions."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -138,6 +138,12 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
138
138
  response_protocol=RoborockMessageProtocol.PING_RESPONSE,
139
139
  )
140
140
 
141
+ async def _validate_connection(self) -> None:
142
+ if not self.should_keepalive():
143
+ self._logger.info("Resetting Roborock connection due to keepalive timeout")
144
+ await self.async_disconnect()
145
+ await self.async_connect()
146
+
141
147
  def _send_msg_raw(self, data: bytes):
142
148
  try:
143
149
  if not self.transport:
@@ -172,7 +178,7 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
172
178
  method: str | None = None,
173
179
  params: list | dict | int | None = None,
174
180
  ) -> RoborockMessage:
175
- await self.validate_connection()
181
+ await self._validate_connection()
176
182
  msg = self._encoder(roborock_message)
177
183
  if method:
178
184
  self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
@@ -51,7 +51,7 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
51
51
  )
52
52
  self._logger.debug("Building message id %s for method %s", request_message.request_id, method)
53
53
 
54
- await self.validate_connection()
54
+ await self._validate_connection()
55
55
  request_id = request_message.request_id
56
56
  response_protocol = (
57
57
  RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE
@@ -40,7 +40,7 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
40
40
  self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
41
41
 
42
42
  async def _send_message(self, roborock_message: RoborockMessage):
43
- await self.validate_connection()
43
+ await self._validate_connection()
44
44
  response_protocol = RoborockMessageProtocol.RPC_RESPONSE
45
45
 
46
46
  m = self._encoder(roborock_message)
@@ -6,6 +6,7 @@ import hmac
6
6
  import logging
7
7
  import math
8
8
  import secrets
9
+ import string
9
10
  import time
10
11
 
11
12
  import aiohttp
@@ -190,6 +191,113 @@ class RoborockApiClient:
190
191
  else:
191
192
  raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
192
193
 
194
+ async def request_code_v4(self) -> None:
195
+ """Request a code using the v4 endpoint."""
196
+ try:
197
+ self._login_limiter.try_acquire("login")
198
+ except BucketFullException as ex:
199
+ _LOGGER.info(ex.meta_info)
200
+ raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
201
+ base_url = await self._get_base_url()
202
+ header_clientid = self._get_header_client_id()
203
+ code_request = PreparedRequest(
204
+ base_url,
205
+ self.session,
206
+ {
207
+ "header_clientid": header_clientid,
208
+ "Content-Type": "application/x-www-form-urlencoded",
209
+ "header_clientlang": "en",
210
+ },
211
+ )
212
+
213
+ code_response = await code_request.request(
214
+ "post",
215
+ "/api/v4/email/code/send",
216
+ params={"email": self._username, "type": "login", "platform": ""},
217
+ )
218
+ if code_response is None:
219
+ raise RoborockException("Failed to get a response from send email code")
220
+ response_code = code_response.get("code")
221
+ if response_code != 200:
222
+ _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
223
+ if response_code == 2008:
224
+ raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
225
+ elif response_code == 9002:
226
+ raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
227
+ else:
228
+ raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
229
+
230
+ async def sign_key_v3(self, s: str) -> str:
231
+ """Sign a randomly generated string."""
232
+ base_url = await self._get_base_url()
233
+ header_clientid = self._get_header_client_id()
234
+ code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
235
+
236
+ code_response = await code_request.request(
237
+ "post",
238
+ "/api/v3/key/sign",
239
+ params={"s": s},
240
+ )
241
+
242
+ if not code_response or "data" not in code_response or "k" not in code_response["data"]:
243
+ raise RoborockException("Failed to get a response from sign key")
244
+ response_code = code_response.get("code")
245
+
246
+ if response_code != 200:
247
+ _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
248
+ raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
249
+
250
+ return code_response["data"]["k"]
251
+
252
+ async def code_login_v4(self, code: int | str, country: str, country_code: int) -> UserData:
253
+ """
254
+ Login via code authentication.
255
+ :param code: The code from the email.
256
+ :param country: The two-character representation of the country, i.e. "US"
257
+ :param country_code: the country phone number code i.e. 1 for US.
258
+ """
259
+ base_url = await self._get_base_url()
260
+ header_clientid = self._get_header_client_id()
261
+ x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
262
+ x_mercy_k = await self.sign_key_v3(x_mercy_ks)
263
+ login_request = PreparedRequest(
264
+ base_url,
265
+ self.session,
266
+ {"header_clientid": header_clientid, "x-mercy-ks": x_mercy_ks, "x-mercy-k": x_mercy_k},
267
+ )
268
+ login_response = await login_request.request(
269
+ "post",
270
+ "/api/v4/auth/email/login/code",
271
+ params={
272
+ "country": country,
273
+ "countryCode": country_code,
274
+ "email": self._username,
275
+ "code": code,
276
+ # Major and minor version are the user agreement version, we will need to see if this needs to be
277
+ # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US
278
+ "majorVersion": 14,
279
+ "minorVersion": 0,
280
+ },
281
+ )
282
+ if login_response is None:
283
+ raise RoborockException("Login request response is None")
284
+ response_code = login_response.get("code")
285
+ if response_code != 200:
286
+ _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
287
+ if response_code == 2018:
288
+ raise RoborockInvalidCode("Invalid code - check your code and try again.")
289
+ if response_code == 3009:
290
+ raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
291
+ if response_code == 3006:
292
+ raise RoborockInvalidUserAgreement(
293
+ "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
294
+ )
295
+ raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
296
+ user_data = login_response.get("data")
297
+ if not isinstance(user_data, dict):
298
+ raise RoborockException("Got unexpected data type for user_data")
299
+ return UserData.from_dict(user_data)
300
+
193
301
  async def pass_login(self, password: str) -> UserData:
194
302
  try:
195
303
  self._login_limiter.try_acquire("login")