python-roborock 2.41.0__tar.gz → 2.42.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 (57) hide show
  1. {python_roborock-2.41.0 → python_roborock-2.42.0}/PKG-INFO +2 -1
  2. {python_roborock-2.41.0 → python_roborock-2.42.0}/pyproject.toml +2 -1
  3. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/cli.py +250 -104
  4. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/v1_channel.py +8 -4
  5. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/v1_rpc_channel.py +13 -14
  6. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocol.py +80 -0
  7. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/util.py +1 -24
  8. {python_roborock-2.41.0 → python_roborock-2.42.0}/LICENSE +0 -0
  9. {python_roborock-2.41.0 → python_roborock-2.42.0}/README.md +0 -0
  10. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/__init__.py +0 -0
  11. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/api.py +0 -0
  12. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/b01_containers.py +0 -0
  13. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/broadcast_protocol.py +0 -0
  14. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/callbacks.py +0 -0
  15. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/clean_modes.py +0 -0
  16. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/cloud_api.py +0 -0
  17. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/code_mappings.py +0 -0
  18. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/command_cache.py +0 -0
  19. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/const.py +0 -0
  20. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/containers.py +0 -0
  21. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/device_features.py +0 -0
  22. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/README.md +0 -0
  23. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/__init__.py +0 -0
  24. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/a01_channel.py +0 -0
  25. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/b01_channel.py +0 -0
  26. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/cache.py +0 -0
  27. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/channel.py +0 -0
  28. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/device.py +0 -0
  29. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/device_manager.py +0 -0
  30. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/local_channel.py +0 -0
  31. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/mqtt_channel.py +0 -0
  32. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/b01/__init__.py +0 -0
  33. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/b01/props.py +0 -0
  34. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/dnd.py +0 -0
  35. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/dyad.py +0 -0
  36. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/status.py +0 -0
  37. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/trait.py +0 -0
  38. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/zeo.py +0 -0
  39. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/exceptions.py +0 -0
  40. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/__init__.py +0 -0
  41. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/roborock_session.py +0 -0
  42. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/session.py +0 -0
  43. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/a01_protocol.py +0 -0
  44. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/b01_protocol.py +0 -0
  45. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/v1_protocol.py +0 -0
  46. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/py.typed +0 -0
  47. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_future.py +0 -0
  48. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_message.py +0 -0
  49. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_typing.py +0 -0
  50. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/__init__.py +0 -0
  51. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  52. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  53. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  54. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/__init__.py +0 -0
  55. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  56. {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  57. {python_roborock-2.41.0 → python_roborock-2.42.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.41.0
3
+ Version: 2.42.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
@@ -21,6 +21,7 @@ Requires-Dist: aiohttp (>=3.8.2,<4.0.0)
21
21
  Requires-Dist: aiomqtt (>=2.3.2,<3.0.0)
22
22
  Requires-Dist: async-timeout
23
23
  Requires-Dist: click (>=8)
24
+ Requires-Dist: click-shell (>=2.1,<3.0)
24
25
  Requires-Dist: construct (>=2.10.57,<3.0.0)
25
26
  Requires-Dist: paho-mqtt (>=1.6.1,<3.0.0)
26
27
  Requires-Dist: pycryptodome (>=3.18,<4.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.41.0"
3
+ version = "2.42.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"
@@ -32,6 +32,7 @@ construct = "^2.10.57"
32
32
  vacuum-map-parser-roborock = "*"
33
33
  pyrate-limiter = "^3.7.0"
34
34
  aiomqtt = "^2.3.2"
35
+ click-shell = "^2.1"
35
36
 
36
37
 
37
38
  [build-system]
@@ -1,29 +1,96 @@
1
+ """Command line interface for python-roborock.
2
+
3
+ The CLI supports both one-off commands and an interactive session mode. In session
4
+ mode, an asyncio event loop is created in a separate thread, allowing users to
5
+ interactively run commands that require async operations.
6
+
7
+ Typical CLI usage:
8
+ ```
9
+ $ roborock login --email <email> [--password <password>]
10
+ $ roborock discover
11
+ $ roborock list-devices
12
+ $ roborock status --device_id <device_id>
13
+ ```
14
+ ...
15
+
16
+ Session mode usage:
17
+ ```
18
+ $ roborock session
19
+ roborock> list-devices
20
+ ...
21
+ roborock> status --device_id <device_id>
22
+ ```
23
+ """
1
24
  import asyncio
25
+ import datetime
26
+ import functools
2
27
  import json
3
28
  import logging
29
+ import threading
4
30
  from dataclasses import asdict, dataclass
5
31
  from pathlib import Path
6
- from typing import Any
32
+ from typing import Any, cast
7
33
 
8
34
  import click
35
+ import click_shell
9
36
  import yaml
10
37
  from pyshark import FileCapture # type: ignore
11
38
  from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException # type: ignore
12
39
  from pyshark.packet.packet import Packet # type: ignore
13
40
 
14
41
  from roborock import SHORT_MODEL_TO_ENUM, DeviceFeatures, RoborockCommand, RoborockException
15
- from roborock.containers import DeviceData, HomeData, HomeDataProduct, LoginData, NetworkInfo, RoborockBase, UserData
42
+ from roborock.containers import DeviceData, HomeData, NetworkInfo, RoborockBase, UserData
16
43
  from roborock.devices.cache import Cache, CacheData
17
- from roborock.devices.device_manager import create_device_manager, create_home_data_api
44
+ from roborock.devices.device import RoborockDevice
45
+ from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
18
46
  from roborock.protocol import MessageParser
19
- from roborock.util import run_sync
20
- from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
21
47
  from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
22
48
  from roborock.web_api import RoborockApiClient
23
49
 
24
50
  _LOGGER = logging.getLogger(__name__)
25
51
 
26
52
 
53
+ def dump_json(obj: Any) -> Any:
54
+ """Dump an object as JSON."""
55
+
56
+ def custom_json_serializer(obj):
57
+ if isinstance(obj, datetime.time):
58
+ return obj.isoformat()
59
+ raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
60
+
61
+ return json.dumps(obj, default=custom_json_serializer)
62
+
63
+
64
+ def async_command(func):
65
+ """Decorator for async commands that work in both CLI and session modes.
66
+
67
+ The CLI supports two execution modes:
68
+ 1. CLI mode: One-off commands that create their own event loop
69
+ 2. Session mode: Interactive shell with a persistent background event loop
70
+
71
+ This decorator ensures async commands work correctly in both modes:
72
+ - CLI mode: Uses asyncio.run() to create a new event loop
73
+ - Session mode: Uses the existing session event loop via run_in_session()
74
+ """
75
+
76
+ @functools.wraps(func)
77
+ def wrapper(*args, **kwargs):
78
+ ctx = args[0]
79
+ context: RoborockContext = ctx.obj
80
+
81
+ async def run():
82
+ return await func(*args, **kwargs)
83
+
84
+ if context.is_session_mode():
85
+ # Session mode - run in the persistent loop
86
+ return context.run_in_session(run())
87
+ else:
88
+ # CLI mode - just run normally (asyncio.run handles loop creation)
89
+ return asyncio.run(run())
90
+
91
+ return wrapper
92
+
93
+
27
94
  @dataclass
28
95
  class ConnectionCache(RoborockBase):
29
96
  """Cache for Roborock data.
@@ -41,12 +108,52 @@ class ConnectionCache(RoborockBase):
41
108
  network_info: dict[str, NetworkInfo] | None = None
42
109
 
43
110
 
111
+ class DeviceConnectionManager:
112
+ """Manages device connections for both CLI and session modes."""
113
+
114
+ def __init__(self, context: "RoborockContext", loop: asyncio.AbstractEventLoop | None = None):
115
+ self.context = context
116
+ self.loop = loop
117
+ self.device_manager: DeviceManager | None = None
118
+ self._devices: dict[str, RoborockDevice] = {}
119
+
120
+ async def ensure_device_manager(self) -> DeviceManager:
121
+ """Ensure device manager is initialized."""
122
+ if self.device_manager is None:
123
+ cache_data = self.context.cache_data()
124
+ home_data_api = create_home_data_api(cache_data.email, cache_data.user_data)
125
+ self.device_manager = await create_device_manager(cache_data.user_data, home_data_api, self.context)
126
+ # Cache devices for quick lookup
127
+ devices = await self.device_manager.get_devices()
128
+ self._devices = {device.duid: device for device in devices}
129
+ return self.device_manager
130
+
131
+ async def get_device(self, device_id: str) -> RoborockDevice:
132
+ """Get a device by ID, creating connections if needed."""
133
+ await self.ensure_device_manager()
134
+ if device_id not in self._devices:
135
+ raise RoborockException(f"Device {device_id} not found")
136
+ return self._devices[device_id]
137
+
138
+ async def close(self):
139
+ """Close device manager connections."""
140
+ if self.device_manager:
141
+ await self.device_manager.close()
142
+ self.device_manager = None
143
+ self._devices = {}
144
+
145
+
44
146
  class RoborockContext(Cache):
147
+ """Context that handles both CLI and session modes internally."""
148
+
45
149
  roborock_file = Path("~/.roborock").expanduser()
46
150
  _cache_data: ConnectionCache | None = None
47
151
 
48
152
  def __init__(self):
49
153
  self.reload()
154
+ self._session_loop: asyncio.AbstractEventLoop | None = None
155
+ self._session_thread: threading.Thread | None = None
156
+ self._device_manager: DeviceConnectionManager | None = None
50
157
 
51
158
  def reload(self):
52
159
  if self.roborock_file.is_file():
@@ -68,7 +175,76 @@ class RoborockContext(Cache):
68
175
  def cache_data(self) -> ConnectionCache:
69
176
  """Get the cache data."""
70
177
  self.validate()
71
- return self._cache_data
178
+ return cast(ConnectionCache, self._cache_data)
179
+
180
+ def start_session_mode(self):
181
+ """Start session mode with a background event loop."""
182
+ if self._session_loop is not None:
183
+ return # Already started
184
+
185
+ self._session_loop = asyncio.new_event_loop()
186
+ self._session_thread = threading.Thread(target=self._run_session_loop)
187
+ self._session_thread.daemon = True
188
+ self._session_thread.start()
189
+
190
+ def _run_session_loop(self):
191
+ """Run the session event loop in a background thread."""
192
+ assert self._session_loop is not None # guaranteed by start_session_mode
193
+ asyncio.set_event_loop(self._session_loop)
194
+ self._session_loop.run_forever()
195
+
196
+ def is_session_mode(self) -> bool:
197
+ return self._session_loop is not None
198
+
199
+ def run_in_session(self, coro):
200
+ """Run a coroutine in the session loop (session mode only)."""
201
+ if not self._session_loop:
202
+ raise RoborockException("Not in session mode")
203
+ future = asyncio.run_coroutine_threadsafe(coro, self._session_loop)
204
+ return future.result()
205
+
206
+ async def get_device_manager(self) -> DeviceConnectionManager:
207
+ """Get device manager, creating if needed."""
208
+ await self.get_devices()
209
+ if self._device_manager is None:
210
+ self._device_manager = DeviceConnectionManager(self, self._session_loop)
211
+ return self._device_manager
212
+
213
+ async def refresh_devices(self) -> ConnectionCache:
214
+ """Refresh device data from server (always fetches fresh data)."""
215
+ cache_data = self.cache_data()
216
+ client = RoborockApiClient(cache_data.email)
217
+ home_data = await client.get_home_data_v3(cache_data.user_data)
218
+ cache_data.home_data = home_data
219
+ self.update(cache_data)
220
+ return cache_data
221
+
222
+ async def get_devices(self) -> ConnectionCache:
223
+ """Get device data (uses cache if available, fetches if needed)."""
224
+ cache_data = self.cache_data()
225
+ if not cache_data.home_data:
226
+ cache_data = await self.refresh_devices()
227
+ return cache_data
228
+
229
+ async def cleanup(self):
230
+ """Clean up resources (mainly for session mode)."""
231
+ if self._device_manager:
232
+ await self._device_manager.close()
233
+ self._device_manager = None
234
+
235
+ # Stop session loop if running
236
+ if self._session_loop:
237
+ self._session_loop.call_soon_threadsafe(self._session_loop.stop)
238
+ if self._session_thread:
239
+ self._session_thread.join(timeout=5.0)
240
+ self._session_loop = None
241
+ self._session_thread = None
242
+
243
+ def finish_session(self) -> None:
244
+ """Finish the session and clean up resources."""
245
+ if self._session_loop:
246
+ future = asyncio.run_coroutine_threadsafe(self.cleanup(), self._session_loop)
247
+ future.result(timeout=5.0)
72
248
 
73
249
  async def get(self) -> CacheData:
74
250
  """Get cached value."""
@@ -101,7 +277,7 @@ def cli(ctx, debug: int):
101
277
  help="Password for the Roborock account. If not provided, an email code will be requested.",
102
278
  )
103
279
  @click.pass_context
104
- @run_sync()
280
+ @async_command
105
281
  async def login(ctx, email, password):
106
282
  """Login to Roborock account."""
107
283
  context: RoborockContext = ctx.obj
@@ -120,91 +296,67 @@ async def login(ctx, email, password):
120
296
  code = click.prompt("A code has been sent to your email, please enter the code", type=str)
121
297
  user_data = await client.code_login(code)
122
298
  print("Login successful")
123
- context.update(LoginData(user_data=user_data, email=email))
299
+ context.update(ConnectionCache(user_data=user_data, email=email))
124
300
 
125
301
 
126
- @click.command()
127
- @click.pass_context
128
- @click.option("--duration", default=10, help="Duration to run the MQTT session in seconds")
129
- @run_sync()
130
- async def session(ctx, duration: int):
302
+ def _shell_session_finished(ctx):
303
+ """Callback for when shell session finishes."""
131
304
  context: RoborockContext = ctx.obj
132
- cache_data = context.cache_data()
133
-
134
- home_data_api = create_home_data_api(cache_data.email, cache_data.user_data)
135
-
136
- # Create device manager
137
- device_manager = await create_device_manager(cache_data.user_data, home_data_api, context)
138
-
139
- devices = await device_manager.get_devices()
140
- click.echo(f"Discovered devices: {', '.join([device.name for device in devices])}")
141
-
142
- click.echo("MQTT session started. Querying devices...")
143
- for device in devices:
144
- if not (status_trait := device.traits.get("status")):
145
- click.echo(f"Device {device.name} does not have a status trait")
146
- continue
147
- try:
148
- status = await status_trait.get_status()
149
- except RoborockException as e:
150
- click.echo(f"Failed to get status for {device.name}: {e}")
151
- else:
152
- click.echo(f"Device {device.name} status: {status.as_dict()}")
153
-
154
- click.echo("Listening for messages.")
155
- await asyncio.sleep(duration)
156
-
157
- # Close the device manager (this will close all devices and MQTT session)
158
- await device_manager.close()
159
-
160
-
161
- async def _discover(ctx):
162
- context: RoborockContext = ctx.obj
163
- cache_data = context.cache_data()
164
- if not cache_data:
165
- raise Exception("You need to login first")
166
- client = RoborockApiClient(cache_data.email)
167
- home_data = await client.get_home_data_v3(cache_data.user_data)
168
- cache_data.home_data = home_data
169
- context.update(cache_data)
170
- click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}")
305
+ try:
306
+ context.finish_session()
307
+ except Exception as e:
308
+ click.echo(f"Error during cleanup: {e}", err=True)
309
+ click.echo("Session finished")
171
310
 
172
311
 
173
- async def _load_and_discover(ctx) -> RoborockContext:
174
- """Discover devices if home data is not available."""
312
+ @click_shell.shell(
313
+ prompt="roborock> ",
314
+ on_finished=_shell_session_finished,
315
+ )
316
+ @click.pass_context
317
+ def session(ctx):
318
+ """Start an interactive session."""
175
319
  context: RoborockContext = ctx.obj
176
- cache_data = context.cache_data()
177
- if not cache_data.home_data:
178
- await _discover(ctx)
179
- cache_data = context.cache_data()
180
- return context
320
+ # Start session mode with background loop
321
+ context.start_session_mode()
322
+ context.run_in_session(context.get_device_manager())
323
+ click.echo("OK")
181
324
 
182
325
 
183
- @click.command()
326
+ @session.command()
184
327
  @click.pass_context
185
- @run_sync()
328
+ @async_command
186
329
  async def discover(ctx):
187
- await _discover(ctx)
330
+ """Discover devices."""
331
+ context: RoborockContext = ctx.obj
332
+ # Use the explicit refresh method for the discover command
333
+ cache_data = await context.refresh_devices()
188
334
 
335
+ home_data = cache_data.home_data
336
+ click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}")
189
337
 
190
- @click.command()
338
+
339
+ @session.command()
191
340
  @click.pass_context
192
- @run_sync()
341
+ @async_command
193
342
  async def list_devices(ctx):
194
- context: RoborockContext = await _load_and_discover(ctx)
195
- cache_data = context.cache_data()
343
+ context: RoborockContext = ctx.obj
344
+ cache_data = await context.get_devices()
345
+
196
346
  home_data = cache_data.home_data
197
- device_name_id = {device.name: device.duid for device in home_data.devices + home_data.received_devices}
347
+
348
+ device_name_id = {device.name: device.duid for device in home_data.get_all_devices()}
198
349
  click.echo(json.dumps(device_name_id, indent=4))
199
350
 
200
351
 
201
352
  @click.command()
202
353
  @click.option("--device_id", required=True)
203
354
  @click.pass_context
204
- @run_sync()
355
+ @async_command
205
356
  async def list_scenes(ctx, device_id):
206
- context: RoborockContext = await _load_and_discover(ctx)
207
- cache_data = context.cache_data()
357
+ context: RoborockContext = ctx.obj
358
+ cache_data = await context.get_devices()
359
+
208
360
  client = RoborockApiClient(cache_data.email)
209
361
  scenes = await client.get_scenes(cache_data.user_data, device_id)
210
362
  output_list = []
@@ -216,40 +368,34 @@ async def list_scenes(ctx, device_id):
216
368
  @click.command()
217
369
  @click.option("--scene_id", required=True)
218
370
  @click.pass_context
219
- @run_sync()
371
+ @async_command
220
372
  async def execute_scene(ctx, scene_id):
221
- context: RoborockContext = await _load_and_discover(ctx)
222
- cache_data = context.cache_data()
373
+ context: RoborockContext = ctx.obj
374
+ cache_data = await context.get_devices()
375
+
223
376
  client = RoborockApiClient(cache_data.email)
224
377
  await client.execute_scene(cache_data.user_data, scene_id)
225
378
 
226
379
 
227
- @click.command()
380
+ @session.command()
228
381
  @click.option("--device_id", required=True)
229
382
  @click.pass_context
230
- @run_sync()
231
- async def status(ctx, device_id):
232
- context: RoborockContext = await _load_and_discover(ctx)
233
- cache_data = context.cache_data()
383
+ @async_command
384
+ async def status(ctx, device_id: str):
385
+ """Get device status - unified implementation for both modes."""
386
+ context: RoborockContext = ctx.obj
234
387
 
235
- home_data = cache_data.home_data
236
- devices = home_data.devices + home_data.received_devices
237
- device = next(device for device in devices if device.duid == device_id)
238
- product_info: dict[str, HomeDataProduct] = {product.id: product for product in home_data.products}
239
- device_data = DeviceData(device, product_info[device.product_id].model)
240
-
241
- mqtt_client = RoborockMqttClientV1(cache_data.user_data, device_data)
242
- if not (networking := cache_data.network_info.get(device.duid)):
243
- networking = await mqtt_client.get_networking()
244
- cache_data.network_info[device.duid] = networking
245
- context.update(cache_data)
246
- else:
247
- _LOGGER.debug("Using cached networking info for device %s: %s", device.duid, networking)
388
+ device_manager = await context.get_device_manager()
389
+ device = await device_manager.get_device(device_id)
248
390
 
249
- local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip)
250
- local_client = RoborockLocalClientV1(local_device_data)
251
- status = await local_client.get_status()
252
- click.echo(json.dumps(status.as_dict(), indent=4))
391
+ click.echo(f"Getting status for device {device_id}")
392
+ if not (status_trait := device.traits.get("status")):
393
+ click.echo(f"Device {device.name} does not have a status trait")
394
+ return
395
+
396
+ status_result = await status_trait.get_status()
397
+ click.echo(f"Device {device_id} status:")
398
+ click.echo(dump_json(status_result.as_dict()))
253
399
 
254
400
 
255
401
  @click.command()
@@ -257,13 +403,13 @@ async def status(ctx, device_id):
257
403
  @click.option("--cmd", required=True)
258
404
  @click.option("--params", required=False)
259
405
  @click.pass_context
260
- @run_sync()
406
+ @async_command
261
407
  async def command(ctx, cmd, device_id, params):
262
- context: RoborockContext = await _load_and_discover(ctx)
263
- cache_data = context.cache_data()
408
+ context: RoborockContext = ctx.obj
409
+ cache_data = await context.get_devices()
264
410
 
265
411
  home_data = cache_data.home_data
266
- devices = home_data.devices + home_data.received_devices
412
+ devices = home_data.get_all_devices()
267
413
  device = next(device for device in devices if device.duid == device_id)
268
414
  model = next(
269
415
  (product.model for product in home_data.products if device is not None and product.id == device.product_id),
@@ -282,7 +428,7 @@ async def command(ctx, cmd, device_id, params):
282
428
  @click.option("--device_ip", required=True)
283
429
  @click.option("--file", required=False)
284
430
  @click.pass_context
285
- @run_sync()
431
+ @async_command
286
432
  async def parser(_, local_key, device_ip, file):
287
433
  file_provided = file is not None
288
434
  if file_provided:
@@ -328,18 +474,18 @@ async def parser(_, local_key, device_ip, file):
328
474
 
329
475
  @click.command()
330
476
  @click.pass_context
331
- @run_sync()
477
+ @async_command
332
478
  async def get_device_info(ctx: click.Context):
333
479
  """
334
480
  Connects to devices and prints their feature information in YAML format.
335
481
  """
336
482
  click.echo("Discovering devices...")
337
- context: RoborockContext = await _load_and_discover(ctx)
338
- cache_data = context.cache_data()
483
+ context: RoborockContext = ctx.obj
484
+ cache_data = await context.get_devices()
339
485
 
340
486
  home_data = cache_data.home_data
341
487
 
342
- all_devices = home_data.devices + home_data.received_devices
488
+ all_devices = home_data.get_all_devices()
343
489
  if not all_devices:
344
490
  click.echo("No devices found.")
345
491
  return
@@ -22,7 +22,7 @@ from .cache import Cache
22
22
  from .channel import Channel
23
23
  from .local_channel import LocalChannel, LocalSession, create_local_session
24
24
  from .mqtt_channel import MqttChannel
25
- from .v1_rpc_channel import V1RpcChannel, create_combined_rpc_channel, create_mqtt_rpc_channel
25
+ from .v1_rpc_channel import PickFirstAvailable, V1RpcChannel, create_local_rpc_channel, create_mqtt_rpc_channel
26
26
 
27
27
  _LOGGER = logging.getLogger(__name__)
28
28
 
@@ -60,7 +60,11 @@ class V1Channel(Channel):
60
60
  self._mqtt_rpc_channel = create_mqtt_rpc_channel(mqtt_channel, security_data)
61
61
  self._local_session = local_session
62
62
  self._local_channel: LocalChannel | None = None
63
- self._combined_rpc_channel: V1RpcChannel | None = None
63
+ self._local_rpc_channel: V1RpcChannel | None = None
64
+ # Prefer local, fallback to MQTT
65
+ self._combined_rpc_channel = PickFirstAvailable(
66
+ [lambda: self._local_rpc_channel, lambda: self._mqtt_rpc_channel]
67
+ )
64
68
  self._mqtt_unsub: Callable[[], None] | None = None
65
69
  self._local_unsub: Callable[[], None] | None = None
66
70
  self._callback: Callable[[RoborockMessage], None] | None = None
@@ -84,7 +88,7 @@ class V1Channel(Channel):
84
88
  @property
85
89
  def rpc_channel(self) -> V1RpcChannel:
86
90
  """Return the combined RPC channel prefers local with a fallback to MQTT."""
87
- return self._combined_rpc_channel or self._mqtt_rpc_channel
91
+ return self._combined_rpc_channel
88
92
 
89
93
  @property
90
94
  def mqtt_rpc_channel(self) -> V1RpcChannel:
@@ -160,7 +164,7 @@ class V1Channel(Channel):
160
164
  except RoborockException as e:
161
165
  self._local_channel = None
162
166
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
163
- self._combined_rpc_channel = create_combined_rpc_channel(self._local_channel, self._mqtt_rpc_channel)
167
+ self._local_rpc_channel = create_local_rpc_channel(self._local_channel)
164
168
  return await self._local_channel.subscribe(self._on_local_message)
165
169
 
166
170
  def _on_mqtt_message(self, message: RoborockMessage) -> None:
@@ -88,16 +88,15 @@ class BaseV1RpcChannel(V1RpcChannel):
88
88
  raise NotImplementedError
89
89
 
90
90
 
91
- class CombinedV1RpcChannel(BaseV1RpcChannel):
92
- """A V1 RPC channel that can use both local and MQTT channels, preferring local when available."""
91
+ class PickFirstAvailable(BaseV1RpcChannel):
92
+ """A V1 RPC channel that tries multiple channels and picks the first that works."""
93
93
 
94
94
  def __init__(
95
- self, local_channel: LocalChannel, local_rpc_channel: V1RpcChannel, mqtt_channel: V1RpcChannel
95
+ self,
96
+ channel_cbs: list[Callable[[], V1RpcChannel | None]],
96
97
  ) -> None:
97
- """Initialize the combined channel with local and MQTT channels."""
98
- self._local_channel = local_channel
99
- self._local_rpc_channel = local_rpc_channel
100
- self._mqtt_rpc_channel = mqtt_channel
98
+ """Initialize the pick-first-available channel."""
99
+ self._channel_cbs = channel_cbs
101
100
 
102
101
  async def _send_raw_command(
103
102
  self,
@@ -106,9 +105,10 @@ class CombinedV1RpcChannel(BaseV1RpcChannel):
106
105
  params: ParamsType = None,
107
106
  ) -> Any:
108
107
  """Send a command and return a parsed response RoborockBase type."""
109
- if self._local_channel.is_connected:
110
- return await self._local_rpc_channel.send_command(method, params=params)
111
- return await self._mqtt_rpc_channel.send_command(method, params=params)
108
+ for channel_cb in self._channel_cbs:
109
+ if channel := channel_cb():
110
+ return await channel.send_command(method, params=params)
111
+ raise RoborockException("No available connection to send command")
112
112
 
113
113
 
114
114
  class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
@@ -170,11 +170,10 @@ def create_mqtt_rpc_channel(mqtt_channel: MqttChannel, security_data: SecurityDa
170
170
  )
171
171
 
172
172
 
173
- def create_combined_rpc_channel(local_channel: LocalChannel, mqtt_rpc_channel: V1RpcChannel) -> V1RpcChannel:
174
- """Create a V1 RPC channel that combines local and MQTT channels."""
175
- local_rpc_channel = PayloadEncodedV1RpcChannel(
173
+ def create_local_rpc_channel(local_channel: LocalChannel) -> V1RpcChannel:
174
+ """Create a V1 RPC channel using a local channel."""
175
+ return PayloadEncodedV1RpcChannel(
176
176
  "local",
177
177
  local_channel,
178
178
  lambda x: x.encode_message(RoborockMessageProtocol.GENERAL_REQUEST),
179
179
  )
180
- return CombinedV1RpcChannel(local_channel, local_rpc_channel, mqtt_rpc_channel)
@@ -149,6 +149,86 @@ class Utils:
149
149
  return unpad(decipher.decrypt(ciphertext), AES.block_size)
150
150
  return ciphertext
151
151
 
152
+ @staticmethod
153
+ def _l01_key(local_key: str, timestamp: int) -> bytes:
154
+ """Derive key for L01 protocol."""
155
+ hash_input = Utils.encode_timestamp(timestamp) + Utils.ensure_bytes(local_key) + SALT
156
+ return hashlib.sha256(hash_input).digest()
157
+
158
+ @staticmethod
159
+ def _l01_iv(timestamp: int, nonce: int, sequence: int) -> bytes:
160
+ """Derive IV for L01 protocol."""
161
+ digest_input = sequence.to_bytes(4, "big") + nonce.to_bytes(4, "big") + timestamp.to_bytes(4, "big")
162
+ digest = hashlib.sha256(digest_input).digest()
163
+ return digest[:12]
164
+
165
+ @staticmethod
166
+ def _l01_aad(timestamp: int, nonce: int, sequence: int, connect_nonce: int, ack_nonce: int) -> bytes:
167
+ """Derive AAD for L01 protocol."""
168
+ return (
169
+ sequence.to_bytes(4, "big")
170
+ + connect_nonce.to_bytes(4, "big")
171
+ + ack_nonce.to_bytes(4, "big")
172
+ + nonce.to_bytes(4, "big")
173
+ + timestamp.to_bytes(4, "big")
174
+ )
175
+
176
+ @staticmethod
177
+ def encrypt_gcm_l01(
178
+ plaintext: bytes,
179
+ local_key: str,
180
+ timestamp: int,
181
+ sequence: int,
182
+ nonce: int,
183
+ connect_nonce: int,
184
+ ack_nonce: int,
185
+ ) -> bytes:
186
+ """Encrypt plaintext for L01 protocol using AES-256-GCM."""
187
+ if not isinstance(plaintext, bytes):
188
+ raise TypeError("plaintext requires bytes")
189
+
190
+ key = Utils._l01_key(local_key, timestamp)
191
+ iv = Utils._l01_iv(timestamp, nonce, sequence)
192
+ aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce)
193
+
194
+ cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
195
+ cipher.update(aad)
196
+ ciphertext, tag = cipher.encrypt_and_digest(plaintext)
197
+
198
+ return ciphertext + tag
199
+
200
+ @staticmethod
201
+ def decrypt_gcm_l01(
202
+ payload: bytes,
203
+ local_key: str,
204
+ timestamp: int,
205
+ sequence: int,
206
+ nonce: int,
207
+ connect_nonce: int,
208
+ ack_nonce: int,
209
+ ) -> bytes:
210
+ """Decrypt payload for L01 protocol using AES-256-GCM."""
211
+ if not isinstance(payload, bytes):
212
+ raise TypeError("payload requires bytes")
213
+
214
+ key = Utils._l01_key(local_key, timestamp)
215
+ iv = Utils._l01_iv(timestamp, nonce, sequence)
216
+ aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce)
217
+
218
+ if len(payload) < 16:
219
+ raise ValueError("Invalid payload length for GCM decryption")
220
+
221
+ tag = payload[-16:]
222
+ ciphertext = payload[:-16]
223
+
224
+ cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
225
+ cipher.update(aad)
226
+
227
+ try:
228
+ return cipher.decrypt_and_verify(ciphertext, tag)
229
+ except ValueError as e:
230
+ raise RoborockException("GCM tag verification failed") from e
231
+
152
232
  @staticmethod
153
233
  def crc(data: bytes) -> int:
154
234
  """Gather bytes for checksum calculation."""
@@ -2,9 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import datetime
5
- import functools
6
5
  import logging
7
- from asyncio import AbstractEventLoop, TimerHandle
6
+ from asyncio import TimerHandle
8
7
  from collections.abc import Callable, Coroutine, MutableMapping
9
8
  from typing import Any, TypeVar
10
9
 
@@ -18,15 +17,6 @@ def unpack_list(value: list[T], size: int) -> list[T | None]:
18
17
  return (value + [None] * size)[:size] # type: ignore
19
18
 
20
19
 
21
- def get_running_loop_or_create_one() -> AbstractEventLoop:
22
- try:
23
- loop = asyncio.get_event_loop()
24
- except RuntimeError:
25
- loop = asyncio.new_event_loop()
26
- asyncio.set_event_loop(loop)
27
- return loop
28
-
29
-
30
20
  def parse_datetime_to_roborock_datetime(
31
21
  start_datetime: datetime.datetime, end_datetime: datetime.datetime
32
22
  ) -> tuple[datetime.datetime, datetime.datetime]:
@@ -60,19 +50,6 @@ def parse_time_to_datetime(
60
50
  return parse_datetime_to_roborock_datetime(start_datetime, end_datetime)
61
51
 
62
52
 
63
- def run_sync():
64
- loop = get_running_loop_or_create_one()
65
-
66
- def decorator(func):
67
- @functools.wraps(func)
68
- def wrapped(*args, **kwargs):
69
- return loop.run_until_complete(func(*args, **kwargs))
70
-
71
- return wrapped
72
-
73
- return decorator
74
-
75
-
76
53
  class RepeatableTask:
77
54
  def __init__(self, callback: Callable[[], Coroutine], interval: int):
78
55
  self.callback = callback