python-roborock 2.47.0__tar.gz → 2.48.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 (61) hide show
  1. {python_roborock-2.47.0 → python_roborock-2.48.0}/PKG-INFO +1 -1
  2. {python_roborock-2.47.0 → python_roborock-2.48.0}/pyproject.toml +2 -1
  3. python_roborock-2.48.0/roborock/__init__.py +39 -0
  4. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/device_manager.py +2 -2
  5. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/v1_channel.py +109 -24
  6. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/mqtt/roborock_session.py +60 -0
  7. python_roborock-2.47.0/roborock/__init__.py +0 -7
  8. {python_roborock-2.47.0 → python_roborock-2.48.0}/LICENSE +0 -0
  9. {python_roborock-2.47.0 → python_roborock-2.48.0}/README.md +0 -0
  10. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/api.py +0 -0
  11. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/b01_containers.py +0 -0
  12. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/broadcast_protocol.py +0 -0
  13. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/callbacks.py +0 -0
  14. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/clean_modes.py +0 -0
  15. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/cli.py +0 -0
  16. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/cloud_api.py +0 -0
  17. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/code_mappings.py +0 -0
  18. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/command_cache.py +0 -0
  19. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/const.py +0 -0
  20. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/containers.py +0 -0
  21. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/device_features.py +0 -0
  22. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/README.md +0 -0
  23. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/__init__.py +0 -0
  24. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/a01_channel.py +0 -0
  25. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/b01_channel.py +0 -0
  26. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/cache.py +0 -0
  27. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/channel.py +0 -0
  28. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/device.py +0 -0
  29. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/local_channel.py +0 -0
  30. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/mqtt_channel.py +0 -0
  31. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/__init__.py +0 -0
  32. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/a01/__init__.py +0 -0
  33. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/b01/__init__.py +0 -0
  34. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/traits_mixin.py +0 -0
  35. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/__init__.py +0 -0
  36. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  37. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/common.py +0 -0
  38. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  39. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/status.py +0 -0
  40. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/traits/v1/volume.py +0 -0
  41. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/devices/v1_rpc_channel.py +0 -0
  42. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/exceptions.py +0 -0
  43. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/mqtt/__init__.py +0 -0
  44. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/mqtt/session.py +0 -0
  45. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/protocol.py +0 -0
  46. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/protocols/a01_protocol.py +0 -0
  47. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/protocols/b01_protocol.py +0 -0
  48. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/protocols/v1_protocol.py +0 -0
  49. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/py.typed +0 -0
  50. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/roborock_future.py +0 -0
  51. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/roborock_message.py +0 -0
  52. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/roborock_typing.py +0 -0
  53. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/util.py +0 -0
  54. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_1_apis/__init__.py +0 -0
  55. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  56. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  57. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  58. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_a01_apis/__init__.py +0 -0
  59. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  60. {python_roborock-2.47.0 → python_roborock-2.48.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  61. {python_roborock-2.47.0 → python_roborock-2.48.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.47.0
3
+ Version: 2.48.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.47.0"
3
+ version = "2.48.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"
@@ -51,6 +51,7 @@ aioresponses = "^0.7.7"
51
51
  freezegun = "^1.5.1"
52
52
  pytest-timeout = "^2.3.1"
53
53
  syrupy = "^4.9.1"
54
+ pdoc = "^15.0.4"
54
55
 
55
56
  [tool.semantic_release]
56
57
  branch = "main"
@@ -0,0 +1,39 @@
1
+ """Roborock API.
2
+
3
+ .. include:: ../README.md
4
+ """
5
+
6
+ from roborock.b01_containers import *
7
+ from roborock.code_mappings import *
8
+ from roborock.containers import *
9
+ from roborock.exceptions import *
10
+ from roborock.roborock_typing import *
11
+
12
+ from . import (
13
+ b01_containers,
14
+ clean_modes,
15
+ cloud_api,
16
+ code_mappings,
17
+ const,
18
+ containers,
19
+ exceptions,
20
+ roborock_typing,
21
+ version_1_apis,
22
+ version_a01_apis,
23
+ web_api,
24
+ )
25
+
26
+ __all__ = [
27
+ "web_api",
28
+ "version_1_apis",
29
+ "version_a01_apis",
30
+ "containers",
31
+ "b01_containers",
32
+ "const",
33
+ "cloud_api",
34
+ "clean_modes",
35
+ "code_mappings",
36
+ "roborock_typing",
37
+ "exceptions",
38
+ # Add new APIs here in the future when they are public e.g. devices/
39
+ ]
@@ -14,7 +14,7 @@ from roborock.containers import (
14
14
  UserData,
15
15
  )
16
16
  from roborock.devices.device import RoborockDevice
17
- from roborock.mqtt.roborock_session import create_mqtt_session
17
+ from roborock.mqtt.roborock_session import create_lazy_mqtt_session
18
18
  from roborock.mqtt.session import MqttSession
19
19
  from roborock.protocol import create_mqtt_params
20
20
  from roborock.web_api import RoborockApiClient
@@ -141,7 +141,7 @@ async def create_device_manager(
141
141
  cache = NoCache()
142
142
 
143
143
  mqtt_params = create_mqtt_params(user_data.rriot)
144
- mqtt_session = await create_mqtt_session(mqtt_params)
144
+ mqtt_session = await create_lazy_mqtt_session(mqtt_params)
145
145
 
146
146
  def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
147
147
  channel: Channel
@@ -3,7 +3,8 @@
3
3
  This module provides a unified channel interface for V1 protocol devices,
4
4
  handling both MQTT and local connections with automatic fallback.
5
5
  """
6
-
6
+ import asyncio
7
+ import datetime
7
8
  import logging
8
9
  from collections.abc import Callable
9
10
  from typing import TypeVar
@@ -22,7 +23,12 @@ from .cache import Cache
22
23
  from .channel import Channel
23
24
  from .local_channel import LocalChannel, LocalSession, create_local_session
24
25
  from .mqtt_channel import MqttChannel
25
- from .v1_rpc_channel import PickFirstAvailable, V1RpcChannel, create_local_rpc_channel, create_mqtt_rpc_channel
26
+ from .v1_rpc_channel import (
27
+ PickFirstAvailable,
28
+ V1RpcChannel,
29
+ create_local_rpc_channel,
30
+ create_mqtt_rpc_channel,
31
+ )
26
32
 
27
33
  _LOGGER = logging.getLogger(__name__)
28
34
 
@@ -32,6 +38,15 @@ __all__ = [
32
38
 
33
39
  _T = TypeVar("_T", bound=RoborockBase)
34
40
 
41
+ # Exponential backoff parameters for reconnecting to local
42
+ MIN_RECONNECT_INTERVAL = datetime.timedelta(minutes=1)
43
+ MAX_RECONNECT_INTERVAL = datetime.timedelta(minutes=10)
44
+ RECONNECT_MULTIPLIER = 1.5
45
+ # After this many hours, the network info is refreshed
46
+ NETWORK_INFO_REFRESH_INTERVAL = datetime.timedelta(hours=12)
47
+ # Interval to check that the local connection is healthy
48
+ LOCAL_CONNECTION_CHECK_INTERVAL = datetime.timedelta(seconds=15)
49
+
35
50
 
36
51
  class V1Channel(Channel):
37
52
  """Unified V1 protocol channel with automatic MQTT/local connection handling.
@@ -69,6 +84,8 @@ class V1Channel(Channel):
69
84
  self._local_unsub: Callable[[], None] | None = None
70
85
  self._callback: Callable[[RoborockMessage], None] | None = None
71
86
  self._cache = cache
87
+ self._reconnect_task: asyncio.Task[None] | None = None
88
+ self._last_network_info_refresh: datetime.datetime | None = None
72
89
 
73
90
  @property
74
91
  def is_connected(self) -> bool:
@@ -78,7 +95,7 @@ class V1Channel(Channel):
78
95
  @property
79
96
  def is_local_connected(self) -> bool:
80
97
  """Return whether local connection is available."""
81
- return self._local_unsub is not None
98
+ return self._local_channel is not None and self._local_channel.is_connected
82
99
 
83
100
  @property
84
101
  def is_mqtt_connected(self) -> bool:
@@ -103,25 +120,35 @@ class V1Channel(Channel):
103
120
  a RoborockException. A local connection failure will not raise an exception,
104
121
  since the local connection is optional.
105
122
  """
123
+ if self._callback is not None:
124
+ raise ValueError("Only one subscription allowed at a time")
106
125
 
107
- if self._mqtt_unsub:
108
- raise ValueError("Already connected to the device")
109
- self._callback = callback
110
-
111
- # First establish MQTT connection
112
- self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
113
- _LOGGER.debug("V1Channel connected to device %s via MQTT", self._device_uid)
114
-
115
- # Try to establish an optional local connection as well.
126
+ # Make an initial, optimistic attempt to connect to local with the
127
+ # cache. The cache information will be refreshed by the background task.
116
128
  try:
117
- self._local_unsub = await self._local_connect()
129
+ await self._local_connect(use_cache=True)
118
130
  except RoborockException as err:
119
131
  _LOGGER.warning("Could not establish local connection for device %s: %s", self._device_uid, err)
120
- else:
121
- _LOGGER.debug("Local connection established for device %s", self._device_uid)
132
+
133
+ # Start a background task to manage the local connection health. This
134
+ # happens independent of whether we were able to connect locally now.
135
+ _LOGGER.info("self._reconnect_task=%s", self._reconnect_task)
136
+ if self._reconnect_task is None:
137
+ loop = asyncio.get_running_loop()
138
+ self._reconnect_task = loop.create_task(self._background_reconnect())
139
+
140
+ if not self.is_local_connected:
141
+ # We were not able to connect locally, so fallback to MQTT and at least
142
+ # establish that connection explicitly. If this fails then raise an
143
+ # error and let the caller know we failed to subscribe.
144
+ self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
145
+ _LOGGER.debug("V1Channel connected to device %s via MQTT", self._device_uid)
122
146
 
123
147
  def unsub() -> None:
124
148
  """Unsubscribe from all messages."""
149
+ if self._reconnect_task:
150
+ self._reconnect_task.cancel()
151
+ self._reconnect_task = None
125
152
  if self._mqtt_unsub:
126
153
  self._mqtt_unsub()
127
154
  self._mqtt_unsub = None
@@ -130,15 +157,16 @@ class V1Channel(Channel):
130
157
  self._local_unsub = None
131
158
  _LOGGER.debug("Unsubscribed from device %s", self._device_uid)
132
159
 
160
+ self._callback = callback
133
161
  return unsub
134
162
 
135
- async def _get_networking_info(self) -> NetworkInfo:
163
+ async def _get_networking_info(self, *, use_cache: bool = True) -> NetworkInfo:
136
164
  """Retrieve networking information for the device.
137
165
 
138
166
  This is a cloud only command used to get the local device's IP address.
139
167
  """
140
168
  cache_data = await self._cache.get()
141
- if cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
169
+ if use_cache and cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
142
170
  _LOGGER.debug("Using cached network info for device %s", self._device_uid)
143
171
  return network_info
144
172
  try:
@@ -148,24 +176,81 @@ class V1Channel(Channel):
148
176
  except RoborockException as e:
149
177
  raise RoborockException(f"Network info failed for device {self._device_uid}") from e
150
178
  _LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info)
179
+ self._last_network_info_refresh = datetime.datetime.now(datetime.timezone.utc)
151
180
  cache_data.network_info[self._device_uid] = network_info
152
181
  await self._cache.set(cache_data)
153
182
  return network_info
154
183
 
155
- async def _local_connect(self) -> Callable[[], None]:
184
+ async def _local_connect(self, *, use_cache: bool = True) -> None:
156
185
  """Set up local connection if possible."""
157
- _LOGGER.debug("Attempting to connect to local channel for device %s", self._device_uid)
158
- networking_info = await self._get_networking_info()
186
+ _LOGGER.debug(
187
+ "Attempting to connect to local channel for device %s (use_cache=%s)", self._device_uid, use_cache
188
+ )
189
+ networking_info = await self._get_networking_info(use_cache=use_cache)
159
190
  host = networking_info.ip
160
191
  _LOGGER.debug("Connecting to local channel at %s", host)
161
- self._local_channel = self._local_session(host)
192
+ # Create a new local channel and connect
193
+ local_channel = self._local_session(host)
162
194
  try:
163
- await self._local_channel.connect()
195
+ await local_channel.connect()
164
196
  except RoborockException as e:
165
- self._local_channel = None
166
197
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
198
+ # Wire up the new channel
199
+ self._local_channel = local_channel
167
200
  self._local_rpc_channel = create_local_rpc_channel(self._local_channel)
168
- return await self._local_channel.subscribe(self._on_local_message)
201
+ self._local_unsub = await self._local_channel.subscribe(self._on_local_message)
202
+ _LOGGER.info("Successfully connected to local device %s", self._device_uid)
203
+
204
+ async def _background_reconnect(self) -> None:
205
+ """Task to run in the background to manage the local connection."""
206
+ _LOGGER.debug("Starting background task to manage local connection for %s", self._device_uid)
207
+ reconnect_backoff = MIN_RECONNECT_INTERVAL
208
+ local_connect_failures = 0
209
+
210
+ while True:
211
+ try:
212
+ if self.is_local_connected:
213
+ await asyncio.sleep(LOCAL_CONNECTION_CHECK_INTERVAL.total_seconds())
214
+ continue
215
+
216
+ # Not connected, so wait with backoff before trying to connect.
217
+ # The first time through, we don't sleep, we just try to connect.
218
+ local_connect_failures += 1
219
+ if local_connect_failures > 1:
220
+ await asyncio.sleep(reconnect_backoff.total_seconds())
221
+ reconnect_backoff = min(reconnect_backoff * RECONNECT_MULTIPLIER, MAX_RECONNECT_INTERVAL)
222
+
223
+ use_cache = self._should_use_cache(local_connect_failures)
224
+ await self._local_connect(use_cache=use_cache)
225
+ # Reset backoff and failures on success
226
+ reconnect_backoff = MIN_RECONNECT_INTERVAL
227
+ local_connect_failures = 0
228
+
229
+ except asyncio.CancelledError:
230
+ _LOGGER.debug("Background reconnect task cancelled")
231
+ if self._local_channel:
232
+ self._local_channel.close()
233
+ return
234
+ except RoborockException as err:
235
+ _LOGGER.debug("Background reconnect failed: %s", err)
236
+ except Exception:
237
+ _LOGGER.exception("Unhandled exception in background reconnect task")
238
+
239
+ def _should_use_cache(self, local_connect_failures: int) -> bool:
240
+ """Determine whether to use cached network info on retries.
241
+
242
+ On the first retry we'll avoid the cache to handle the case where
243
+ the network ip may have recently changed. Otherwise, use the cache
244
+ if available then expire at some point.
245
+ """
246
+ if local_connect_failures == 1:
247
+ return False
248
+ elif self._last_network_info_refresh and (
249
+ datetime.datetime.now(datetime.timezone.utc) - self._last_network_info_refresh
250
+ > NETWORK_INFO_REFRESH_INTERVAL
251
+ ):
252
+ return False
253
+ return True
169
254
 
170
255
  def _on_mqtt_message(self, message: RoborockMessage) -> None:
171
256
  """Handle incoming MQTT messages."""
@@ -220,6 +220,57 @@ class RoborockMqttSession(MqttSession):
220
220
  raise MqttSessionException(f"Error publishing message: {err}") from err
221
221
 
222
222
 
223
+ class LazyMqttSession(MqttSession):
224
+ """An MQTT session that is started on first attempt to subscribe.
225
+
226
+ This is a wrapper around an existing MqttSession that will only start
227
+ the underlying session when the first attempt to subscribe or publish
228
+ is made.
229
+ """
230
+
231
+ def __init__(self, session: RoborockMqttSession) -> None:
232
+ """Initialize the lazy session with an existing session."""
233
+ self._lock = asyncio.Lock()
234
+ self._started = False
235
+ self._session = session
236
+
237
+ @property
238
+ def connected(self) -> bool:
239
+ """True if the session is connected to the broker."""
240
+ return self._session.connected
241
+
242
+ async def _maybe_start(self) -> None:
243
+ """Start the MQTT session if not already started."""
244
+ async with self._lock:
245
+ if not self._started:
246
+ await self._session.start()
247
+ self._started = True
248
+
249
+ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
250
+ """Invoke the callback when messages are received on the topic.
251
+
252
+ The returned callable unsubscribes from the topic when called.
253
+ """
254
+ await self._maybe_start()
255
+ return await self._session.subscribe(device_id, callback)
256
+
257
+ async def publish(self, topic: str, message: bytes) -> None:
258
+ """Publish a message on the specified topic.
259
+
260
+ This will raise an exception if the message could not be sent.
261
+ """
262
+ await self._maybe_start()
263
+ return await self._session.publish(topic, message)
264
+
265
+ async def close(self) -> None:
266
+ """Cancels the mqtt loop.
267
+
268
+ This will close the underlying session and will not allow it to be
269
+ restarted again.
270
+ """
271
+ await self._session.close()
272
+
273
+
223
274
  async def create_mqtt_session(params: MqttParams) -> MqttSession:
224
275
  """Create an MQTT session.
225
276
 
@@ -230,3 +281,12 @@ async def create_mqtt_session(params: MqttParams) -> MqttSession:
230
281
  session = RoborockMqttSession(params)
231
282
  await session.start()
232
283
  return session
284
+
285
+
286
+ async def create_lazy_mqtt_session(params: MqttParams) -> MqttSession:
287
+ """Create a lazy MQTT session.
288
+
289
+ This function is a factory for creating an MQTT session that will
290
+ only connect when the first attempt to subscribe or publish is made.
291
+ """
292
+ return LazyMqttSession(RoborockMqttSession(params))
@@ -1,7 +0,0 @@
1
- """Roborock API."""
2
-
3
- from roborock.b01_containers import *
4
- from roborock.code_mappings import *
5
- from roborock.containers import *
6
- from roborock.exceptions import *
7
- from roborock.roborock_typing import *