python-roborock 2.9.0__py3-none-any.whl → 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-roborock
3
- Version: 2.9.0
3
+ Version: 2.9.1
4
4
  Summary: A package to control Roborock vacuums.
5
- Home-page: https://github.com/humbertogontijo/python-roborock
6
5
  License: GPL-3.0-only
7
6
  Keywords: roborock,vacuum,homeassistant
8
7
  Author: humbertogontijo
@@ -1,29 +1,29 @@
1
1
  roborock/__init__.py,sha256=AqfxNGj4wILqL7dooJyQQd6w7o2EW02fMAF51wT9VK8,165
2
- roborock/api.py,sha256=zNQNf_Zmx-rW-jIbqhZm4aYnASKFFztMCtAJBUOOVzc,4270
2
+ roborock/api.py,sha256=ahk1kUTnSefR7lPaHZJZHF-XfCFyjj6fRBVnQgruts4,4383
3
3
  roborock/cli.py,sha256=l4dOt9HZ4TZOZNp_btsNCZEDUihM94Dz173KPtUYdm4,6826
4
- roborock/cloud_api.py,sha256=8p_iRfXRstBW5KfdxFtya41VJjeRXbdwfpASXIqASqM,7144
4
+ roborock/cloud_api.py,sha256=rExyj_ZLatzHFmmKlwywmUX0CQxz-a_cSjSrTaQO-cY,7109
5
5
  roborock/code_mappings.py,sha256=PTNPwWsuVb0nOkg6GpDklGMYlIB6m9uJXcMdPKVqLDY,18432
6
6
  roborock/command_cache.py,sha256=E8e7EYzYFZEmw_cIfhPYZXNpBuSJbIAT9KXnE-e5NPg,7912
7
7
  roborock/const.py,sha256=7DtkKJJ2KscgV-BKIGHsMNV_fqfVupt02LQ66r7yOyQ,2632
8
8
  roborock/containers.py,sha256=hApLhRbZlp2lMCwlecMXC-vfLMHFq7l9kOVW6rVMC3U,32050
9
9
  roborock/exceptions.py,sha256=HAe8bI78n-8-aMD_-9ahTm43xh4lc8KX9qeG5GdqtQc,1962
10
- roborock/local_api.py,sha256=sbgoo1ucuAu2HgdtS1IVeXpsZrJ9pBS41Qk9tB4YEtk,4375
10
+ roborock/local_api.py,sha256=RcBp1I7sJdUfDddrd4XeJMB1OVX7L77GDeD-bPaSbIs,4652
11
11
  roborock/protocol.py,sha256=VaHCceR6zbe0_kQCnGqfkbWpqaKCq96IqAh5hgSI_Kk,12315
12
12
  roborock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- roborock/roborock_future.py,sha256=j72NFkP1s5TXOzWkkJNr5FAec_44lRMyeSoCe37BNeM,834
13
+ roborock/roborock_future.py,sha256=W8vm5IA1pzaKLsV2fIR9HyYFiANDLwrs8yE-OqR8ITE,1044
14
14
  roborock/roborock_message.py,sha256=5wFwaIu5fGZMG_g6PLEEjUu3Avc3Y_dEevuTeQEP9kE,5486
15
15
  roborock/roborock_typing.py,sha256=TTWU0wP-4OjdO2Qfp-uA3CySE8eupLbLJmlxTaSaHWg,23933
16
16
  roborock/util.py,sha256=CndrrzhWwMOCP1HRGsOMWtzt7EOkGS5Hr5llcAKMEUA,3946
17
17
  roborock/version_1_apis/__init__.py,sha256=BDAkBZPOtpyYUPUa6VaZd7Aaza1hbvM2fIzsjIGD9Vc,183
18
- roborock/version_1_apis/roborock_client_v1.py,sha256=aR9vDGRvrwwI9bw-Cr0l8AIoVypgWYN9SvNrZql1jjs,22345
19
- roborock/version_1_apis/roborock_local_client_v1.py,sha256=L1P8f1ldlHZtcvq6NWTcGf8fZt_DkDI4CYAY8pq8180,3564
20
- roborock/version_1_apis/roborock_mqtt_client_v1.py,sha256=GkQOcltXvGN-Wapsr1ZdZO3aQJHeAJ0gqDyabuSM5_Y,4270
18
+ roborock/version_1_apis/roborock_client_v1.py,sha256=ATKmedhvwj1fqqCDKfyEb_zSHb5Dr1tfhncWpFI1AYU,22305
19
+ roborock/version_1_apis/roborock_local_client_v1.py,sha256=SuH0yBCR4USeDg2UJdPnzqrQq8YmH39wc5ZL662IbJY,4022
20
+ roborock/version_1_apis/roborock_mqtt_client_v1.py,sha256=qrDNry25AXAk0ZfCbQJdF2UIa5mKy19dBrNfcbBa460,4373
21
21
  roborock/version_a01_apis/__init__.py,sha256=i_WTIX0LUCG93YX3RraNaQYNYBMY-xJI7ncWMdUFQVs,111
22
- roborock/version_a01_apis/roborock_client_a01.py,sha256=ISXMQ7pFoohoHG2mmukdZx6J7S2sgYvvInpJGqtKGL8,7424
23
- roborock/version_a01_apis/roborock_mqtt_client_a01.py,sha256=fiy2LdCVYwAXo70QN5vwOM8abEHTGACmaLzaEiB8rmk,3043
22
+ roborock/version_a01_apis/roborock_client_a01.py,sha256=84Z1uR5ZzfGOm-g4Ln4rquiL5SzNOLx0OVHUUzvjLIw,7576
23
+ roborock/version_a01_apis/roborock_mqtt_client_a01.py,sha256=5fpUWnnPTj4yvxUwkh7ANHEJHH6RTgN3jEbVXL06blk,3180
24
24
  roborock/web_api.py,sha256=Evpql45Kh40vncbW3khK_CrPwvY1wnL6GGeBAEmG4jM,14860
25
- python_roborock-2.9.0.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
26
- python_roborock-2.9.0.dist-info/METADATA,sha256=xnk2Jk_eFimdQ9b7YuvGH4ts4olvfns_rDIAD1Dyk3A,4338
27
- python_roborock-2.9.0.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
28
- python_roborock-2.9.0.dist-info/entry_points.txt,sha256=3V_nm8VDR5Wg8m9QlRy9R4olW2lC--6JrDjZJpQpDNI,46
29
- python_roborock-2.9.0.dist-info/RECORD,,
25
+ python_roborock-2.9.1.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
26
+ python_roborock-2.9.1.dist-info/METADATA,sha256=tuHdDtQgY5MzPxXmbAqR5zrz4Yep19wwuWVIhZ8vxiU,4276
27
+ python_roborock-2.9.1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
28
+ python_roborock-2.9.1.dist-info/entry_points.txt,sha256=3V_nm8VDR5Wg8m9QlRy9R4olW2lC--6JrDjZJpQpDNI,46
29
+ python_roborock-2.9.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.0.0
2
+ Generator: poetry-core 2.0.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
roborock/api.py CHANGED
@@ -7,7 +7,7 @@ import base64
7
7
  import logging
8
8
  import secrets
9
9
  import time
10
- from collections.abc import Coroutine
10
+ from abc import ABC, abstractmethod
11
11
  from typing import Any
12
12
 
13
13
  from .containers import (
@@ -16,24 +16,27 @@ from .containers import (
16
16
  from .exceptions import (
17
17
  RoborockTimeout,
18
18
  UnknownMethodError,
19
- VacuumError,
20
19
  )
21
20
  from .roborock_future import RoborockFuture
22
21
  from .roborock_message import (
23
22
  RoborockMessage,
24
23
  )
25
24
  from .roborock_typing import RoborockCommand
26
- from .util import RoborockLoggerAdapter, get_next_int, get_running_loop_or_create_one
25
+ from .util import get_next_int, get_running_loop_or_create_one
27
26
 
28
27
  _LOGGER = logging.getLogger(__name__)
29
28
  KEEPALIVE = 60
30
29
 
31
30
 
32
- class RoborockClient:
33
- def __init__(self, endpoint: str, device_info: DeviceData, queue_timeout: int = 4) -> None:
31
+ class RoborockClient(ABC):
32
+ """Roborock client base class."""
33
+
34
+ _logger: logging.LoggerAdapter
35
+
36
+ def __init__(self, device_info: DeviceData, queue_timeout: int = 4) -> None:
37
+ """Initialize RoborockClient."""
34
38
  self.event_loop = get_running_loop_or_create_one()
35
39
  self.device_info = device_info
36
- self._endpoint = endpoint
37
40
  self._nonce = secrets.token_bytes(16)
38
41
  self._waiting_queue: dict[int, RoborockFuture] = {}
39
42
  self._last_device_msg_in = time.monotonic()
@@ -42,7 +45,6 @@ class RoborockClient:
42
45
  self._diagnostic_data: dict[str, dict[str, Any]] = {
43
46
  "misc_info": {"Nonce": base64.b64encode(self._nonce).decode("utf-8")}
44
47
  }
45
- self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
46
48
  self.is_available: bool = True
47
49
  self.queue_timeout = queue_timeout
48
50
 
@@ -59,17 +61,21 @@ class RoborockClient:
59
61
  def diagnostic_data(self) -> dict:
60
62
  return self._diagnostic_data
61
63
 
64
+ @abstractmethod
62
65
  async def async_connect(self):
63
- raise NotImplementedError
66
+ """Connect to the Roborock device."""
64
67
 
68
+ @abstractmethod
65
69
  def sync_disconnect(self) -> Any:
66
- raise NotImplementedError
70
+ """Disconnect from the Roborock device."""
67
71
 
72
+ @abstractmethod
68
73
  async def async_disconnect(self) -> Any:
69
- raise NotImplementedError
74
+ """Disconnect from the Roborock device."""
70
75
 
76
+ @abstractmethod
71
77
  def on_message_received(self, messages: list[RoborockMessage]) -> None:
72
- raise NotImplementedError
78
+ """Handle received incoming messages from the device."""
73
79
 
74
80
  def on_connection_lost(self, exc: Exception | None) -> None:
75
81
  self._last_disconnection = time.monotonic()
@@ -89,24 +95,22 @@ class RoborockClient:
89
95
  await self.async_disconnect()
90
96
  await self.async_connect()
91
97
 
92
- async def _wait_response(self, request_id: int, queue: RoborockFuture) -> tuple[Any, VacuumError | None]:
98
+ async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any:
93
99
  try:
94
- (response, err) = await queue.async_get(self.queue_timeout)
100
+ response = await queue.async_get(self.queue_timeout)
95
101
  if response == "unknown_method":
96
102
  raise UnknownMethodError("Unknown method")
97
- return response, err
103
+ return response
98
104
  except (asyncio.TimeoutError, asyncio.CancelledError):
99
105
  raise RoborockTimeout(f"id={request_id} Timeout after {self.queue_timeout} seconds") from None
100
106
  finally:
101
107
  self._waiting_queue.pop(request_id, None)
102
108
 
103
- def _async_response(
104
- self, request_id: int, protocol_id: int = 0
105
- ) -> Coroutine[Any, Any, tuple[Any, VacuumError | None]]:
109
+ def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
106
110
  queue = RoborockFuture(protocol_id)
107
111
  if request_id in self._waiting_queue:
108
112
  new_id = get_next_int(10000, 32767)
109
- _LOGGER.warning(
113
+ self._logger.warning(
110
114
  "Attempting to create a future with an existing id %s (%s)... New id is %s. "
111
115
  "Code may not function properly.",
112
116
  request_id,
@@ -115,14 +119,16 @@ class RoborockClient:
115
119
  )
116
120
  request_id = new_id
117
121
  self._waiting_queue[request_id] = queue
118
- return self._wait_response(request_id, queue)
122
+ return asyncio.ensure_future(self._wait_response(request_id, queue))
119
123
 
124
+ @abstractmethod
120
125
  async def send_message(self, roborock_message: RoborockMessage):
121
- raise NotImplementedError
126
+ """Send a message to the Roborock device."""
122
127
 
128
+ @abstractmethod
123
129
  async def _send_command(
124
130
  self,
125
131
  method: RoborockCommand | str,
126
132
  params: list | dict | int | None = None,
127
133
  ):
128
- raise NotImplementedError
134
+ """Send a command to the Roborock device."""
roborock/cloud_api.py CHANGED
@@ -1,12 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
- import base64
5
3
  import logging
6
4
  import threading
7
- import typing
8
5
  import uuid
9
- from asyncio import Lock, Task
6
+ from abc import ABC
7
+ from asyncio import Lock
10
8
  from typing import Any
11
9
  from urllib.parse import urlparse
12
10
 
@@ -15,31 +13,52 @@ import paho.mqtt.client as mqtt
15
13
  from .api import KEEPALIVE, RoborockClient
16
14
  from .containers import DeviceData, UserData
17
15
  from .exceptions import RoborockException, VacuumError
18
- from .protocol import MessageParser, Utils, md5hex
16
+ from .protocol import MessageParser, md5hex
19
17
  from .roborock_future import RoborockFuture
20
- from .roborock_message import RoborockMessage
21
- from .roborock_typing import RoborockCommand
22
- from .util import RoborockLoggerAdapter
23
18
 
24
- if typing.TYPE_CHECKING:
25
- pass
26
19
  _LOGGER = logging.getLogger(__name__)
27
20
  CONNECT_REQUEST_ID = 0
28
21
  DISCONNECT_REQUEST_ID = 1
29
22
 
30
23
 
31
- class RoborockMqttClient(RoborockClient, mqtt.Client):
24
+ class _Mqtt(mqtt.Client):
25
+ """Internal MQTT client.
26
+
27
+ This is a subclass of the Paho MQTT client that adds some additional functionality
28
+ for error cases where things get stuck.
29
+ """
30
+
32
31
  _thread: threading.Thread
33
32
  _client_id: str
34
33
 
34
+ def __init__(self) -> None:
35
+ """Initialize the MQTT client."""
36
+ super().__init__(protocol=mqtt.MQTTv5)
37
+ self.reset_client_id()
38
+
39
+ def reset_client_id(self):
40
+ """Generate a new client id to make a new session when reconnecting."""
41
+ self._client_id = mqtt.base62(uuid.uuid4().int, padding=22)
42
+
43
+ def maybe_restart_loop(self) -> None:
44
+ """Ensure that the MQTT loop is running in case it previously exited."""
45
+ if not self._thread or not self._thread.is_alive():
46
+ if self._thread:
47
+ _LOGGER.info("Stopping mqtt loop")
48
+ super().loop_stop()
49
+ _LOGGER.info("Starting mqtt loop")
50
+ super().loop_start()
51
+
52
+
53
+ class RoborockMqttClient(RoborockClient, ABC):
54
+ """Roborock MQTT client base class."""
55
+
35
56
  def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None:
57
+ """Initialize the Roborock MQTT client."""
36
58
  rriot = user_data.rriot
37
59
  if rriot is None:
38
60
  raise RoborockException("Got no rriot data from user_data")
39
- endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
40
- RoborockClient.__init__(self, endpoint, device_info, queue_timeout)
41
- mqtt.Client.__init__(self, protocol=mqtt.MQTTv5)
42
- self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
61
+ RoborockClient.__init__(self, device_info, queue_timeout)
43
62
  self._mqtt_user = rriot.u
44
63
  self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10]
45
64
  url = urlparse(rriot.r.m)
@@ -48,39 +67,43 @@ class RoborockMqttClient(RoborockClient, mqtt.Client):
48
67
  self._mqtt_host = str(url.hostname)
49
68
  self._mqtt_port = url.port
50
69
  self._mqtt_ssl = url.scheme == "ssl"
70
+
71
+ self._mqtt_client = _Mqtt()
72
+ self._mqtt_client.on_connect = self._mqtt_on_connect
73
+ self._mqtt_client.on_message = self._mqtt_on_message
74
+ self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
51
75
  if self._mqtt_ssl:
52
- super().tls_set()
76
+ self._mqtt_client.tls_set()
77
+
53
78
  self._mqtt_password = rriot.s
54
79
  self._hashed_password = md5hex(self._mqtt_password + ":" + rriot.k)[16:]
55
- super().username_pw_set(self._hashed_user, self._hashed_password)
56
- self._endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
80
+ self._mqtt_client.username_pw_set(self._hashed_user, self._hashed_password)
57
81
  self._waiting_queue: dict[int, RoborockFuture] = {}
58
82
  self._mutex = Lock()
59
- self.update_client_id()
60
83
 
61
- def on_connect(self, *args, **kwargs):
84
+ def _mqtt_on_connect(self, *args, **kwargs):
62
85
  _, __, ___, rc, ____ = args
63
86
  connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID)
64
87
  if rc != mqtt.MQTT_ERR_SUCCESS:
65
88
  message = f"Failed to connect ({mqtt.error_string(rc)})"
66
89
  self._logger.error(message)
67
90
  if connection_queue:
68
- connection_queue.resolve((None, VacuumError(message)))
91
+ connection_queue.set_exception(VacuumError(message))
69
92
  return
70
93
  self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}")
71
94
  topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
72
- (result, mid) = self.subscribe(topic)
95
+ (result, mid) = self._mqtt_client.subscribe(topic)
73
96
  if result != 0:
74
97
  message = f"Failed to subscribe ({mqtt.error_string(rc)})"
75
98
  self._logger.error(message)
76
99
  if connection_queue:
77
- connection_queue.resolve((None, VacuumError(message)))
100
+ connection_queue.set_exception(VacuumError(message))
78
101
  return
79
102
  self._logger.info(f"Subscribed to topic {topic}")
80
103
  if connection_queue:
81
- connection_queue.resolve((True, None))
104
+ connection_queue.set_result(True)
82
105
 
83
- def on_message(self, *args, **kwargs):
106
+ def _mqtt_on_message(self, *args, **kwargs):
84
107
  client, __, msg = args
85
108
  try:
86
109
  messages, _ = MessageParser.parse(msg.payload, local_key=self.device_info.device.local_key)
@@ -88,93 +111,74 @@ class RoborockMqttClient(RoborockClient, mqtt.Client):
88
111
  except Exception as ex:
89
112
  self._logger.exception(ex)
90
113
 
91
- def on_disconnect(self, *args, **kwargs):
114
+ def _mqtt_on_disconnect(self, *args, **kwargs):
92
115
  _, __, rc, ___ = args
93
116
  try:
94
117
  exc = RoborockException(mqtt.error_string(rc)) if rc != mqtt.MQTT_ERR_SUCCESS else None
95
118
  super().on_connection_lost(exc)
96
119
  if rc == mqtt.MQTT_ERR_PROTOCOL:
97
- self.update_client_id()
120
+ self._mqtt_client.reset_client_id()
98
121
  connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID)
99
122
  if connection_queue:
100
- connection_queue.resolve((True, None))
123
+ connection_queue.set_result(True)
101
124
  except Exception as ex:
102
125
  self._logger.exception(ex)
103
126
 
104
- def update_client_id(self):
105
- self._client_id = mqtt.base62(uuid.uuid4().int, padding=22)
106
-
107
- def sync_stop_loop(self) -> None:
108
- if self._thread:
109
- self._logger.info("Stopping mqtt loop")
110
- super().loop_stop()
111
-
112
- def sync_start_loop(self) -> None:
113
- if not self._thread or not self._thread.is_alive():
114
- self.sync_stop_loop()
115
- self._logger.info("Starting mqtt loop")
116
- super().loop_start()
127
+ def is_connected(self) -> bool:
128
+ """Check if the mqtt client is connected."""
129
+ return self._mqtt_client.is_connected()
117
130
 
118
- def sync_disconnect(self) -> tuple[bool, Task[tuple[Any, VacuumError | None]] | None]:
131
+ def sync_disconnect(self) -> Any:
119
132
  if not self.is_connected():
120
- return False, None
133
+ return None
121
134
 
122
135
  self._logger.info("Disconnecting from mqtt")
123
- disconnected_future = asyncio.ensure_future(self._async_response(DISCONNECT_REQUEST_ID))
124
- rc = super().disconnect()
136
+ disconnected_future = self._async_response(DISCONNECT_REQUEST_ID)
137
+ rc = self._mqtt_client.disconnect()
125
138
 
126
139
  if rc == mqtt.MQTT_ERR_NO_CONN:
127
140
  disconnected_future.cancel()
128
- return False, None
141
+ return None
129
142
 
130
143
  if rc != mqtt.MQTT_ERR_SUCCESS:
131
144
  disconnected_future.cancel()
132
145
  raise RoborockException(f"Failed to disconnect ({mqtt.error_string(rc)})")
133
146
 
134
- return True, disconnected_future
147
+ return disconnected_future
135
148
 
136
- def sync_connect(self) -> tuple[bool, Task[tuple[Any, VacuumError | None]] | None]:
149
+ def sync_connect(self) -> Any:
137
150
  if self.is_connected():
138
- self.sync_start_loop()
139
- return False, None
151
+ self._mqtt_client.maybe_restart_loop()
152
+ return None
140
153
 
141
154
  if self._mqtt_port is None or self._mqtt_host is None:
142
155
  raise RoborockException("Mqtt information was not entered. Cannot connect.")
143
156
 
144
157
  self._logger.debug("Connecting to mqtt")
145
- connected_future = asyncio.ensure_future(self._async_response(CONNECT_REQUEST_ID))
146
- super().connect(host=self._mqtt_host, port=self._mqtt_port, keepalive=KEEPALIVE)
147
-
148
- self.sync_start_loop()
149
- return True, connected_future
158
+ connected_future = self._async_response(CONNECT_REQUEST_ID)
159
+ self._mqtt_client.connect(host=self._mqtt_host, port=self._mqtt_port, keepalive=KEEPALIVE)
160
+ self._mqtt_client.maybe_restart_loop()
161
+ return connected_future
150
162
 
151
163
  async def async_disconnect(self) -> None:
152
164
  async with self._mutex:
153
- (disconnecting, disconnected_future) = self.sync_disconnect()
154
- if disconnecting and disconnected_future:
155
- (_, err) = await disconnected_future
156
- if err:
165
+ if disconnected_future := self.sync_disconnect():
166
+ try:
167
+ await disconnected_future
168
+ except VacuumError as err:
157
169
  raise RoborockException(err) from err
158
170
 
159
171
  async def async_connect(self) -> None:
160
172
  async with self._mutex:
161
- (connecting, connected_future) = self.sync_connect()
162
- if connecting and connected_future:
163
- (_, err) = await connected_future
164
- if err:
173
+ if connected_future := self.sync_connect():
174
+ try:
175
+ await connected_future
176
+ except VacuumError as err:
165
177
  raise RoborockException(err) from err
166
178
 
167
179
  def _send_msg_raw(self, msg: bytes) -> None:
168
- info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg)
180
+ info = self._mqtt_client.publish(
181
+ f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg
182
+ )
169
183
  if info.rc != mqtt.MQTT_ERR_SUCCESS:
170
184
  raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
171
-
172
- async def send_message(self, roborock_message: RoborockMessage):
173
- raise NotImplementedError
174
-
175
- async def _send_command(
176
- self,
177
- method: RoborockCommand | str,
178
- params: list | dict | int | None = None,
179
- ):
180
- raise NotImplementedError
roborock/local_api.py CHANGED
@@ -2,7 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from abc import ABC
5
6
  from asyncio import Lock, TimerHandle, Transport
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
6
9
 
7
10
  import async_timeout
8
11
 
@@ -11,14 +14,23 @@ from .api import RoborockClient
11
14
  from .exceptions import RoborockConnectionException, RoborockException
12
15
  from .protocol import MessageParser
13
16
  from .roborock_message import RoborockMessage, RoborockMessageProtocol
14
- from .roborock_typing import RoborockCommand
15
- from .util import RoborockLoggerAdapter
16
17
 
17
18
  _LOGGER = logging.getLogger(__name__)
18
19
 
19
20
 
20
- class RoborockLocalClient(RoborockClient, asyncio.Protocol):
21
+ @dataclass
22
+ class _LocalProtocol(asyncio.Protocol):
23
+ """Callbacks for the Roborock local client transport."""
24
+
25
+ messages_cb: Callable[[bytes], None]
26
+ connection_lost_cb: Callable[[Exception | None], None]
27
+
28
+
29
+ class RoborockLocalClient(RoborockClient, ABC):
30
+ """Roborock local client base class."""
31
+
21
32
  def __init__(self, device_data: DeviceData, queue_timeout: int = 4):
33
+ """Initialize the Roborock local client."""
22
34
  if device_data.host is None:
23
35
  raise RoborockException("Host is required")
24
36
  self.host = device_data.host
@@ -28,17 +40,19 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
28
40
  self.transport: Transport | None = None
29
41
  self._mutex = Lock()
30
42
  self.keep_alive_task: TimerHandle | None = None
31
- self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER)
32
- RoborockClient.__init__(self, "abc", device_data, queue_timeout)
43
+ RoborockClient.__init__(self, device_data, queue_timeout)
44
+ self._local_protocol = _LocalProtocol(self._data_received, self._connection_lost)
33
45
 
34
- def data_received(self, message):
46
+ def _data_received(self, message):
47
+ """Called when data is received from the transport."""
35
48
  if self.remaining:
36
49
  message = self.remaining + message
37
50
  self.remaining = b""
38
51
  parser_msg, self.remaining = MessageParser.parse(message, local_key=self.device_info.device.local_key)
39
52
  self.on_message_received(parser_msg)
40
53
 
41
- def connection_lost(self, exc: Exception | None):
54
+ def _connection_lost(self, exc: Exception | None):
55
+ """Called when the transport connection is lost."""
42
56
  self.sync_disconnect()
43
57
  self.on_connection_lost(exc)
44
58
 
@@ -61,7 +75,7 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
61
75
  async with async_timeout.timeout(self.queue_timeout):
62
76
  self._logger.debug(f"Connecting to {self.host}")
63
77
  self.transport, _ = await self.event_loop.create_connection( # type: ignore
64
- lambda: self, self.host, 58867
78
+ lambda: self._local_protocol, self.host, 58867
65
79
  )
66
80
  self._logger.info(f"Connected to {self.host}")
67
81
  should_ping = True
@@ -107,13 +121,6 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
107
121
  )
108
122
  )
109
123
 
110
- async def _send_command(
111
- self,
112
- method: RoborockCommand | str,
113
- params: list | dict | int | None = None,
114
- ):
115
- raise NotImplementedError
116
-
117
124
  def _send_msg_raw(self, data: bytes):
118
125
  try:
119
126
  if not self.transport:
@@ -14,12 +14,19 @@ class RoborockFuture:
14
14
  self.fut: Future = Future()
15
15
  self.loop = self.fut.get_loop()
16
16
 
17
- def _resolve(self, item: tuple[Any, VacuumError | None]) -> None:
17
+ def _set_result(self, item: Any) -> None:
18
18
  if not self.fut.cancelled():
19
19
  self.fut.set_result(item)
20
20
 
21
- def resolve(self, item: tuple[Any, VacuumError | None]) -> None:
22
- self.loop.call_soon_threadsafe(self._resolve, item)
21
+ def set_result(self, item: Any) -> None:
22
+ self.loop.call_soon_threadsafe(self._set_result, item)
23
+
24
+ def _set_exception(self, exc: VacuumError) -> None:
25
+ if not self.fut.cancelled():
26
+ self.fut.set_exception(exc)
27
+
28
+ def set_exception(self, exc: VacuumError) -> None:
29
+ self.loop.call_soon_threadsafe(self._set_exception, exc)
23
30
 
24
31
  async def async_get(self, timeout: float | int) -> tuple[Any, VacuumError | None]:
25
32
  try:
@@ -4,6 +4,7 @@ import json
4
4
  import math
5
5
  import struct
6
6
  import time
7
+ from abc import ABC
7
8
  from collections.abc import Callable, Coroutine
8
9
  from typing import Any, TypeVar, final
9
10
 
@@ -142,19 +143,22 @@ class ListenerModel:
142
143
  cache: dict[CacheableAttribute, AttributeCache]
143
144
 
144
145
 
145
- class RoborockClientV1(RoborockClient):
146
+ class RoborockClientV1(RoborockClient, ABC):
147
+ """Roborock client base class for version 1 devices."""
148
+
146
149
  _listeners: dict[str, ListenerModel] = {}
147
150
 
148
- def __init__(self, device_info: DeviceData, logger, endpoint: str):
149
- super().__init__(endpoint, device_info)
151
+ def __init__(self, device_info: DeviceData, endpoint: str):
152
+ """Initializes the Roborock client."""
153
+ super().__init__(device_info)
150
154
  self._status_type: type[Status] = ModelStatus.get(device_info.model, S7MaxVStatus)
151
- self._logger = logger
152
155
  self.cache: dict[CacheableAttribute, AttributeCache] = {
153
156
  cacheable_attribute: AttributeCache(attr, self) for cacheable_attribute, attr in get_cache_map().items()
154
157
  }
155
158
  if device_info.device.duid not in self._listeners:
156
159
  self._listeners[device_info.device.duid] = ListenerModel({}, self.cache)
157
160
  self.listener_model = self._listeners[device_info.device.duid]
161
+ self._endpoint = endpoint
158
162
 
159
163
  def release(self):
160
164
  super().release()
@@ -378,20 +382,17 @@ class RoborockClientV1(RoborockClient):
378
382
  if queue and queue.protocol == protocol:
379
383
  error = data_point_response.get("error")
380
384
  if error:
381
- queue.resolve(
382
- (
383
- None,
384
- VacuumError(
385
- error.get("code"),
386
- error.get("message"),
387
- ),
388
- )
385
+ queue.set_exception(
386
+ VacuumError(
387
+ error.get("code"),
388
+ error.get("message"),
389
+ ),
389
390
  )
390
391
  else:
391
392
  result = data_point_response.get("result")
392
393
  if isinstance(result, list) and len(result) == 1:
393
394
  result = result[0]
394
- queue.resolve((result, None))
395
+ queue.set_result(result)
395
396
  else:
396
397
  self._logger.debug("Received response for unknown request id %s", request_id)
397
398
  else:
@@ -451,13 +452,13 @@ class RoborockClientV1(RoborockClient):
451
452
  if queue:
452
453
  if isinstance(decompressed, list):
453
454
  decompressed = decompressed[0]
454
- queue.resolve((decompressed, None))
455
+ queue.set_result(decompressed)
455
456
  else:
456
457
  self._logger.debug("Received response for unknown request id %s", request_id)
457
458
  else:
458
459
  queue = self._waiting_queue.get(data.seq)
459
460
  if queue:
460
- queue.resolve((data.payload, None))
461
+ queue.set_result(data.payload)
461
462
  else:
462
463
  self._logger.debug("Received response for unknown request id %s", data.seq)
463
464
  except Exception as ex:
@@ -1,17 +1,25 @@
1
- import asyncio
1
+ import logging
2
2
 
3
3
  from roborock.local_api import RoborockLocalClient
4
4
 
5
5
  from .. import CommandVacuumError, DeviceData, RoborockCommand, RoborockException
6
+ from ..exceptions import VacuumError
6
7
  from ..protocol import MessageParser
7
8
  from ..roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol
9
+ from ..util import RoborockLoggerAdapter
8
10
  from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1
9
11
 
12
+ _LOGGER = logging.getLogger(__name__)
13
+
10
14
 
11
15
  class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1):
16
+ """Roborock local client for v1 devices."""
17
+
12
18
  def __init__(self, device_data: DeviceData, queue_timeout: int = 4):
19
+ """Initialize the Roborock local client."""
13
20
  RoborockLocalClient.__init__(self, device_data, queue_timeout)
14
- RoborockClientV1.__init__(self, device_data, self._logger, "abc")
21
+ RoborockClientV1.__init__(self, device_data, "abc")
22
+ self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER)
15
23
 
16
24
  def build_roborock_message(
17
25
  self, method: RoborockCommand | str, params: list | dict | int | None = None
@@ -53,16 +61,21 @@ class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1):
53
61
  if method:
54
62
  self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
55
63
  # Send the command to the Roborock device
56
- async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol))
64
+ async_response = self._async_response(request_id, response_protocol)
57
65
  self._send_msg_raw(msg)
58
- (response, err) = await async_response
59
- self._diagnostic_data[method if method is not None else "unknown"] = {
66
+ diagnostic_key = method if method is not None else "unknown"
67
+ try:
68
+ response = await async_response
69
+ except VacuumError as err:
70
+ self._diagnostic_data[diagnostic_key] = {
71
+ "params": roborock_message.get_params(),
72
+ "error": err,
73
+ }
74
+ raise CommandVacuumError(method, err) from err
75
+ self._diagnostic_data[diagnostic_key] = {
60
76
  "params": roborock_message.get_params(),
61
77
  "response": response,
62
- "error": err,
63
78
  }
64
- if err:
65
- raise CommandVacuumError(method, err) from err
66
79
  if roborock_message.protocol == RoborockMessageProtocol.GENERAL_REQUEST:
67
80
  self._logger.debug(f"id={request_id} Response from method {roborock_message.get_method()}: {response}")
68
81
  if response == "retry":
@@ -1,7 +1,6 @@
1
- import asyncio
2
1
  import base64
2
+ import logging
3
3
 
4
- import paho.mqtt.client as mqtt
5
4
  from vacuum_map_parser_base.config.color import ColorsPalette
6
5
  from vacuum_map_parser_base.config.image_config import ImageConfig
7
6
  from vacuum_map_parser_base.config.size import Sizes
@@ -10,30 +9,32 @@ from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
10
9
  from roborock.cloud_api import RoborockMqttClient
11
10
 
12
11
  from ..containers import DeviceData, UserData
13
- from ..exceptions import CommandVacuumError, RoborockException
12
+ from ..exceptions import CommandVacuumError, RoborockException, VacuumError
14
13
  from ..protocol import MessageParser, Utils
15
14
  from ..roborock_message import (
16
15
  RoborockMessage,
17
16
  RoborockMessageProtocol,
18
17
  )
19
18
  from ..roborock_typing import RoborockCommand
19
+ from ..util import RoborockLoggerAdapter
20
20
  from .roborock_client_v1 import COMMANDS_SECURED, CUSTOM_COMMANDS, RoborockClientV1
21
21
 
22
+ _LOGGER = logging.getLogger(__name__)
23
+
22
24
 
23
25
  class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
26
+ """Roborock mqtt client for v1 devices."""
27
+
24
28
  def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None:
29
+ """Initialize the Roborock mqtt client."""
25
30
  rriot = user_data.rriot
26
31
  if rriot is None:
27
32
  raise RoborockException("Got no rriot data from user_data")
28
33
  endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
29
34
 
30
35
  RoborockMqttClient.__init__(self, user_data, device_info, queue_timeout)
31
- RoborockClientV1.__init__(self, device_info, self._logger, endpoint)
32
-
33
- def _send_msg_raw(self, msg: bytes) -> None:
34
- info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg)
35
- if info.rc != mqtt.MQTT_ERR_SUCCESS:
36
- raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
36
+ RoborockClientV1.__init__(self, device_info, endpoint)
37
+ self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
37
38
 
38
39
  async def send_message(self, roborock_message: RoborockMessage):
39
40
  await self.validate_connection()
@@ -49,16 +50,21 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
49
50
  local_key = self.device_info.device.local_key
50
51
  msg = MessageParser.build(roborock_message, local_key, False)
51
52
  self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
52
- async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol))
53
+ async_response = self._async_response(request_id, response_protocol)
53
54
  self._send_msg_raw(msg)
54
- (response, err) = await async_response
55
- self._diagnostic_data[method if method is not None else "unknown"] = {
55
+ diagnostic_key = method if method is not None else "unknown"
56
+ try:
57
+ response = await async_response
58
+ except VacuumError as err:
59
+ self._diagnostic_data[diagnostic_key] = {
60
+ "params": roborock_message.get_params(),
61
+ "error": err,
62
+ }
63
+ raise CommandVacuumError(method, err) from err
64
+ self._diagnostic_data[diagnostic_key] = {
56
65
  "params": roborock_message.get_params(),
57
66
  "response": response,
58
- "error": err,
59
67
  }
60
- if err:
61
- raise CommandVacuumError(method, err) from err
62
68
  if response_protocol == RoborockMessageProtocol.MAP_RESPONSE:
63
69
  self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes")
64
70
  else:
@@ -1,6 +1,8 @@
1
1
  import dataclasses
2
2
  import json
3
+ import logging
3
4
  import typing
5
+ from abc import ABC, abstractmethod
4
6
  from collections.abc import Callable
5
7
  from datetime import time
6
8
 
@@ -38,6 +40,8 @@ from roborock.roborock_message import (
38
40
  RoborockZeoProtocol,
39
41
  )
40
42
 
43
+ _LOGGER = logging.getLogger(__name__)
44
+
41
45
 
42
46
  @dataclasses.dataclass
43
47
  class A01ProtocolCacheEntry:
@@ -101,9 +105,12 @@ zeo_data_protocol_entries = {
101
105
  }
102
106
 
103
107
 
104
- class RoborockClientA01(RoborockClient):
105
- def __init__(self, endpoint: str, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 4):
106
- super().__init__(endpoint, device_info, queue_timeout)
108
+ class RoborockClientA01(RoborockClient, ABC):
109
+ """Roborock client base class for A01 devices."""
110
+
111
+ def __init__(self, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 4):
112
+ """Initialize the Roborock client."""
113
+ super().__init__(device_info, queue_timeout)
107
114
  self.category = category
108
115
 
109
116
  def on_message_received(self, messages: list[RoborockMessage]) -> None:
@@ -135,10 +142,10 @@ class RoborockClientA01(RoborockClient):
135
142
  converted_response = entries[data_point_protocol].post_process_fn(data_point)
136
143
  queue = self._waiting_queue.get(int(data_point_number))
137
144
  if queue and queue.protocol == protocol:
138
- queue.resolve((converted_response, None))
145
+ queue.set_result(converted_response)
139
146
 
147
+ @abstractmethod
140
148
  async def update_values(
141
149
  self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]
142
150
  ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, typing.Any]:
143
151
  """This should handle updating for each given protocol."""
144
- raise NotImplementedError
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
- import base64
3
2
  import json
3
+ import logging
4
4
  import typing
5
5
 
6
6
  from Crypto.Cipher import AES
@@ -9,7 +9,7 @@ from Crypto.Util.Padding import pad, unpad
9
9
  from roborock.cloud_api import RoborockMqttClient
10
10
  from roborock.containers import DeviceData, RoborockCategory, UserData
11
11
  from roborock.exceptions import RoborockException
12
- from roborock.protocol import MessageParser, Utils
12
+ from roborock.protocol import MessageParser
13
13
  from roborock.roborock_message import (
14
14
  RoborockDyadDataProtocol,
15
15
  RoborockMessage,
@@ -17,20 +17,26 @@ from roborock.roborock_message import (
17
17
  RoborockZeoProtocol,
18
18
  )
19
19
 
20
+ from ..util import RoborockLoggerAdapter
20
21
  from .roborock_client_a01 import RoborockClientA01
21
22
 
23
+ _LOGGER = logging.getLogger(__name__)
24
+
22
25
 
23
26
  class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
27
+ """Roborock mqtt client for A01 devices."""
28
+
24
29
  def __init__(
25
30
  self, user_data: UserData, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 10
26
31
  ) -> None:
32
+ """Initialize the Roborock mqtt client."""
27
33
  rriot = user_data.rriot
28
34
  if rriot is None:
29
35
  raise RoborockException("Got no rriot data from user_data")
30
- endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
31
36
 
32
37
  RoborockMqttClient.__init__(self, user_data, device_info, queue_timeout)
33
- RoborockClientA01.__init__(self, endpoint, device_info, category, queue_timeout)
38
+ RoborockClientA01.__init__(self, device_info, category, queue_timeout)
39
+ self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
34
40
 
35
41
  async def send_message(self, roborock_message: RoborockMessage):
36
42
  await self.validate_connection()
@@ -43,7 +49,7 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
43
49
  futures = []
44
50
  if "10000" in payload["dps"]:
45
51
  for dps in json.loads(payload["dps"]["10000"]):
46
- futures.append(asyncio.ensure_future(self._async_response(dps, response_protocol)))
52
+ futures.append(self._async_response(dps, response_protocol))
47
53
  self._send_msg_raw(m)
48
54
  responses = await asyncio.gather(*futures, return_exceptions=True)
49
55
  dps_responses: dict[int, typing.Any] = {}
@@ -54,7 +60,7 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
54
60
  self._logger.warning("Timed out get req for %s after %s s", dps, self.queue_timeout)
55
61
  dps_responses[dps] = None
56
62
  else:
57
- dps_responses[dps] = response[0]
63
+ dps_responses[dps] = response
58
64
  return dps_responses
59
65
 
60
66
  async def update_values(