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
roborock_cli/cli.py ADDED
@@ -0,0 +1,715 @@
1
+ """
2
+ CLI 命令实现
3
+
4
+ 15 个命令:
5
+ auth, devices, status, map, routines
6
+ start-clean, stop-clean, pause-clean, resume-clean
7
+ go-home, find-robot, dock-action, set-volume, set-dnd, execute-routine
8
+ """
9
+
10
+ import asyncio
11
+ import functools
12
+ import json
13
+ import sys
14
+ from contextlib import asynccontextmanager
15
+ from typing import Any
16
+
17
+ from roborock_cli._vendor.roborock import RoborockCommand
18
+ from roborock_cli._vendor.roborock.data import DnDTimer, DnDTimer as DnDTimerClass
19
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
20
+
21
+ from .connection import (
22
+ AuthExpiredError,
23
+ AmbiguousDeviceError,
24
+ ConnectionManager,
25
+ DeviceNotFoundError,
26
+ NoDeviceError,
27
+ )
28
+ from .helpers import STATUS_FIELDS, field, safe_refresh, serialize_value
29
+
30
+
31
+ # CLIError 类已在此文件中定义,供 __main__.py 导入
32
+ __all__ = ["CLIError", "format_error", "add_cli_subparsers"]
33
+
34
+
35
+ # ============================================================================
36
+ # CLI 异常类(退出码绑定)
37
+ # ============================================================================
38
+
39
+ class CLIError(Exception):
40
+ """CLI 基础异常"""
41
+ def __init__(self, message: str, exit_code: int = 1):
42
+ self.message = message
43
+ self.exit_code = exit_code
44
+ super().__init__(message)
45
+
46
+
47
+ class AuthError(CLIError):
48
+ """认证错误"""
49
+ def __init__(self, message: str = "认证已过期,请运行 roborock-cli auth"):
50
+ super().__init__(message, exit_code=2)
51
+
52
+
53
+ class DeviceError(CLIError):
54
+ """设备错误"""
55
+ def __init__(self, message: str):
56
+ super().__init__(message, exit_code=3)
57
+
58
+
59
+ class ParamError(CLIError):
60
+ """参数无效"""
61
+ def __init__(self, message: str):
62
+ super().__init__(message, exit_code=4)
63
+
64
+
65
+ class InternalError(CLIError):
66
+ """内部错误"""
67
+ def __init__(self, message: str):
68
+ super().__init__(message, exit_code=5)
69
+
70
+
71
+ # ============================================================================
72
+ # 连接管理(含超时,Python 3.7+ 兼容)
73
+ # ============================================================================
74
+
75
+ @asynccontextmanager
76
+ async def get_connection(timeout: float = 30.0):
77
+ """获取连接上下文管理器(带超时)"""
78
+ conn = ConnectionManager.from_env()
79
+ try:
80
+ await asyncio.wait_for(conn.ensure_connected(), timeout=timeout)
81
+ except asyncio.TimeoutError:
82
+ raise CLIError(f"连接超时({timeout}秒)")
83
+ try:
84
+ yield conn
85
+ finally:
86
+ await conn.close()
87
+
88
+
89
+ def run_async(coro):
90
+ """运行异步协程"""
91
+ import anyio
92
+
93
+ async def main():
94
+ return await coro
95
+
96
+ if sys.platform == "win32":
97
+ backend_options = {"loop_factory": asyncio.SelectorEventLoop}
98
+ else:
99
+ backend_options = {}
100
+
101
+ return anyio.run(main, backend_options=backend_options)
102
+
103
+
104
+ # ============================================================================
105
+ # 错误处理装饰器
106
+ # ============================================================================
107
+
108
+ def handle_cli_errors(func):
109
+ """CLI 错误处理装饰器,将底层异常转为 CLI 异常"""
110
+ @functools.wraps(func)
111
+ def wrapper(args):
112
+ try:
113
+ return func(args)
114
+ except AuthExpiredError:
115
+ raise AuthError()
116
+ except (NoDeviceError, AmbiguousDeviceError, DeviceNotFoundError) as e:
117
+ raise DeviceError(str(e))
118
+ except ValueError as e:
119
+ raise ParamError(str(e))
120
+ except RoborockException as e:
121
+ raise CLIError(f"设备通信失败: {type(e).__name__}: {e}")
122
+ except Exception as e:
123
+ raise InternalError(f"内部错误: {type(e).__name__}: {e}")
124
+ return wrapper
125
+
126
+
127
+ # ============================================================================
128
+ # 输出格式化(默认 JSON)
129
+ # ============================================================================
130
+
131
+ def format_output(data: Any) -> str:
132
+ """默认 JSON 输出"""
133
+ return json.dumps(data, ensure_ascii=False, indent=2)
134
+
135
+
136
+ def format_error(error: CLIError) -> str:
137
+ """错误也输出 JSON"""
138
+ return json.dumps({"error": error.message, "exit_code": error.exit_code})
139
+
140
+
141
+ # ============================================================================
142
+ # 辅助函数
143
+ # ============================================================================
144
+
145
+ def _parse_time(time_str: str) -> tuple[int, int]:
146
+ """解析 HH:MM 格式时间,返回 (hour, minute)"""
147
+ parts = time_str.split(":")
148
+ if len(parts) != 2:
149
+ raise ValueError(f"时间格式错误: {time_str},期望 HH:MM")
150
+ hour, minute = int(parts[0]), int(parts[1])
151
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
152
+ raise ValueError(f"时间值无效: {time_str}")
153
+ return hour, minute
154
+
155
+
156
+ # ============================================================================
157
+ # 命令实现
158
+ # ============================================================================
159
+
160
+ # --- auth(交互式,无网络操作)---
161
+
162
+ def cmd_auth(args):
163
+ """交互式认证(需要 TTY)"""
164
+ from .setup_auth import main as auth_main
165
+ auth_main()
166
+
167
+
168
+ # --- devices ---
169
+
170
+ @handle_cli_errors
171
+ def cmd_devices(args):
172
+ """列出所有设备"""
173
+ async def _run():
174
+ async with get_connection() as conn:
175
+ devices = await conn.list_devices()
176
+ return {
177
+ "count": len(devices),
178
+ "devices": [
179
+ {
180
+ "name": d.name,
181
+ "duid": d.duid,
182
+ "model": d.product.model if d.product else None,
183
+ "firmware": d.device_info.fv if d.device_info else None,
184
+ }
185
+ for d in devices
186
+ ]
187
+ }
188
+ result = run_async(_run())
189
+ print(format_output(result))
190
+
191
+
192
+ # --- status ---
193
+
194
+ @handle_cli_errors
195
+ def cmd_status(args):
196
+ """获取设备状态"""
197
+ async def _run():
198
+ async with get_connection() as conn:
199
+ device = await conn.get_device(args.device)
200
+ if not device.v1_properties:
201
+ raise DeviceError("设备不支持 V1 协议")
202
+
203
+ props = device.v1_properties
204
+
205
+ # 并行刷新所有 trait
206
+ trait_names = [
207
+ "status", "dnd", "clean_summary", "sound_volume",
208
+ "rooms", "maps", "consumables", "device_features",
209
+ ]
210
+ refresh_results = await asyncio.gather(
211
+ *[safe_refresh(getattr(props, name), name) for name in trait_names]
212
+ )
213
+ ok = dict(zip(trait_names, refresh_results))
214
+
215
+ result: dict[str, Any] = {"device_name": device.name}
216
+
217
+ # --- status trait ---
218
+ if ok["status"]:
219
+ status = props.status
220
+
221
+ # 白名单字段
222
+ for fname, desc in STATUS_FIELDS.items():
223
+ raw = getattr(status, fname, None)
224
+ result[fname] = field(serialize_value(raw), desc)
225
+
226
+ # computed properties
227
+ result["state"] = field(status.state_name, "设备状态")
228
+ result["error_code"] = field(status.error_code_name, "错误状态")
229
+ result["clean_area_m2"] = field(status.square_meter_clean_area, "清洁面积 (m²)")
230
+ result["clean_time_minutes"] = field(
231
+ round(status.clean_time / 60, 1) if status.clean_time else 0,
232
+ "清洁时长 (分钟)",
233
+ )
234
+ result["fan_speed_name"] = field(status.fan_speed_name, "吸力档位")
235
+ result["water_mode_name"] = field(status.water_mode_name, "拖布湿度")
236
+ result["mop_route_name"] = field(status.mop_route_name, "拖地路线")
237
+
238
+ # dss 拆解
239
+ result["clear_water_box_status"] = field(
240
+ serialize_value(status.clear_water_box_status), "清水箱状态")
241
+ result["dirty_water_box_status"] = field(
242
+ serialize_value(status.dirty_water_box_status), "污水箱状态")
243
+ result["dust_bag_status"] = field(
244
+ serialize_value(status.dust_bag_status), "尘袋状态")
245
+
246
+ # --- dnd ---
247
+ if ok["dnd"]:
248
+ dnd = props.dnd
249
+ result["dnd_enabled"] = field(dnd.is_on, "勿扰模式")
250
+ result["dnd_start_time"] = field(
251
+ f"{dnd.start_hour:02d}:{dnd.start_minute:02d}" if dnd.is_on else None,
252
+ "勿扰开始时间",
253
+ )
254
+ result["dnd_end_time"] = field(
255
+ f"{dnd.end_hour:02d}:{dnd.end_minute:02d}" if dnd.is_on else None,
256
+ "勿扰结束时间",
257
+ )
258
+
259
+ # --- clean_summary ---
260
+ if ok["clean_summary"]:
261
+ summary = props.clean_summary
262
+ result["total_clean_time_hours"] = field(
263
+ round(summary.clean_time / 3600, 1) if summary.clean_time else 0,
264
+ "累计清洁时长 (小时)",
265
+ )
266
+ result["total_clean_area_m2"] = field(
267
+ round(summary.clean_area / 1_000_000, 1) if summary.clean_area else 0,
268
+ "累计清洁面积 (m²)",
269
+ )
270
+ result["total_clean_count"] = field(summary.clean_count, "累计清洁次数")
271
+
272
+ if summary.last_clean_record:
273
+ record = summary.last_clean_record
274
+ result["last_clean_begin"] = field(
275
+ record.begin_datetime.isoformat() if record.begin_datetime else None,
276
+ "最近清洁开始时间",
277
+ )
278
+ result["last_clean_end"] = field(
279
+ record.end_datetime.isoformat() if record.end_datetime else None,
280
+ "最近清洁结束时间",
281
+ )
282
+
283
+ # --- sound_volume ---
284
+ if ok["sound_volume"]:
285
+ result["volume"] = field(props.sound_volume.volume, "音量")
286
+
287
+ # --- rooms ---
288
+ if ok["rooms"]:
289
+ rooms = props.rooms
290
+ room_list = [
291
+ {
292
+ "segment_id": room.segment_id,
293
+ "iot_id": room.iot_id,
294
+ "name": room.raw_name or "<未命名>",
295
+ }
296
+ for room in rooms.rooms or []
297
+ ]
298
+ result["room_count"] = field(len(room_list), "房间数量")
299
+ result["rooms"] = field(room_list, "房间列表")
300
+
301
+ # --- maps ---
302
+ if ok["maps"]:
303
+ maps = props.maps
304
+ map_list = [
305
+ {
306
+ "map_flag": m.map_flag,
307
+ "name": m.name or "<未命名>",
308
+ "is_current": m.map_flag == maps.current_map,
309
+ }
310
+ for m in maps.map_info or []
311
+ ]
312
+ result["current_map_flag"] = field(maps.current_map, "当前地图 ID")
313
+ result["maps"] = field(map_list, "地图列表")
314
+
315
+ # --- consumables ---
316
+ if ok["consumables"]:
317
+ consumables = props.consumables
318
+ SECS_PER_HOUR = 3600
319
+ result["main_brush_hours"] = field(
320
+ round(consumables.main_brush_work_time / SECS_PER_HOUR, 1)
321
+ if consumables.main_brush_work_time else 0,
322
+ "主刷使用时长 (小时)",
323
+ )
324
+ result["side_brush_hours"] = field(
325
+ round(consumables.side_brush_work_time / SECS_PER_HOUR, 1)
326
+ if consumables.side_brush_work_time else 0,
327
+ "边刷使用时长 (小时)",
328
+ )
329
+ result["filter_hours"] = field(
330
+ round(consumables.filter_work_time / SECS_PER_HOUR, 1)
331
+ if consumables.filter_work_time else 0,
332
+ "滤网使用时长 (小时)",
333
+ )
334
+
335
+ return result
336
+
337
+ result = run_async(_run())
338
+ print(format_output(result))
339
+
340
+
341
+ # --- map ---
342
+
343
+ @handle_cli_errors
344
+ def cmd_map(args):
345
+ """获取地图"""
346
+ async def _run():
347
+ async with get_connection() as conn:
348
+ device = await conn.get_device(args.device)
349
+ if not device.v1_properties:
350
+ raise DeviceError("设备不支持 V1 协议")
351
+
352
+ props = device.v1_properties
353
+ await props.map_content.refresh()
354
+ content = props.map_content
355
+
356
+ metadata = {
357
+ "device_name": device.name,
358
+ "image_size_bytes": len(content.image_content) if content.image_content else 0,
359
+ "has_map_data": content.map_data is not None,
360
+ }
361
+
362
+ if content.map_data:
363
+ vacuum_pos = content.map_data.vacuum_position
364
+ charger_pos = content.map_data.charger
365
+ metadata["vacuum_position"] = {
366
+ "x": vacuum_pos.x if vacuum_pos else None,
367
+ "y": vacuum_pos.y if vacuum_pos else None,
368
+ }
369
+ metadata["charger_position"] = {
370
+ "x": charger_pos.x if charger_pos else None,
371
+ "y": charger_pos.y if charger_pos else None,
372
+ }
373
+
374
+ return metadata, content.image_content
375
+
376
+ metadata, image_data = run_async(_run())
377
+
378
+ if args.save:
379
+ if not image_data:
380
+ raise CLIError("无地图图片数据")
381
+ with open(args.save, "wb") as f:
382
+ f.write(image_data)
383
+ print(format_output({"saved": args.save, "metadata": metadata}))
384
+ else:
385
+ print(format_output(metadata))
386
+
387
+
388
+ # --- routines ---
389
+
390
+ @handle_cli_errors
391
+ def cmd_routines(args):
392
+ """列出智能场景"""
393
+ async def _run():
394
+ async with get_connection() as conn:
395
+ device = await conn.get_device(args.device)
396
+ if not device.v1_properties:
397
+ raise DeviceError("设备不支持 V1 协议")
398
+
399
+ routines = await device.v1_properties.routines.get_routines()
400
+ return {
401
+ "device_name": device.name,
402
+ "count": len(routines),
403
+ "routines": [{"id": r.id, "name": r.name} for r in routines],
404
+ }
405
+
406
+ result = run_async(_run())
407
+ print(format_output(result))
408
+
409
+
410
+ # --- start-clean ---
411
+
412
+ @handle_cli_errors
413
+ def cmd_start_clean(args):
414
+ """开始清洁"""
415
+ async def _run():
416
+ async with get_connection() as conn:
417
+ device = await conn.get_device(args.device)
418
+ if not device.v1_properties:
419
+ raise DeviceError("设备不支持 V1 协议")
420
+
421
+ cmd = device.v1_properties.command
422
+ if args.segments:
423
+ params = [{"segments": args.segments, "repeat": args.repeat}]
424
+ await cmd.send(RoborockCommand.APP_SEGMENT_CLEAN, params=params)
425
+ return {"status": "ok", "action": "segment_clean", "segments": args.segments, "repeat": args.repeat}
426
+ else:
427
+ await cmd.send(RoborockCommand.APP_START)
428
+ return {"status": "ok", "action": "full_clean"}
429
+
430
+ result = run_async(_run())
431
+ print(format_output(result))
432
+
433
+
434
+ # --- stop-clean ---
435
+
436
+ @handle_cli_errors
437
+ def cmd_stop_clean(args):
438
+ """停止清洁"""
439
+ async def _run():
440
+ async with get_connection() as conn:
441
+ device = await conn.get_device(args.device)
442
+ if not device.v1_properties:
443
+ raise DeviceError("设备不支持 V1 协议")
444
+
445
+ await device.v1_properties.command.send(RoborockCommand.APP_STOP)
446
+ return {"status": "ok", "action": "stop"}
447
+
448
+ result = run_async(_run())
449
+ print(format_output(result))
450
+
451
+
452
+ # --- pause-clean ---
453
+
454
+ @handle_cli_errors
455
+ def cmd_pause_clean(args):
456
+ """暂停清洁"""
457
+ async def _run():
458
+ async with get_connection() as conn:
459
+ device = await conn.get_device(args.device)
460
+ if not device.v1_properties:
461
+ raise DeviceError("设备不支持 V1 协议")
462
+
463
+ await device.v1_properties.command.send(RoborockCommand.APP_PAUSE)
464
+ return {"status": "ok", "action": "pause"}
465
+
466
+ result = run_async(_run())
467
+ print(format_output(result))
468
+
469
+
470
+ # --- resume-clean ---
471
+
472
+ @handle_cli_errors
473
+ def cmd_resume_clean(args):
474
+ """恢复清洁"""
475
+ async def _run():
476
+ async with get_connection() as conn:
477
+ device = await conn.get_device(args.device)
478
+ if not device.v1_properties:
479
+ raise DeviceError("设备不支持 V1 协议")
480
+
481
+ await device.v1_properties.command.send(RoborockCommand.APP_START)
482
+ return {"status": "ok", "action": "resume"}
483
+
484
+ result = run_async(_run())
485
+ print(format_output(result))
486
+
487
+
488
+ # --- go-home ---
489
+
490
+ @handle_cli_errors
491
+ def cmd_go_home(args):
492
+ """回充电座"""
493
+ async def _run():
494
+ async with get_connection() as conn:
495
+ device = await conn.get_device(args.device)
496
+ if not device.v1_properties:
497
+ raise DeviceError("设备不支持 V1 协议")
498
+
499
+ await device.v1_properties.command.send(RoborockCommand.APP_CHARGE)
500
+ return {"status": "ok", "action": "go_home"}
501
+
502
+ result = run_async(_run())
503
+ print(format_output(result))
504
+
505
+
506
+ # --- find-robot ---
507
+
508
+ @handle_cli_errors
509
+ def cmd_find_robot(args):
510
+ """寻找机器人"""
511
+ async def _run():
512
+ async with get_connection() as conn:
513
+ device = await conn.get_device(args.device)
514
+ if not device.v1_properties:
515
+ raise DeviceError("设备不支持 V1 协议")
516
+
517
+ await device.v1_properties.command.send(RoborockCommand.FIND_ME)
518
+ return {"status": "ok", "action": "find_robot"}
519
+
520
+ result = run_async(_run())
521
+ print(format_output(result))
522
+
523
+
524
+ # --- dock-action ---
525
+
526
+ @handle_cli_errors
527
+ def cmd_dock_action(args):
528
+ """基座操作"""
529
+ ACTION_MAP = {
530
+ "start_collect_dust": RoborockCommand.APP_START_COLLECT_DUST,
531
+ "stop_collect_dust": RoborockCommand.APP_STOP_COLLECT_DUST,
532
+ "start_wash": RoborockCommand.APP_START_WASH,
533
+ "stop_wash": RoborockCommand.APP_STOP_WASH,
534
+ }
535
+
536
+ async def _run():
537
+ async with get_connection() as conn:
538
+ device = await conn.get_device(args.device)
539
+ if not device.v1_properties:
540
+ raise DeviceError("设备不支持 V1 协议")
541
+
542
+ await device.v1_properties.command.send(ACTION_MAP[args.action])
543
+ return {"status": "ok", "action": args.action}
544
+
545
+ result = run_async(_run())
546
+ print(format_output(result))
547
+
548
+
549
+ # --- set-volume ---
550
+
551
+ @handle_cli_errors
552
+ def cmd_set_volume(args):
553
+ """设置音量"""
554
+ if not 0 <= args.volume <= 100:
555
+ raise ParamError("音量值需在 0-100 之间")
556
+
557
+ async def _run():
558
+ async with get_connection() as conn:
559
+ device = await conn.get_device(args.device)
560
+ if not device.v1_properties:
561
+ raise DeviceError("设备不支持 V1 协议")
562
+
563
+ await device.v1_properties.sound_volume.set_volume(args.volume)
564
+ return {"status": "ok", "volume": args.volume}
565
+
566
+ result = run_async(_run())
567
+ print(format_output(result))
568
+
569
+
570
+ # --- set-dnd ---
571
+
572
+ @handle_cli_errors
573
+ def cmd_set_dnd(args):
574
+ """设置勿扰模式"""
575
+ if args.action == "enable":
576
+ if not args.start or not args.end:
577
+ raise ParamError("enable 操作需要 --start 和 --end 参数")
578
+ try:
579
+ start_hour, start_minute = _parse_time(args.start)
580
+ end_hour, end_minute = _parse_time(args.end)
581
+ except ValueError as e:
582
+ raise ParamError(str(e))
583
+ else:
584
+ start_hour = start_minute = end_hour = end_minute = 0
585
+
586
+ async def _run():
587
+ async with get_connection() as conn:
588
+ device = await conn.get_device(args.device)
589
+ if not device.v1_properties:
590
+ raise DeviceError("设备不支持 V1 协议")
591
+
592
+ dnd = device.v1_properties.dnd
593
+ if args.action == "enable":
594
+ timer = DnDTimer(start_hour, start_minute, end_hour, end_minute)
595
+ await dnd.set_dnd_timer(timer)
596
+ return {
597
+ "status": "ok",
598
+ "action": "enable",
599
+ "time": f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}",
600
+ }
601
+ else:
602
+ await dnd.disable()
603
+ return {"status": "ok", "action": "disable"}
604
+
605
+ result = run_async(_run())
606
+ print(format_output(result))
607
+
608
+
609
+ # --- execute-routine ---
610
+
611
+ @handle_cli_errors
612
+ def cmd_execute_routine(args):
613
+ """执行智能场景"""
614
+ async def _run():
615
+ async with get_connection() as conn:
616
+ device = await conn.get_device(args.device)
617
+ if not device.v1_properties:
618
+ raise DeviceError("设备不支持 V1 协议")
619
+
620
+ await device.v1_properties.routines.execute_routine(args.routine_id)
621
+ return {"status": "ok", "routine_id": args.routine_id}
622
+
623
+ result = run_async(_run())
624
+ print(format_output(result))
625
+
626
+
627
+ # ============================================================================
628
+ # argparse 集成
629
+ # ============================================================================
630
+
631
+ def add_cli_subparsers(subparsers):
632
+ """注册 CLI 子命令"""
633
+
634
+ # auth(交互式)
635
+ p = subparsers.add_parser("auth", help="交互式认证(需要 TTY)")
636
+ p.set_defaults(func=cmd_auth)
637
+
638
+ # devices
639
+ p = subparsers.add_parser("devices", help="列出所有设备")
640
+ p.set_defaults(func=cmd_devices)
641
+
642
+ # status
643
+ p = subparsers.add_parser("status", help="获取设备状态")
644
+ p.add_argument("-d", "--device", help="设备名称(模糊匹配)")
645
+ p.set_defaults(func=cmd_status)
646
+
647
+ # map
648
+ p = subparsers.add_parser("map", help="获取地图")
649
+ p.add_argument("-d", "--device", help="设备名称")
650
+ p.add_argument("--save", help="保存地图到文件")
651
+ p.set_defaults(func=cmd_map)
652
+
653
+ # routines
654
+ p = subparsers.add_parser("routines", help="列出智能场景")
655
+ p.add_argument("-d", "--device", help="设备名称")
656
+ p.set_defaults(func=cmd_routines)
657
+
658
+ # start-clean
659
+ p = subparsers.add_parser("start-clean", help="开始清洁")
660
+ p.add_argument("-d", "--device", help="设备名称")
661
+ p.add_argument("-s", "--segments", type=int, nargs="+", help="房间 ID 列表")
662
+ p.add_argument("-r", "--repeat", type=int, default=1, help="清洁遍数")
663
+ p.set_defaults(func=cmd_start_clean)
664
+
665
+ # stop-clean
666
+ p = subparsers.add_parser("stop-clean", help="停止清洁")
667
+ p.add_argument("-d", "--device", help="设备名称")
668
+ p.set_defaults(func=cmd_stop_clean)
669
+
670
+ # pause-clean
671
+ p = subparsers.add_parser("pause-clean", help="暂停清洁")
672
+ p.add_argument("-d", "--device", help="设备名称")
673
+ p.set_defaults(func=cmd_pause_clean)
674
+
675
+ # resume-clean
676
+ p = subparsers.add_parser("resume-clean", help="恢复清洁")
677
+ p.add_argument("-d", "--device", help="设备名称")
678
+ p.set_defaults(func=cmd_resume_clean)
679
+
680
+ # go-home
681
+ p = subparsers.add_parser("go-home", help="回充电座")
682
+ p.add_argument("-d", "--device", help="设备名称")
683
+ p.set_defaults(func=cmd_go_home)
684
+
685
+ # find-robot
686
+ p = subparsers.add_parser("find-robot", help="寻找机器人")
687
+ p.add_argument("-d", "--device", help="设备名称")
688
+ p.set_defaults(func=cmd_find_robot)
689
+
690
+ # dock-action
691
+ p = subparsers.add_parser("dock-action", help="基座操作")
692
+ p.add_argument("action", choices=["start_collect_dust", "stop_collect_dust", "start_wash", "stop_wash"],
693
+ help="操作类型")
694
+ p.add_argument("-d", "--device", help="设备名称")
695
+ p.set_defaults(func=cmd_dock_action)
696
+
697
+ # set-volume
698
+ p = subparsers.add_parser("set-volume", help="设置音量")
699
+ p.add_argument("volume", type=int, help="音量值 (0-100)")
700
+ p.add_argument("-d", "--device", help="设备名称")
701
+ p.set_defaults(func=cmd_set_volume)
702
+
703
+ # set-dnd
704
+ p = subparsers.add_parser("set-dnd", help="设置勿扰模式")
705
+ p.add_argument("action", choices=["enable", "disable"], help="操作类型")
706
+ p.add_argument("--start", help="开始时间(HH:MM,enable 时必需)")
707
+ p.add_argument("--end", help="结束时间(HH:MM,enable 时必需)")
708
+ p.add_argument("-d", "--device", help="设备名称")
709
+ p.set_defaults(func=cmd_set_dnd)
710
+
711
+ # execute-routine
712
+ p = subparsers.add_parser("execute-routine", help="执行智能场景")
713
+ p.add_argument("routine_id", type=int, help="场景 ID")
714
+ p.add_argument("-d", "--device", help="设备名称")
715
+ p.set_defaults(func=cmd_execute_routine)