roborock-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1338 @@
|
|
|
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
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import datetime
|
|
27
|
+
import functools
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from dataclasses import asdict, dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, cast
|
|
36
|
+
|
|
37
|
+
import click
|
|
38
|
+
import click_shell
|
|
39
|
+
import yaml
|
|
40
|
+
from pyshark import FileCapture # type: ignore
|
|
41
|
+
from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException # type: ignore
|
|
42
|
+
from pyshark.packet.packet import Packet # type: ignore
|
|
43
|
+
|
|
44
|
+
from roborock_cli._vendor.roborock import RoborockCommand
|
|
45
|
+
from roborock_cli._vendor.roborock.data import RoborockBase, UserData
|
|
46
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType, YXFanLevel
|
|
47
|
+
from roborock_cli._vendor.roborock.data.code_mappings import SHORT_MODEL_TO_ENUM
|
|
48
|
+
from roborock_cli._vendor.roborock.device_features import DeviceFeatures
|
|
49
|
+
from roborock_cli._vendor.roborock.devices.cache import Cache, CacheData
|
|
50
|
+
from roborock_cli._vendor.roborock.devices.device import RoborockDevice
|
|
51
|
+
from roborock_cli._vendor.roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
|
|
52
|
+
from roborock_cli._vendor.roborock.devices.traits import Trait
|
|
53
|
+
from roborock_cli._vendor.roborock.devices.traits.b01.q10.vacuum import VacuumTrait
|
|
54
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import V1TraitMixin
|
|
55
|
+
from roborock_cli._vendor.roborock.devices.traits.v1.consumeable import ConsumableAttribute
|
|
56
|
+
from roborock_cli._vendor.roborock.devices.traits.v1.map_content import MapContentTrait
|
|
57
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException, RoborockUnsupportedFeature
|
|
58
|
+
from roborock_cli._vendor.roborock.protocol import MessageParser
|
|
59
|
+
from roborock_cli._vendor.roborock.web_api import RoborockApiClient
|
|
60
|
+
|
|
61
|
+
_LOGGER = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dump_json(obj: Any) -> Any:
|
|
68
|
+
"""Dump an object as JSON."""
|
|
69
|
+
|
|
70
|
+
def custom_json_serializer(obj):
|
|
71
|
+
if isinstance(obj, datetime.time):
|
|
72
|
+
return obj.isoformat()
|
|
73
|
+
raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
|
|
74
|
+
|
|
75
|
+
return json.dumps(obj, default=custom_json_serializer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def async_command(func):
|
|
79
|
+
"""Decorator for async commands that work in both CLI and session modes.
|
|
80
|
+
|
|
81
|
+
The CLI supports two execution modes:
|
|
82
|
+
1. CLI mode: One-off commands that create their own event loop
|
|
83
|
+
2. Session mode: Interactive shell with a persistent background event loop
|
|
84
|
+
|
|
85
|
+
This decorator ensures async commands work correctly in both modes:
|
|
86
|
+
- CLI mode: Uses asyncio.run() to create a new event loop
|
|
87
|
+
- Session mode: Uses the existing session event loop via run_in_session()
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@functools.wraps(func)
|
|
91
|
+
def wrapper(*args, **kwargs):
|
|
92
|
+
ctx = args[0]
|
|
93
|
+
context: RoborockContext = ctx.obj
|
|
94
|
+
|
|
95
|
+
async def run():
|
|
96
|
+
try:
|
|
97
|
+
await func(*args, **kwargs)
|
|
98
|
+
except Exception as err:
|
|
99
|
+
_LOGGER.exception("Uncaught exception in command")
|
|
100
|
+
click.echo(f"Error: {err}", err=True)
|
|
101
|
+
finally:
|
|
102
|
+
if not context.is_session_mode():
|
|
103
|
+
await context.cleanup()
|
|
104
|
+
|
|
105
|
+
if context.is_session_mode():
|
|
106
|
+
# Session mode - run in the persistent loop
|
|
107
|
+
return context.run_in_session(run())
|
|
108
|
+
else:
|
|
109
|
+
# CLI mode - just run normally (asyncio.run handles loop creation)
|
|
110
|
+
return asyncio.run(run())
|
|
111
|
+
|
|
112
|
+
return wrapper
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ConnectionCache(RoborockBase):
|
|
117
|
+
"""Cache for Roborock data.
|
|
118
|
+
|
|
119
|
+
This is used to store data retrieved from the Roborock API, such as user
|
|
120
|
+
data and home data to avoid repeated API calls.
|
|
121
|
+
|
|
122
|
+
This cache is superset of `LoginData` since we used to directly store that
|
|
123
|
+
dataclass, but now we also store additional data.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
user_data: UserData
|
|
127
|
+
email: str
|
|
128
|
+
# TODO: Used new APIs for cache file storage
|
|
129
|
+
cache_data: CacheData | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DeviceConnectionManager:
|
|
133
|
+
"""Manages device connections for both CLI and session modes."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, context: "RoborockContext", loop: asyncio.AbstractEventLoop | None = None):
|
|
136
|
+
self.context = context
|
|
137
|
+
self.loop = loop
|
|
138
|
+
self.device_manager: DeviceManager | None = None
|
|
139
|
+
self._devices: dict[str, RoborockDevice] = {}
|
|
140
|
+
|
|
141
|
+
async def ensure_device_manager(self) -> DeviceManager:
|
|
142
|
+
"""Ensure device manager is initialized."""
|
|
143
|
+
if self.device_manager is None:
|
|
144
|
+
connection_cache = self.context.connection_cache()
|
|
145
|
+
user_params = UserParams(
|
|
146
|
+
username=connection_cache.email,
|
|
147
|
+
user_data=connection_cache.user_data,
|
|
148
|
+
)
|
|
149
|
+
self.device_manager = await create_device_manager(user_params, cache=self.context)
|
|
150
|
+
# Cache devices for quick lookup
|
|
151
|
+
devices = await self.device_manager.get_devices()
|
|
152
|
+
self._devices = {device.duid: device for device in devices}
|
|
153
|
+
return self.device_manager
|
|
154
|
+
|
|
155
|
+
async def get_device(self, device_id: str) -> RoborockDevice:
|
|
156
|
+
"""Get a device by ID, creating connections if needed."""
|
|
157
|
+
await self.ensure_device_manager()
|
|
158
|
+
if device_id not in self._devices:
|
|
159
|
+
raise RoborockException(f"Device {device_id} not found")
|
|
160
|
+
return self._devices[device_id]
|
|
161
|
+
|
|
162
|
+
async def close(self):
|
|
163
|
+
"""Close device manager connections."""
|
|
164
|
+
if self.device_manager:
|
|
165
|
+
await self.device_manager.close()
|
|
166
|
+
self.device_manager = None
|
|
167
|
+
self._devices = {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RoborockContext(Cache):
|
|
171
|
+
"""Context that handles both CLI and session modes internally."""
|
|
172
|
+
|
|
173
|
+
roborock_file = Path("~/.roborock").expanduser()
|
|
174
|
+
roborock_cache_file = Path("~/.roborock.cache").expanduser()
|
|
175
|
+
_connection_cache: ConnectionCache | None = None
|
|
176
|
+
|
|
177
|
+
def __init__(self):
|
|
178
|
+
self.reload()
|
|
179
|
+
self._session_loop: asyncio.AbstractEventLoop | None = None
|
|
180
|
+
self._session_thread: threading.Thread | None = None
|
|
181
|
+
self._device_manager: DeviceConnectionManager | None = None
|
|
182
|
+
|
|
183
|
+
def reload(self):
|
|
184
|
+
if self.roborock_file.is_file():
|
|
185
|
+
with open(self.roborock_file) as f:
|
|
186
|
+
data = json.load(f)
|
|
187
|
+
if data:
|
|
188
|
+
self._connection_cache = ConnectionCache.from_dict(data)
|
|
189
|
+
|
|
190
|
+
def update(self, connection_cache: ConnectionCache):
|
|
191
|
+
data = json.dumps(connection_cache.as_dict(), default=vars, indent=4)
|
|
192
|
+
with open(self.roborock_file, "w") as f:
|
|
193
|
+
f.write(data)
|
|
194
|
+
self.reload()
|
|
195
|
+
|
|
196
|
+
def validate(self):
|
|
197
|
+
if self._connection_cache is None:
|
|
198
|
+
raise RoborockException("You must login first")
|
|
199
|
+
|
|
200
|
+
def connection_cache(self) -> ConnectionCache:
|
|
201
|
+
"""Get the cache data."""
|
|
202
|
+
self.validate()
|
|
203
|
+
return cast(ConnectionCache, self._connection_cache)
|
|
204
|
+
|
|
205
|
+
def start_session_mode(self):
|
|
206
|
+
"""Start session mode with a background event loop."""
|
|
207
|
+
if self._session_loop is not None:
|
|
208
|
+
return # Already started
|
|
209
|
+
|
|
210
|
+
self._session_loop = asyncio.new_event_loop()
|
|
211
|
+
self._session_thread = threading.Thread(target=self._run_session_loop)
|
|
212
|
+
self._session_thread.daemon = True
|
|
213
|
+
self._session_thread.start()
|
|
214
|
+
|
|
215
|
+
def _run_session_loop(self):
|
|
216
|
+
"""Run the session event loop in a background thread."""
|
|
217
|
+
assert self._session_loop is not None # guaranteed by start_session_mode
|
|
218
|
+
asyncio.set_event_loop(self._session_loop)
|
|
219
|
+
self._session_loop.run_forever()
|
|
220
|
+
|
|
221
|
+
def is_session_mode(self) -> bool:
|
|
222
|
+
return self._session_loop is not None
|
|
223
|
+
|
|
224
|
+
def run_in_session(self, coro):
|
|
225
|
+
"""Run a coroutine in the session loop (session mode only)."""
|
|
226
|
+
if not self._session_loop:
|
|
227
|
+
raise RoborockException("Not in session mode")
|
|
228
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._session_loop)
|
|
229
|
+
return future.result()
|
|
230
|
+
|
|
231
|
+
async def get_device_manager(self) -> DeviceConnectionManager:
|
|
232
|
+
"""Get device manager, creating if needed."""
|
|
233
|
+
await self.get_devices()
|
|
234
|
+
if self._device_manager is None:
|
|
235
|
+
self._device_manager = DeviceConnectionManager(self, self._session_loop)
|
|
236
|
+
return self._device_manager
|
|
237
|
+
|
|
238
|
+
async def refresh_devices(self) -> ConnectionCache:
|
|
239
|
+
"""Refresh device data from server (always fetches fresh data)."""
|
|
240
|
+
connection_cache = self.connection_cache()
|
|
241
|
+
client = RoborockApiClient(connection_cache.email)
|
|
242
|
+
home_data = await client.get_home_data_v3(connection_cache.user_data)
|
|
243
|
+
if connection_cache.cache_data is None:
|
|
244
|
+
connection_cache.cache_data = CacheData()
|
|
245
|
+
connection_cache.cache_data.home_data = home_data
|
|
246
|
+
self.update(connection_cache)
|
|
247
|
+
return connection_cache
|
|
248
|
+
|
|
249
|
+
async def get_devices(self) -> ConnectionCache:
|
|
250
|
+
"""Get device data (uses cache if available, fetches if needed)."""
|
|
251
|
+
connection_cache = self.connection_cache()
|
|
252
|
+
if (connection_cache.cache_data is None) or (connection_cache.cache_data.home_data is None):
|
|
253
|
+
connection_cache = await self.refresh_devices()
|
|
254
|
+
return connection_cache
|
|
255
|
+
|
|
256
|
+
async def cleanup(self):
|
|
257
|
+
"""Clean up resources (mainly for session mode)."""
|
|
258
|
+
if self._device_manager:
|
|
259
|
+
await self._device_manager.close()
|
|
260
|
+
self._device_manager = None
|
|
261
|
+
|
|
262
|
+
# Stop session loop if running
|
|
263
|
+
if self._session_loop:
|
|
264
|
+
self._session_loop.call_soon_threadsafe(self._session_loop.stop)
|
|
265
|
+
if self._session_thread:
|
|
266
|
+
self._session_thread.join(timeout=5.0)
|
|
267
|
+
self._session_loop = None
|
|
268
|
+
self._session_thread = None
|
|
269
|
+
|
|
270
|
+
def finish_session(self) -> None:
|
|
271
|
+
"""Finish the session and clean up resources."""
|
|
272
|
+
if self._session_loop:
|
|
273
|
+
future = asyncio.run_coroutine_threadsafe(self.cleanup(), self._session_loop)
|
|
274
|
+
future.result(timeout=5.0)
|
|
275
|
+
|
|
276
|
+
async def get(self) -> CacheData:
|
|
277
|
+
"""Get cached value."""
|
|
278
|
+
_LOGGER.debug("Getting cache data")
|
|
279
|
+
connection_cache = self.connection_cache()
|
|
280
|
+
if connection_cache.cache_data is not None:
|
|
281
|
+
return connection_cache.cache_data
|
|
282
|
+
return CacheData()
|
|
283
|
+
|
|
284
|
+
async def set(self, value: CacheData) -> None:
|
|
285
|
+
"""Set value in the cache."""
|
|
286
|
+
_LOGGER.debug("Setting cache data")
|
|
287
|
+
connection_cache = self.connection_cache()
|
|
288
|
+
connection_cache.cache_data = value
|
|
289
|
+
self.update(connection_cache)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@click.option("-d", "--debug", default=False, count=True)
|
|
293
|
+
@click.version_option(package_name="python-roborock")
|
|
294
|
+
@click.group()
|
|
295
|
+
@click.pass_context
|
|
296
|
+
def cli(ctx, debug: int):
|
|
297
|
+
logging_config: dict[str, Any] = {"level": logging.DEBUG if debug > 0 else logging.INFO}
|
|
298
|
+
logging.basicConfig(**logging_config) # type: ignore
|
|
299
|
+
ctx.obj = RoborockContext()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@click.command()
|
|
303
|
+
@click.option("--email", required=True)
|
|
304
|
+
@click.option(
|
|
305
|
+
"--reauth",
|
|
306
|
+
is_flag=True,
|
|
307
|
+
default=False,
|
|
308
|
+
help="Re-authenticate even if cached credentials exist.",
|
|
309
|
+
)
|
|
310
|
+
@click.option(
|
|
311
|
+
"--password",
|
|
312
|
+
required=False,
|
|
313
|
+
help="Password for the Roborock account. If not provided, an email code will be requested.",
|
|
314
|
+
)
|
|
315
|
+
@click.pass_context
|
|
316
|
+
@async_command
|
|
317
|
+
async def login(ctx, email, password, reauth):
|
|
318
|
+
"""Login to Roborock account."""
|
|
319
|
+
context: RoborockContext = ctx.obj
|
|
320
|
+
if not reauth:
|
|
321
|
+
try:
|
|
322
|
+
context.validate()
|
|
323
|
+
_LOGGER.info("Already logged in")
|
|
324
|
+
return
|
|
325
|
+
except RoborockException:
|
|
326
|
+
pass
|
|
327
|
+
client = RoborockApiClient(email)
|
|
328
|
+
if password is not None:
|
|
329
|
+
user_data = await client.pass_login(password)
|
|
330
|
+
else:
|
|
331
|
+
print(f"Requesting code for {email}")
|
|
332
|
+
await client.request_code()
|
|
333
|
+
code = click.prompt("A code has been sent to your email, please enter the code", type=str)
|
|
334
|
+
user_data = await client.code_login(code)
|
|
335
|
+
print("Login successful")
|
|
336
|
+
context.update(ConnectionCache(user_data=user_data, email=email))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _shell_session_finished(ctx):
|
|
340
|
+
"""Callback for when shell session finishes."""
|
|
341
|
+
context: RoborockContext = ctx.obj
|
|
342
|
+
try:
|
|
343
|
+
context.finish_session()
|
|
344
|
+
except Exception as e:
|
|
345
|
+
click.echo(f"Error during cleanup: {e}", err=True)
|
|
346
|
+
click.echo("Session finished")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@click_shell.shell(
|
|
350
|
+
prompt="roborock> ",
|
|
351
|
+
on_finished=_shell_session_finished,
|
|
352
|
+
)
|
|
353
|
+
@click.pass_context
|
|
354
|
+
def session(ctx):
|
|
355
|
+
"""Start an interactive session."""
|
|
356
|
+
context: RoborockContext = ctx.obj
|
|
357
|
+
# Start session mode with background loop
|
|
358
|
+
context.start_session_mode()
|
|
359
|
+
context.run_in_session(context.get_device_manager())
|
|
360
|
+
click.echo("OK")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@session.command()
|
|
364
|
+
@click.pass_context
|
|
365
|
+
@async_command
|
|
366
|
+
async def discover(ctx):
|
|
367
|
+
"""Discover devices."""
|
|
368
|
+
context: RoborockContext = ctx.obj
|
|
369
|
+
# Use the explicit refresh method for the discover command
|
|
370
|
+
connection_cache = await context.refresh_devices()
|
|
371
|
+
|
|
372
|
+
home_data = connection_cache.cache_data.home_data
|
|
373
|
+
click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@session.command()
|
|
377
|
+
@click.pass_context
|
|
378
|
+
@async_command
|
|
379
|
+
async def list_devices(ctx):
|
|
380
|
+
context: RoborockContext = ctx.obj
|
|
381
|
+
connection_cache = await context.get_devices()
|
|
382
|
+
|
|
383
|
+
home_data = connection_cache.cache_data.home_data
|
|
384
|
+
|
|
385
|
+
device_name_id = {device.name: device.duid for device in home_data.get_all_devices()}
|
|
386
|
+
click.echo(json.dumps(device_name_id, indent=4))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@click.command()
|
|
390
|
+
@click.option("--device_id", required=True)
|
|
391
|
+
@click.pass_context
|
|
392
|
+
@async_command
|
|
393
|
+
async def list_scenes(ctx, device_id):
|
|
394
|
+
context: RoborockContext = ctx.obj
|
|
395
|
+
connection_cache = await context.get_devices()
|
|
396
|
+
|
|
397
|
+
client = RoborockApiClient(connection_cache.email)
|
|
398
|
+
scenes = await client.get_scenes(connection_cache.user_data, device_id)
|
|
399
|
+
output_list = []
|
|
400
|
+
for scene in scenes:
|
|
401
|
+
output_list.append(scene.as_dict())
|
|
402
|
+
click.echo(json.dumps(output_list, indent=4))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@click.command()
|
|
406
|
+
@click.option("--scene_id", required=True)
|
|
407
|
+
@click.pass_context
|
|
408
|
+
@async_command
|
|
409
|
+
async def execute_scene(ctx, scene_id):
|
|
410
|
+
context: RoborockContext = ctx.obj
|
|
411
|
+
connection_cache = await context.get_devices()
|
|
412
|
+
|
|
413
|
+
client = RoborockApiClient(connection_cache.email)
|
|
414
|
+
await client.execute_scene(connection_cache.user_data, scene_id)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait:
|
|
418
|
+
device_manager = await context.get_device_manager()
|
|
419
|
+
device = await device_manager.get_device(device_id)
|
|
420
|
+
if device.v1_properties is None:
|
|
421
|
+
raise RoborockUnsupportedFeature(f"Device {device.name} does not support V1 protocol")
|
|
422
|
+
await device.v1_properties.discover_features()
|
|
423
|
+
trait = display_func(device.v1_properties)
|
|
424
|
+
if trait is None:
|
|
425
|
+
raise RoborockUnsupportedFeature("Trait not supported by device")
|
|
426
|
+
await trait.refresh()
|
|
427
|
+
return trait
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _display_v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], Trait]) -> None:
|
|
431
|
+
try:
|
|
432
|
+
trait = await _v1_trait(context, device_id, display_func)
|
|
433
|
+
except RoborockUnsupportedFeature:
|
|
434
|
+
click.echo("Feature not supported by device")
|
|
435
|
+
return
|
|
436
|
+
except RoborockException as e:
|
|
437
|
+
click.echo(f"Error: {e}")
|
|
438
|
+
return
|
|
439
|
+
click.echo(dump_json(trait.as_dict()))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
|
|
443
|
+
"""Get VacuumTrait from Q10 device."""
|
|
444
|
+
device_manager = await context.get_device_manager()
|
|
445
|
+
device = await device_manager.get_device(device_id)
|
|
446
|
+
if device.b01_q10_properties is None:
|
|
447
|
+
raise RoborockUnsupportedFeature("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
448
|
+
return device.b01_q10_properties.vacuum
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@session.command()
|
|
452
|
+
@click.option("--device_id", required=True)
|
|
453
|
+
@click.pass_context
|
|
454
|
+
@async_command
|
|
455
|
+
async def status(ctx, device_id: str):
|
|
456
|
+
"""Get device status."""
|
|
457
|
+
context: RoborockContext = ctx.obj
|
|
458
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.status)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@session.command()
|
|
462
|
+
@click.option("--device_id", required=True)
|
|
463
|
+
@click.pass_context
|
|
464
|
+
@async_command
|
|
465
|
+
async def clean_summary(ctx, device_id: str):
|
|
466
|
+
"""Get device clean summary."""
|
|
467
|
+
context: RoborockContext = ctx.obj
|
|
468
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.clean_summary)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@session.command()
|
|
472
|
+
@click.option("--device_id", required=True)
|
|
473
|
+
@click.pass_context
|
|
474
|
+
@async_command
|
|
475
|
+
async def clean_record(ctx, device_id: str):
|
|
476
|
+
"""Get device last clean record."""
|
|
477
|
+
context: RoborockContext = ctx.obj
|
|
478
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.clean_record)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@session.command()
|
|
482
|
+
@click.option("--device_id", required=True)
|
|
483
|
+
@click.pass_context
|
|
484
|
+
@async_command
|
|
485
|
+
async def dock_summary(ctx, device_id: str):
|
|
486
|
+
"""Get device dock summary."""
|
|
487
|
+
context: RoborockContext = ctx.obj
|
|
488
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.dock_summary)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@session.command()
|
|
492
|
+
@click.option("--device_id", required=True)
|
|
493
|
+
@click.pass_context
|
|
494
|
+
@async_command
|
|
495
|
+
async def volume(ctx, device_id: str):
|
|
496
|
+
"""Get device volume."""
|
|
497
|
+
context: RoborockContext = ctx.obj
|
|
498
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.sound_volume)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@session.command()
|
|
502
|
+
@click.option("--device_id", required=True)
|
|
503
|
+
@click.option("--volume", required=True, type=int)
|
|
504
|
+
@click.pass_context
|
|
505
|
+
@async_command
|
|
506
|
+
async def set_volume(ctx, device_id: str, volume: int):
|
|
507
|
+
"""Set the devicevolume."""
|
|
508
|
+
context: RoborockContext = ctx.obj
|
|
509
|
+
volume_trait = await _v1_trait(context, device_id, lambda v1: v1.sound_volume)
|
|
510
|
+
await volume_trait.set_volume(volume)
|
|
511
|
+
click.echo(f"Set Device {device_id} volume to {volume}")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@session.command()
|
|
515
|
+
@click.option("--device_id", required=True)
|
|
516
|
+
@click.pass_context
|
|
517
|
+
@async_command
|
|
518
|
+
async def maps(ctx, device_id: str):
|
|
519
|
+
"""Get device maps info."""
|
|
520
|
+
context: RoborockContext = ctx.obj
|
|
521
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@session.command()
|
|
525
|
+
@click.option("--device_id", required=True)
|
|
526
|
+
@click.option("--output-file", required=True, help="Path to save the map image.")
|
|
527
|
+
@click.pass_context
|
|
528
|
+
@async_command
|
|
529
|
+
async def map_image(ctx, device_id: str, output_file: str):
|
|
530
|
+
"""Get device map image and save it to a file."""
|
|
531
|
+
context: RoborockContext = ctx.obj
|
|
532
|
+
trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
|
|
533
|
+
if trait.image_content:
|
|
534
|
+
with open(output_file, "wb") as f:
|
|
535
|
+
f.write(trait.image_content)
|
|
536
|
+
click.echo(f"Map image saved to {output_file}")
|
|
537
|
+
else:
|
|
538
|
+
click.echo("No map image content available.")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@session.command()
|
|
542
|
+
@click.option("--device_id", required=True)
|
|
543
|
+
@click.option("--include_path", is_flag=True, default=False, help="Include path data in the output.")
|
|
544
|
+
@click.pass_context
|
|
545
|
+
@async_command
|
|
546
|
+
async def map_data(ctx, device_id: str, include_path: bool):
|
|
547
|
+
"""Get parsed map data as JSON."""
|
|
548
|
+
context: RoborockContext = ctx.obj
|
|
549
|
+
trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
|
|
550
|
+
if not trait.map_data:
|
|
551
|
+
click.echo("No parsed map data available.")
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
# Pick some parts of the map data to display.
|
|
555
|
+
data_summary = {
|
|
556
|
+
"charger": trait.map_data.charger.as_dict() if trait.map_data.charger else None,
|
|
557
|
+
"image_size": trait.map_data.image.data.size if trait.map_data.image else None,
|
|
558
|
+
"vacuum_position": trait.map_data.vacuum_position.as_dict() if trait.map_data.vacuum_position else None,
|
|
559
|
+
"calibration": trait.map_data.calibration(),
|
|
560
|
+
"zones": [z.as_dict() for z in trait.map_data.zones or ()],
|
|
561
|
+
}
|
|
562
|
+
if include_path and trait.map_data.path:
|
|
563
|
+
data_summary["path"] = trait.map_data.path.as_dict()
|
|
564
|
+
click.echo(dump_json(data_summary))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@session.command()
|
|
568
|
+
@click.option("--device_id", required=True)
|
|
569
|
+
@click.pass_context
|
|
570
|
+
@async_command
|
|
571
|
+
async def consumables(ctx, device_id: str):
|
|
572
|
+
"""Get device consumables."""
|
|
573
|
+
context: RoborockContext = ctx.obj
|
|
574
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.consumables)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@session.command()
|
|
578
|
+
@click.option("--device_id", required=True)
|
|
579
|
+
@click.option("--consumable", required=True, type=click.Choice([e.value for e in ConsumableAttribute]))
|
|
580
|
+
@click.pass_context
|
|
581
|
+
@async_command
|
|
582
|
+
async def reset_consumable(ctx, device_id: str, consumable: str):
|
|
583
|
+
"""Reset a specific consumable attribute."""
|
|
584
|
+
context: RoborockContext = ctx.obj
|
|
585
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.consumables)
|
|
586
|
+
attribute = ConsumableAttribute.from_str(consumable)
|
|
587
|
+
await trait.reset_consumable(attribute)
|
|
588
|
+
click.echo(f"Reset {consumable} for device {device_id}")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@session.command()
|
|
592
|
+
@click.option("--device_id", required=True)
|
|
593
|
+
@click.option("--enabled", type=bool, help="Enable (True) or disable (False) the child lock.")
|
|
594
|
+
@click.pass_context
|
|
595
|
+
@async_command
|
|
596
|
+
async def child_lock(ctx, device_id: str, enabled: bool | None):
|
|
597
|
+
"""Get device child lock status."""
|
|
598
|
+
context: RoborockContext = ctx.obj
|
|
599
|
+
try:
|
|
600
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.child_lock)
|
|
601
|
+
except RoborockUnsupportedFeature:
|
|
602
|
+
click.echo("Feature not supported by device")
|
|
603
|
+
return
|
|
604
|
+
if enabled is not None:
|
|
605
|
+
if enabled:
|
|
606
|
+
await trait.enable()
|
|
607
|
+
else:
|
|
608
|
+
await trait.disable()
|
|
609
|
+
click.echo(f"Set child lock to {enabled} for device {device_id}")
|
|
610
|
+
await trait.refresh()
|
|
611
|
+
|
|
612
|
+
click.echo(dump_json(trait.as_dict()))
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@session.command()
|
|
616
|
+
@click.option("--device_id", required=True)
|
|
617
|
+
@click.option("--enabled", type=bool, help="Enable (True) or disable (False) the DND status.")
|
|
618
|
+
@click.pass_context
|
|
619
|
+
@async_command
|
|
620
|
+
async def dnd(ctx, device_id: str, enabled: bool | None):
|
|
621
|
+
"""Get Do Not Disturb Timer status."""
|
|
622
|
+
context: RoborockContext = ctx.obj
|
|
623
|
+
try:
|
|
624
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.dnd)
|
|
625
|
+
except RoborockUnsupportedFeature:
|
|
626
|
+
click.echo("Feature not supported by device")
|
|
627
|
+
return
|
|
628
|
+
if enabled is not None:
|
|
629
|
+
if enabled:
|
|
630
|
+
await trait.enable()
|
|
631
|
+
else:
|
|
632
|
+
await trait.disable()
|
|
633
|
+
click.echo(f"Set DND to {enabled} for device {device_id}")
|
|
634
|
+
await trait.refresh()
|
|
635
|
+
|
|
636
|
+
click.echo(dump_json(trait.as_dict()))
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@session.command()
|
|
640
|
+
@click.option("--device_id", required=True)
|
|
641
|
+
@click.option("--enabled", required=False, type=bool, help="Enable (True) or disable (False) the Flow LED.")
|
|
642
|
+
@click.pass_context
|
|
643
|
+
@async_command
|
|
644
|
+
async def flow_led_status(ctx, device_id: str, enabled: bool | None):
|
|
645
|
+
"""Get device Flow LED status."""
|
|
646
|
+
context: RoborockContext = ctx.obj
|
|
647
|
+
try:
|
|
648
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.flow_led_status)
|
|
649
|
+
except RoborockUnsupportedFeature:
|
|
650
|
+
click.echo("Feature not supported by device")
|
|
651
|
+
return
|
|
652
|
+
if enabled is not None:
|
|
653
|
+
if enabled:
|
|
654
|
+
await trait.enable()
|
|
655
|
+
else:
|
|
656
|
+
await trait.disable()
|
|
657
|
+
click.echo(f"Set Flow LED to {enabled} for device {device_id}")
|
|
658
|
+
await trait.refresh()
|
|
659
|
+
|
|
660
|
+
click.echo(dump_json(trait.as_dict()))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@session.command()
|
|
664
|
+
@click.option("--device_id", required=True)
|
|
665
|
+
@click.option("--enabled", required=False, type=bool, help="Enable (True) or disable (False) the LED.")
|
|
666
|
+
@click.pass_context
|
|
667
|
+
@async_command
|
|
668
|
+
async def led_status(ctx, device_id: str, enabled: bool | None):
|
|
669
|
+
"""Get device LED status."""
|
|
670
|
+
context: RoborockContext = ctx.obj
|
|
671
|
+
try:
|
|
672
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.led_status)
|
|
673
|
+
except RoborockUnsupportedFeature:
|
|
674
|
+
click.echo("Feature not supported by device")
|
|
675
|
+
return
|
|
676
|
+
if enabled is not None:
|
|
677
|
+
if enabled:
|
|
678
|
+
await trait.enable()
|
|
679
|
+
else:
|
|
680
|
+
await trait.disable()
|
|
681
|
+
click.echo(f"Set LED Status to {enabled} for device {device_id}")
|
|
682
|
+
await trait.refresh()
|
|
683
|
+
|
|
684
|
+
click.echo(dump_json(trait.as_dict()))
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@session.command()
|
|
688
|
+
@click.option("--device_id", required=True)
|
|
689
|
+
@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False) the child lock.")
|
|
690
|
+
@click.pass_context
|
|
691
|
+
@async_command
|
|
692
|
+
async def set_child_lock(ctx, device_id: str, enabled: bool):
|
|
693
|
+
"""Set the child lock status."""
|
|
694
|
+
context: RoborockContext = ctx.obj
|
|
695
|
+
trait = await _v1_trait(context, device_id, lambda v1: v1.child_lock)
|
|
696
|
+
await trait.set_child_lock(enabled)
|
|
697
|
+
status = "enabled" if enabled else "disabled"
|
|
698
|
+
click.echo(f"Child lock {status} for device {device_id}")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@session.command()
|
|
702
|
+
@click.option("--device_id", required=True)
|
|
703
|
+
@click.pass_context
|
|
704
|
+
@async_command
|
|
705
|
+
async def rooms(ctx, device_id: str):
|
|
706
|
+
"""Get device room mapping info."""
|
|
707
|
+
context: RoborockContext = ctx.obj
|
|
708
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.rooms)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@session.command()
|
|
712
|
+
@click.option("--device_id", required=True)
|
|
713
|
+
@click.pass_context
|
|
714
|
+
@async_command
|
|
715
|
+
async def features(ctx, device_id: str):
|
|
716
|
+
"""Get device room mapping info."""
|
|
717
|
+
context: RoborockContext = ctx.obj
|
|
718
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.device_features)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@session.command()
|
|
722
|
+
@click.option("--device_id", required=True)
|
|
723
|
+
@click.option("--refresh", is_flag=True, default=False, help="Refresh status before discovery.")
|
|
724
|
+
@click.pass_context
|
|
725
|
+
@async_command
|
|
726
|
+
async def home(ctx, device_id: str, refresh: bool):
|
|
727
|
+
"""Discover and cache home layout (maps and rooms)."""
|
|
728
|
+
context: RoborockContext = ctx.obj
|
|
729
|
+
device_manager = await context.get_device_manager()
|
|
730
|
+
device = await device_manager.get_device(device_id)
|
|
731
|
+
if device.v1_properties is None:
|
|
732
|
+
raise RoborockException(f"Device {device.name} does not support V1 protocol")
|
|
733
|
+
|
|
734
|
+
# Ensure we have the latest status before discovery
|
|
735
|
+
await device.v1_properties.status.refresh()
|
|
736
|
+
|
|
737
|
+
home_trait = device.v1_properties.home
|
|
738
|
+
await home_trait.discover_home()
|
|
739
|
+
if refresh:
|
|
740
|
+
await home_trait.refresh()
|
|
741
|
+
|
|
742
|
+
# Display the discovered home cache
|
|
743
|
+
if home_trait.home_map_info:
|
|
744
|
+
cache_summary = {
|
|
745
|
+
map_flag: {
|
|
746
|
+
"name": map_data.name,
|
|
747
|
+
"room_count": len(map_data.rooms),
|
|
748
|
+
"rooms": [{"segment_id": room.segment_id, "name": room.name} for room in map_data.rooms],
|
|
749
|
+
}
|
|
750
|
+
for map_flag, map_data in home_trait.home_map_info.items()
|
|
751
|
+
}
|
|
752
|
+
click.echo(dump_json(cache_summary))
|
|
753
|
+
else:
|
|
754
|
+
click.echo("No maps discovered")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@session.command()
|
|
758
|
+
@click.option("--device_id", required=True)
|
|
759
|
+
@click.pass_context
|
|
760
|
+
@async_command
|
|
761
|
+
async def network_info(ctx, device_id: str):
|
|
762
|
+
"""Get device network information."""
|
|
763
|
+
context: RoborockContext = ctx.obj
|
|
764
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.network_info)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _parse_b01_q10_command(cmd: str) -> B01_Q10_DP:
|
|
768
|
+
"""Parse B01_Q10 command from either enum name or value."""
|
|
769
|
+
try:
|
|
770
|
+
return B01_Q10_DP(int(cmd))
|
|
771
|
+
except ValueError:
|
|
772
|
+
try:
|
|
773
|
+
return B01_Q10_DP.from_name(cmd)
|
|
774
|
+
except ValueError:
|
|
775
|
+
try:
|
|
776
|
+
return B01_Q10_DP.from_value(cmd)
|
|
777
|
+
except ValueError:
|
|
778
|
+
pass
|
|
779
|
+
raise RoborockException(f"Invalid command {cmd} for B01_Q10 device")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@click.command()
|
|
783
|
+
@click.option("--device_id", required=True)
|
|
784
|
+
@click.option("--cmd", required=True)
|
|
785
|
+
@click.option("--params", required=False)
|
|
786
|
+
@click.pass_context
|
|
787
|
+
@async_command
|
|
788
|
+
async def command(ctx, cmd, device_id, params):
|
|
789
|
+
context: RoborockContext = ctx.obj
|
|
790
|
+
device_manager = await context.get_device_manager()
|
|
791
|
+
device = await device_manager.get_device(device_id)
|
|
792
|
+
if device.v1_properties is not None:
|
|
793
|
+
command_trait: Trait = device.v1_properties.command
|
|
794
|
+
result = await command_trait.send(cmd, json.loads(params) if params is not None else None)
|
|
795
|
+
if result:
|
|
796
|
+
click.echo(dump_json(result))
|
|
797
|
+
elif device.b01_q10_properties is not None:
|
|
798
|
+
cmd_value = _parse_b01_q10_command(cmd)
|
|
799
|
+
command_trait: Trait = device.b01_q10_properties.command
|
|
800
|
+
await command_trait.send(cmd_value, json.loads(params) if params is not None else None)
|
|
801
|
+
click.echo("Command sent successfully; Enable debug logging (-d) to see responses.")
|
|
802
|
+
# Q10 commands don't have a specific time to respond, so wait a bit and log
|
|
803
|
+
await asyncio.sleep(5)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@click.command()
|
|
807
|
+
@click.option("--local_key", required=True)
|
|
808
|
+
@click.option("--device_ip", required=True)
|
|
809
|
+
@click.option("--file", required=False)
|
|
810
|
+
@click.pass_context
|
|
811
|
+
@async_command
|
|
812
|
+
async def parser(_, local_key, device_ip, file):
|
|
813
|
+
file_provided = file is not None
|
|
814
|
+
if file_provided:
|
|
815
|
+
capture = FileCapture(file)
|
|
816
|
+
else:
|
|
817
|
+
_LOGGER.info("Listen for interface rvi0 since no file was provided")
|
|
818
|
+
capture = LiveCapture(interface="rvi0")
|
|
819
|
+
buffer = {"data": b""}
|
|
820
|
+
|
|
821
|
+
def on_package(packet: Packet):
|
|
822
|
+
if hasattr(packet, "ip"):
|
|
823
|
+
if packet.transport_layer == "TCP" and (packet.ip.dst == device_ip or packet.ip.src == device_ip):
|
|
824
|
+
if hasattr(packet, "DATA"):
|
|
825
|
+
if hasattr(packet.DATA, "data"):
|
|
826
|
+
if packet.ip.dst == device_ip:
|
|
827
|
+
try:
|
|
828
|
+
f, buffer["data"] = MessageParser.parse(
|
|
829
|
+
buffer["data"] + bytes.fromhex(packet.DATA.data),
|
|
830
|
+
local_key,
|
|
831
|
+
)
|
|
832
|
+
print(f"Received request: {f}")
|
|
833
|
+
except BaseException as e:
|
|
834
|
+
print(e)
|
|
835
|
+
pass
|
|
836
|
+
elif packet.ip.src == device_ip:
|
|
837
|
+
try:
|
|
838
|
+
f, buffer["data"] = MessageParser.parse(
|
|
839
|
+
buffer["data"] + bytes.fromhex(packet.DATA.data),
|
|
840
|
+
local_key,
|
|
841
|
+
)
|
|
842
|
+
print(f"Received response: {f}")
|
|
843
|
+
except BaseException as e:
|
|
844
|
+
print(e)
|
|
845
|
+
pass
|
|
846
|
+
|
|
847
|
+
try:
|
|
848
|
+
await capture.packets_from_tshark(on_package, close_tshark=not file_provided)
|
|
849
|
+
except UnknownInterfaceException:
|
|
850
|
+
raise RoborockException(
|
|
851
|
+
"You need to run 'rvictl -s XXXXXXXX-XXXXXXXXXXXXXXXX' first, with an iPhone connected to usb port"
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _parse_diagnostic_file(diagnostic_path: Path) -> dict[str, dict[str, Any]]:
|
|
856
|
+
"""Parse device info from a Home Assistant diagnostic file.
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
diagnostic_path: Path to the diagnostic JSON file.
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
A dictionary mapping model names to device info dictionaries.
|
|
863
|
+
"""
|
|
864
|
+
with open(diagnostic_path, encoding="utf-8") as f:
|
|
865
|
+
diagnostic_data = json.load(f)
|
|
866
|
+
|
|
867
|
+
all_products_data: dict[str, dict[str, Any]] = {}
|
|
868
|
+
|
|
869
|
+
# Navigate to coordinators in the diagnostic data
|
|
870
|
+
coordinators = diagnostic_data.get("data", {}).get("coordinators", {})
|
|
871
|
+
if not coordinators:
|
|
872
|
+
return all_products_data
|
|
873
|
+
|
|
874
|
+
for coordinator_data in coordinators.values():
|
|
875
|
+
device_data = coordinator_data.get("device", {})
|
|
876
|
+
product_data = coordinator_data.get("product", {})
|
|
877
|
+
|
|
878
|
+
model = product_data.get("model")
|
|
879
|
+
if not model or model in all_products_data:
|
|
880
|
+
continue
|
|
881
|
+
# Derive product nickname from model
|
|
882
|
+
short_model = model.split(".")[-1]
|
|
883
|
+
product_nickname = SHORT_MODEL_TO_ENUM.get(short_model)
|
|
884
|
+
|
|
885
|
+
current_product_data: dict[str, Any] = {
|
|
886
|
+
"protocol_version": device_data.get("pv"),
|
|
887
|
+
"product_nickname": product_nickname.name if product_nickname else "Unknown",
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
# Get feature info from the device_features trait (preferred location)
|
|
891
|
+
traits_data = coordinator_data.get("traits", {})
|
|
892
|
+
device_features = traits_data.get("device_features", {})
|
|
893
|
+
|
|
894
|
+
# newFeatureInfo is the integer
|
|
895
|
+
new_feature_info = device_features.get("newFeatureInfo")
|
|
896
|
+
if new_feature_info is not None:
|
|
897
|
+
current_product_data["new_feature_info"] = new_feature_info
|
|
898
|
+
|
|
899
|
+
# newFeatureInfoStr is the hex string
|
|
900
|
+
new_feature_info_str = device_features.get("newFeatureInfoStr")
|
|
901
|
+
if new_feature_info_str:
|
|
902
|
+
current_product_data["new_feature_info_str"] = new_feature_info_str
|
|
903
|
+
|
|
904
|
+
# featureInfo is the list of feature codes
|
|
905
|
+
feature_info = device_features.get("featureInfo")
|
|
906
|
+
if feature_info:
|
|
907
|
+
current_product_data["feature_info"] = feature_info
|
|
908
|
+
|
|
909
|
+
# Build product dict from diagnostic product data
|
|
910
|
+
if product_data:
|
|
911
|
+
# Convert to the format expected by device_info.yaml
|
|
912
|
+
product_dict: dict[str, Any] = {}
|
|
913
|
+
for key in ["id", "name", "model", "category", "capability", "schema"]:
|
|
914
|
+
if key in product_data:
|
|
915
|
+
product_dict[key] = product_data[key]
|
|
916
|
+
if product_dict:
|
|
917
|
+
current_product_data["product"] = product_dict
|
|
918
|
+
|
|
919
|
+
all_products_data[model] = current_product_data
|
|
920
|
+
|
|
921
|
+
return all_products_data
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@click.command()
|
|
925
|
+
@click.option(
|
|
926
|
+
"--record",
|
|
927
|
+
is_flag=True,
|
|
928
|
+
default=False,
|
|
929
|
+
help="Save new device info entries to the YAML file.",
|
|
930
|
+
)
|
|
931
|
+
@click.option(
|
|
932
|
+
"--device-info-file",
|
|
933
|
+
default="device_info.yaml",
|
|
934
|
+
help="Path to the YAML file with device and product data.",
|
|
935
|
+
)
|
|
936
|
+
@click.option(
|
|
937
|
+
"--diagnostic-file",
|
|
938
|
+
default=None,
|
|
939
|
+
help="Path to a Home Assistant diagnostic JSON file to parse instead of connecting to devices.",
|
|
940
|
+
)
|
|
941
|
+
@click.pass_context
|
|
942
|
+
@async_command
|
|
943
|
+
async def get_device_info(ctx: click.Context, record: bool, device_info_file: str, diagnostic_file: str | None):
|
|
944
|
+
"""
|
|
945
|
+
Connects to devices and prints their feature information in YAML format.
|
|
946
|
+
|
|
947
|
+
Can also parse device info from a Home Assistant diagnostic file using --diagnostic-file.
|
|
948
|
+
"""
|
|
949
|
+
context: RoborockContext = ctx.obj
|
|
950
|
+
device_info_path = Path(device_info_file)
|
|
951
|
+
existing_device_info: dict[str, Any] = {}
|
|
952
|
+
|
|
953
|
+
# Load existing device info if recording
|
|
954
|
+
if record:
|
|
955
|
+
click.echo(f"Using device info file: {device_info_path.resolve()}")
|
|
956
|
+
if device_info_path.exists():
|
|
957
|
+
with open(device_info_path, encoding="utf-8") as f:
|
|
958
|
+
data = yaml.safe_load(f)
|
|
959
|
+
if isinstance(data, dict):
|
|
960
|
+
existing_device_info = data
|
|
961
|
+
|
|
962
|
+
# Parse from diagnostic file if provided
|
|
963
|
+
if diagnostic_file:
|
|
964
|
+
diagnostic_path = Path(diagnostic_file)
|
|
965
|
+
if not diagnostic_path.exists():
|
|
966
|
+
click.echo(f"Diagnostic file not found: {diagnostic_path}", err=True)
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
click.echo(f"Parsing diagnostic file: {diagnostic_path.resolve()}")
|
|
970
|
+
all_products_data = _parse_diagnostic_file(diagnostic_path)
|
|
971
|
+
|
|
972
|
+
if not all_products_data:
|
|
973
|
+
click.echo("No device data found in diagnostic file.")
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
click.echo(f"Found {len(all_products_data)} device(s) in diagnostic file.")
|
|
977
|
+
|
|
978
|
+
else:
|
|
979
|
+
click.echo("Discovering devices...")
|
|
980
|
+
|
|
981
|
+
if record:
|
|
982
|
+
connection_cache = await context.get_devices()
|
|
983
|
+
home_data = connection_cache.cache_data.home_data if connection_cache.cache_data else None
|
|
984
|
+
if home_data is None:
|
|
985
|
+
click.echo("Home data not available.", err=True)
|
|
986
|
+
return
|
|
987
|
+
|
|
988
|
+
device_connection_manager = await context.get_device_manager()
|
|
989
|
+
device_manager = await device_connection_manager.ensure_device_manager()
|
|
990
|
+
devices = await device_manager.get_devices()
|
|
991
|
+
if not devices:
|
|
992
|
+
click.echo("No devices found.")
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
click.echo(f"Found {len(devices)} devices. Fetching data...")
|
|
996
|
+
|
|
997
|
+
all_products_data = {}
|
|
998
|
+
|
|
999
|
+
for device in devices:
|
|
1000
|
+
click.echo(f" - Processing {device.name} ({device.duid})")
|
|
1001
|
+
|
|
1002
|
+
model = device.product.model
|
|
1003
|
+
if model in all_products_data:
|
|
1004
|
+
click.echo(f" - Skipping duplicate model {model}")
|
|
1005
|
+
continue
|
|
1006
|
+
|
|
1007
|
+
current_product_data = {
|
|
1008
|
+
"protocol_version": device.device_info.pv,
|
|
1009
|
+
"product_nickname": device.product.product_nickname.name
|
|
1010
|
+
if device.product.product_nickname
|
|
1011
|
+
else "Unknown",
|
|
1012
|
+
}
|
|
1013
|
+
if device.v1_properties is not None:
|
|
1014
|
+
try:
|
|
1015
|
+
result: list[dict[str, Any]] = await device.v1_properties.command.send(
|
|
1016
|
+
RoborockCommand.APP_GET_INIT_STATUS
|
|
1017
|
+
)
|
|
1018
|
+
except Exception as e:
|
|
1019
|
+
click.echo(f" - Error processing device {device.name}: {e}", err=True)
|
|
1020
|
+
continue
|
|
1021
|
+
init_status_result = result[0] if result else {}
|
|
1022
|
+
current_product_data.update(
|
|
1023
|
+
{
|
|
1024
|
+
"new_feature_info": init_status_result.get("new_feature_info"),
|
|
1025
|
+
"new_feature_info_str": init_status_result.get("new_feature_info_str"),
|
|
1026
|
+
"feature_info": init_status_result.get("feature_info"),
|
|
1027
|
+
}
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
product_data = device.product.as_dict()
|
|
1031
|
+
if product_data:
|
|
1032
|
+
current_product_data["product"] = product_data
|
|
1033
|
+
|
|
1034
|
+
all_products_data[model] = current_product_data
|
|
1035
|
+
|
|
1036
|
+
if record:
|
|
1037
|
+
if not all_products_data:
|
|
1038
|
+
click.echo("No device info updates needed.")
|
|
1039
|
+
return
|
|
1040
|
+
updated_device_info = {**existing_device_info, **all_products_data}
|
|
1041
|
+
device_info_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1042
|
+
ordered_data = dict(sorted(updated_device_info.items(), key=lambda item: item[0]))
|
|
1043
|
+
with open(device_info_path, "w", encoding="utf-8") as f:
|
|
1044
|
+
yaml.safe_dump(ordered_data, f, sort_keys=False)
|
|
1045
|
+
click.echo(f"Updated {device_info_path}.")
|
|
1046
|
+
click.echo("\n--- Device Info Updates ---\n")
|
|
1047
|
+
click.echo(yaml.safe_dump(all_products_data, sort_keys=False))
|
|
1048
|
+
return
|
|
1049
|
+
|
|
1050
|
+
if all_products_data:
|
|
1051
|
+
click.echo("\n--- Device Information (copy to your YAML file) ---\n")
|
|
1052
|
+
click.echo(yaml.dump(all_products_data, sort_keys=False))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
@click.command()
|
|
1056
|
+
@click.option("--data-file", default="../device_info.yaml", help="Path to the YAML file with device feature data.")
|
|
1057
|
+
@click.option("--output-file", default="../SUPPORTED_FEATURES.md", help="Path to the output markdown file.")
|
|
1058
|
+
def update_docs(data_file: str, output_file: str):
|
|
1059
|
+
"""
|
|
1060
|
+
Generates a markdown file by processing raw feature data from a YAML file.
|
|
1061
|
+
"""
|
|
1062
|
+
data_path = Path(data_file)
|
|
1063
|
+
output_path = Path(output_file)
|
|
1064
|
+
|
|
1065
|
+
if not data_path.exists():
|
|
1066
|
+
click.echo(f"Error: Data file not found at '{data_path}'", err=True)
|
|
1067
|
+
return
|
|
1068
|
+
|
|
1069
|
+
click.echo(f"Loading data from {data_path}...")
|
|
1070
|
+
with open(data_path, encoding="utf-8") as f:
|
|
1071
|
+
product_data_from_yaml = yaml.safe_load(f)
|
|
1072
|
+
|
|
1073
|
+
if not product_data_from_yaml:
|
|
1074
|
+
click.echo("No data found in YAML file. Exiting.", err=True)
|
|
1075
|
+
return
|
|
1076
|
+
|
|
1077
|
+
product_features_map = {}
|
|
1078
|
+
all_feature_names = set()
|
|
1079
|
+
|
|
1080
|
+
# Process the raw data from YAML to build the feature map
|
|
1081
|
+
for model, data in product_data_from_yaml.items():
|
|
1082
|
+
# Reconstruct the DeviceFeatures object from the raw data in the YAML file
|
|
1083
|
+
device_features = DeviceFeatures.from_feature_flags(
|
|
1084
|
+
new_feature_info=data.get("new_feature_info"),
|
|
1085
|
+
new_feature_info_str=data.get("new_feature_info_str"),
|
|
1086
|
+
feature_info=data.get("feature_info"),
|
|
1087
|
+
product_nickname=data.get("product_nickname"),
|
|
1088
|
+
)
|
|
1089
|
+
features_dict = asdict(device_features)
|
|
1090
|
+
|
|
1091
|
+
# This dictionary will hold the final data for the markdown table row
|
|
1092
|
+
current_product_data = {
|
|
1093
|
+
"product_nickname": data.get("product_nickname", ""),
|
|
1094
|
+
"protocol_version": data.get("protocol_version", ""),
|
|
1095
|
+
"new_feature_info": data.get("new_feature_info", ""),
|
|
1096
|
+
"new_feature_info_str": data.get("new_feature_info_str", ""),
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
# Populate features from the calculated DeviceFeatures object
|
|
1100
|
+
for feature, is_supported in features_dict.items():
|
|
1101
|
+
all_feature_names.add(feature)
|
|
1102
|
+
if is_supported:
|
|
1103
|
+
current_product_data[feature] = "X"
|
|
1104
|
+
|
|
1105
|
+
supported_codes = data.get("feature_info", [])
|
|
1106
|
+
if isinstance(supported_codes, list):
|
|
1107
|
+
for code in supported_codes:
|
|
1108
|
+
feature_name = str(code)
|
|
1109
|
+
all_feature_names.add(feature_name)
|
|
1110
|
+
current_product_data[feature_name] = "X"
|
|
1111
|
+
|
|
1112
|
+
product_features_map[model] = current_product_data
|
|
1113
|
+
|
|
1114
|
+
# --- Helper function to write the markdown table ---
|
|
1115
|
+
def write_markdown_table(product_features: dict[str, dict[str, any]], all_features: set[str]):
|
|
1116
|
+
"""Writes the data into a markdown table (products as columns)."""
|
|
1117
|
+
sorted_products = sorted(product_features.keys())
|
|
1118
|
+
special_rows = [
|
|
1119
|
+
"product_nickname",
|
|
1120
|
+
"protocol_version",
|
|
1121
|
+
"new_feature_info",
|
|
1122
|
+
"new_feature_info_str",
|
|
1123
|
+
]
|
|
1124
|
+
# Regular features are the remaining keys, sorted alphabetically
|
|
1125
|
+
# We filter out the special rows to avoid duplicating them.
|
|
1126
|
+
sorted_features = sorted(list(all_features - set(special_rows)))
|
|
1127
|
+
|
|
1128
|
+
header = ["Feature"] + sorted_products
|
|
1129
|
+
|
|
1130
|
+
click.echo(f"Writing documentation to {output_path}...")
|
|
1131
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
1132
|
+
f.write("| " + " | ".join(header) + " |\n")
|
|
1133
|
+
f.write("|" + "---|" * len(header) + "\n")
|
|
1134
|
+
|
|
1135
|
+
# Write the special metadata rows first
|
|
1136
|
+
for row_name in special_rows:
|
|
1137
|
+
row_values = [str(product_features[p].get(row_name, "")) for p in sorted_products]
|
|
1138
|
+
f.write("| " + " | ".join([row_name] + row_values) + " |\n")
|
|
1139
|
+
|
|
1140
|
+
# Write the feature rows
|
|
1141
|
+
for feature in sorted_features:
|
|
1142
|
+
# Use backticks for feature names that are just numbers (from the list)
|
|
1143
|
+
display_feature = f"`{feature}`"
|
|
1144
|
+
feature_row = [display_feature]
|
|
1145
|
+
for product in sorted_products:
|
|
1146
|
+
# Use .get() to place an 'X' or an empty string
|
|
1147
|
+
feature_row.append(product_features[product].get(feature, ""))
|
|
1148
|
+
f.write("| " + " | ".join(feature_row) + " |\n")
|
|
1149
|
+
|
|
1150
|
+
write_markdown_table(product_features_map, all_feature_names)
|
|
1151
|
+
click.echo("Done.")
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
cli.add_command(login)
|
|
1155
|
+
cli.add_command(discover)
|
|
1156
|
+
cli.add_command(list_devices)
|
|
1157
|
+
cli.add_command(list_scenes)
|
|
1158
|
+
cli.add_command(execute_scene)
|
|
1159
|
+
cli.add_command(status)
|
|
1160
|
+
cli.add_command(command)
|
|
1161
|
+
cli.add_command(parser)
|
|
1162
|
+
cli.add_command(session)
|
|
1163
|
+
cli.add_command(get_device_info)
|
|
1164
|
+
cli.add_command(update_docs)
|
|
1165
|
+
cli.add_command(clean_summary)
|
|
1166
|
+
cli.add_command(clean_record)
|
|
1167
|
+
cli.add_command(dock_summary)
|
|
1168
|
+
cli.add_command(volume)
|
|
1169
|
+
cli.add_command(set_volume)
|
|
1170
|
+
cli.add_command(maps)
|
|
1171
|
+
cli.add_command(map_image)
|
|
1172
|
+
cli.add_command(map_data)
|
|
1173
|
+
cli.add_command(consumables)
|
|
1174
|
+
cli.add_command(reset_consumable)
|
|
1175
|
+
cli.add_command(rooms)
|
|
1176
|
+
cli.add_command(home)
|
|
1177
|
+
cli.add_command(features)
|
|
1178
|
+
cli.add_command(child_lock)
|
|
1179
|
+
cli.add_command(dnd)
|
|
1180
|
+
cli.add_command(flow_led_status)
|
|
1181
|
+
cli.add_command(led_status)
|
|
1182
|
+
cli.add_command(network_info)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
# --- Q10 session commands ---
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
@session.command()
|
|
1189
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1190
|
+
@click.pass_context
|
|
1191
|
+
@async_command
|
|
1192
|
+
async def q10_vacuum_start(ctx: click.Context, device_id: str) -> None:
|
|
1193
|
+
"""Start vacuum cleaning on Q10 device."""
|
|
1194
|
+
context: RoborockContext = ctx.obj
|
|
1195
|
+
try:
|
|
1196
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1197
|
+
await trait.start_clean()
|
|
1198
|
+
click.echo("Starting vacuum cleaning...")
|
|
1199
|
+
except RoborockUnsupportedFeature:
|
|
1200
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1201
|
+
except RoborockException as e:
|
|
1202
|
+
click.echo(f"Error: {e}")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
@session.command()
|
|
1206
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1207
|
+
@click.pass_context
|
|
1208
|
+
@async_command
|
|
1209
|
+
async def q10_vacuum_pause(ctx: click.Context, device_id: str) -> None:
|
|
1210
|
+
"""Pause vacuum cleaning on Q10 device."""
|
|
1211
|
+
context: RoborockContext = ctx.obj
|
|
1212
|
+
try:
|
|
1213
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1214
|
+
await trait.pause_clean()
|
|
1215
|
+
click.echo("Pausing vacuum cleaning...")
|
|
1216
|
+
except RoborockUnsupportedFeature:
|
|
1217
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1218
|
+
except RoborockException as e:
|
|
1219
|
+
click.echo(f"Error: {e}")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
@session.command()
|
|
1223
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1224
|
+
@click.pass_context
|
|
1225
|
+
@async_command
|
|
1226
|
+
async def q10_vacuum_resume(ctx: click.Context, device_id: str) -> None:
|
|
1227
|
+
"""Resume vacuum cleaning on Q10 device."""
|
|
1228
|
+
context: RoborockContext = ctx.obj
|
|
1229
|
+
try:
|
|
1230
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1231
|
+
await trait.resume_clean()
|
|
1232
|
+
click.echo("Resuming vacuum cleaning...")
|
|
1233
|
+
except RoborockUnsupportedFeature:
|
|
1234
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1235
|
+
except RoborockException as e:
|
|
1236
|
+
click.echo(f"Error: {e}")
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
@session.command()
|
|
1240
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1241
|
+
@click.pass_context
|
|
1242
|
+
@async_command
|
|
1243
|
+
async def q10_vacuum_stop(ctx: click.Context, device_id: str) -> None:
|
|
1244
|
+
"""Stop vacuum cleaning on Q10 device."""
|
|
1245
|
+
context: RoborockContext = ctx.obj
|
|
1246
|
+
try:
|
|
1247
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1248
|
+
await trait.stop_clean()
|
|
1249
|
+
click.echo("Stopping vacuum cleaning...")
|
|
1250
|
+
except RoborockUnsupportedFeature:
|
|
1251
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1252
|
+
except RoborockException as e:
|
|
1253
|
+
click.echo(f"Error: {e}")
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
@session.command()
|
|
1257
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1258
|
+
@click.pass_context
|
|
1259
|
+
@async_command
|
|
1260
|
+
async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None:
|
|
1261
|
+
"""Return vacuum to dock on Q10 device."""
|
|
1262
|
+
context: RoborockContext = ctx.obj
|
|
1263
|
+
try:
|
|
1264
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1265
|
+
await trait.return_to_dock()
|
|
1266
|
+
click.echo("Returning vacuum to dock...")
|
|
1267
|
+
except RoborockUnsupportedFeature:
|
|
1268
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1269
|
+
except RoborockException as e:
|
|
1270
|
+
click.echo(f"Error: {e}")
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@session.command()
|
|
1274
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1275
|
+
@click.pass_context
|
|
1276
|
+
@async_command
|
|
1277
|
+
async def q10_empty_dustbin(ctx: click.Context, device_id: str) -> None:
|
|
1278
|
+
"""Empty the dustbin at the dock on Q10 device."""
|
|
1279
|
+
context: RoborockContext = ctx.obj
|
|
1280
|
+
try:
|
|
1281
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1282
|
+
await trait.empty_dustbin()
|
|
1283
|
+
click.echo("Emptying dustbin...")
|
|
1284
|
+
except RoborockUnsupportedFeature:
|
|
1285
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1286
|
+
except RoborockException as e:
|
|
1287
|
+
click.echo(f"Error: {e}")
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
@session.command()
|
|
1291
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1292
|
+
@click.option("--mode", required=True, type=click.Choice(["bothwork", "onlysweep", "onlymop"]), help="Clean mode")
|
|
1293
|
+
@click.pass_context
|
|
1294
|
+
@async_command
|
|
1295
|
+
async def q10_set_clean_mode(ctx: click.Context, device_id: str, mode: str) -> None:
|
|
1296
|
+
"""Set the cleaning mode on Q10 device (vacuum, mop, or both)."""
|
|
1297
|
+
context: RoborockContext = ctx.obj
|
|
1298
|
+
try:
|
|
1299
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1300
|
+
clean_mode = YXCleanType.from_value(mode)
|
|
1301
|
+
await trait.set_clean_mode(clean_mode)
|
|
1302
|
+
click.echo(f"Clean mode set to {mode}")
|
|
1303
|
+
except RoborockUnsupportedFeature:
|
|
1304
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1305
|
+
except RoborockException as e:
|
|
1306
|
+
click.echo(f"Error: {e}")
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
@session.command()
|
|
1310
|
+
@click.option("--device_id", required=True, help="Device ID")
|
|
1311
|
+
@click.option(
|
|
1312
|
+
"--level",
|
|
1313
|
+
required=True,
|
|
1314
|
+
type=click.Choice(["close", "quiet", "normal", "strong", "max", "super"]),
|
|
1315
|
+
help='Fan suction level (one of "close", "quiet", "normal", "strong", "max", "super")',
|
|
1316
|
+
)
|
|
1317
|
+
@click.pass_context
|
|
1318
|
+
@async_command
|
|
1319
|
+
async def q10_set_fan_level(ctx: click.Context, device_id: str, level: str) -> None:
|
|
1320
|
+
"""Set the fan suction level on Q10 device."""
|
|
1321
|
+
context: RoborockContext = ctx.obj
|
|
1322
|
+
try:
|
|
1323
|
+
trait = await _q10_vacuum_trait(context, device_id)
|
|
1324
|
+
fan_level = YXFanLevel.from_value(level)
|
|
1325
|
+
await trait.set_fan_level(fan_level)
|
|
1326
|
+
click.echo(f"Fan level set to {fan_level.value}")
|
|
1327
|
+
except RoborockUnsupportedFeature:
|
|
1328
|
+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
|
|
1329
|
+
except RoborockException as e:
|
|
1330
|
+
click.echo(f"Error: {e}")
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def main():
|
|
1334
|
+
return cli()
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
if __name__ == "__main__":
|
|
1338
|
+
main()
|