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.
- {python_roborock-2.41.0 → python_roborock-2.42.0}/PKG-INFO +2 -1
- {python_roborock-2.41.0 → python_roborock-2.42.0}/pyproject.toml +2 -1
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/cli.py +250 -104
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/v1_channel.py +8 -4
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/v1_rpc_channel.py +13 -14
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocol.py +80 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/util.py +1 -24
- {python_roborock-2.41.0 → python_roborock-2.42.0}/LICENSE +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/README.md +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/api.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/b01_containers.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/callbacks.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/clean_modes.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/const.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/containers.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/device_features.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/a01_channel.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/b01_channel.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/cache.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/channel.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/device.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/device_manager.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/local_channel.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/mqtt_channel.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/b01/props.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/dnd.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/dyad.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/status.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/trait.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/devices/traits/zeo.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/b01_protocol.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/py.typed +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
- {python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
42
|
+
from roborock.containers import DeviceData, HomeData, NetworkInfo, RoborockBase, UserData
|
|
16
43
|
from roborock.devices.cache import Cache, CacheData
|
|
17
|
-
from roborock.devices.
|
|
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
|
-
@
|
|
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(
|
|
299
|
+
context.update(ConnectionCache(user_data=user_data, email=email))
|
|
124
300
|
|
|
125
301
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
174
|
-
"
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
@
|
|
326
|
+
@session.command()
|
|
184
327
|
@click.pass_context
|
|
185
|
-
@
|
|
328
|
+
@async_command
|
|
186
329
|
async def discover(ctx):
|
|
187
|
-
|
|
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
|
-
|
|
338
|
+
|
|
339
|
+
@session.command()
|
|
191
340
|
@click.pass_context
|
|
192
|
-
@
|
|
341
|
+
@async_command
|
|
193
342
|
async def list_devices(ctx):
|
|
194
|
-
context: RoborockContext =
|
|
195
|
-
cache_data = context.
|
|
343
|
+
context: RoborockContext = ctx.obj
|
|
344
|
+
cache_data = await context.get_devices()
|
|
345
|
+
|
|
196
346
|
home_data = cache_data.home_data
|
|
197
|
-
|
|
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
|
-
@
|
|
355
|
+
@async_command
|
|
205
356
|
async def list_scenes(ctx, device_id):
|
|
206
|
-
context: RoborockContext =
|
|
207
|
-
cache_data = context.
|
|
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
|
-
@
|
|
371
|
+
@async_command
|
|
220
372
|
async def execute_scene(ctx, scene_id):
|
|
221
|
-
context: RoborockContext =
|
|
222
|
-
cache_data = context.
|
|
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
|
-
@
|
|
380
|
+
@session.command()
|
|
228
381
|
@click.option("--device_id", required=True)
|
|
229
382
|
@click.pass_context
|
|
230
|
-
@
|
|
231
|
-
async def status(ctx, device_id):
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
@
|
|
406
|
+
@async_command
|
|
261
407
|
async def command(ctx, cmd, device_id, params):
|
|
262
|
-
context: RoborockContext =
|
|
263
|
-
cache_data = context.
|
|
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.
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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 =
|
|
338
|
-
cache_data = context.
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
92
|
-
"""A V1 RPC channel that
|
|
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,
|
|
95
|
+
self,
|
|
96
|
+
channel_cbs: list[Callable[[], V1RpcChannel | None]],
|
|
96
97
|
) -> None:
|
|
97
|
-
"""Initialize the
|
|
98
|
-
self.
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
174
|
-
"""Create a V1 RPC channel
|
|
175
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.41.0 → python_roborock-2.42.0}/roborock/version_a01_apis/roborock_client_a01.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|