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.
Files changed (106) hide show
  1. roborock_cli/__init__.py +3 -0
  2. roborock_cli/__main__.py +76 -0
  3. roborock_cli/_vendor/VERSION +6 -0
  4. roborock_cli/_vendor/__init__.py +0 -0
  5. roborock_cli/_vendor/roborock/__init__.py +27 -0
  6. roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
  7. roborock_cli/_vendor/roborock/callbacks.py +130 -0
  8. roborock_cli/_vendor/roborock/cli.py +1338 -0
  9. roborock_cli/_vendor/roborock/const.py +84 -0
  10. roborock_cli/_vendor/roborock/data/__init__.py +9 -0
  11. roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
  12. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
  13. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
  14. roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
  15. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
  16. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
  17. roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
  18. roborock_cli/_vendor/roborock/data/containers.py +530 -0
  19. roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
  20. roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
  21. roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
  22. roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
  23. roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
  24. roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
  25. roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
  26. roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
  27. roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
  28. roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
  29. roborock_cli/_vendor/roborock/device_features.py +668 -0
  30. roborock_cli/_vendor/roborock/devices/README.md +41 -0
  31. roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
  32. roborock_cli/_vendor/roborock/devices/cache.py +143 -0
  33. roborock_cli/_vendor/roborock/devices/device.py +240 -0
  34. roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
  35. roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
  36. roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
  37. roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
  38. roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
  39. roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
  40. roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
  41. roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
  42. roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
  43. roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
  44. roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
  45. roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
  46. roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
  47. roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
  48. roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
  49. roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
  50. roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
  51. roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
  52. roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
  53. roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
  54. roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
  55. roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
  56. roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
  57. roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
  58. roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
  59. roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
  60. roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
  61. roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
  62. roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
  63. roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
  64. roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
  65. roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
  66. roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
  67. roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
  68. roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
  69. roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
  70. roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
  71. roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
  72. roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
  73. roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
  74. roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
  75. roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
  76. roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
  77. roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
  78. roborock_cli/_vendor/roborock/diagnostics.py +166 -0
  79. roborock_cli/_vendor/roborock/exceptions.py +95 -0
  80. roborock_cli/_vendor/roborock/map/__init__.py +7 -0
  81. roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
  82. roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
  83. roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
  84. roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
  85. roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
  86. roborock_cli/_vendor/roborock/protocol.py +558 -0
  87. roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
  88. roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
  89. roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
  90. roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
  91. roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
  92. roborock_cli/_vendor/roborock/py.typed +0 -0
  93. roborock_cli/_vendor/roborock/roborock_message.py +246 -0
  94. roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
  95. roborock_cli/_vendor/roborock/util.py +54 -0
  96. roborock_cli/_vendor/roborock/web_api.py +761 -0
  97. roborock_cli/cli.py +715 -0
  98. roborock_cli/connection.py +202 -0
  99. roborock_cli/helpers.py +71 -0
  100. roborock_cli/server.py +759 -0
  101. roborock_cli/setup_auth.py +92 -0
  102. roborock_cli-0.1.1.dist-info/METADATA +172 -0
  103. roborock_cli-0.1.1.dist-info/RECORD +106 -0
  104. roborock_cli-0.1.1.dist-info/WHEEL +4 -0
  105. roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
  106. 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()