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
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)
|