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,3 @@
1
+ """roborock-cli - CLI 控制工具,让 Claude 或用户控制石头扫地机"""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,76 @@
1
+ """
2
+ CLI 入口
3
+
4
+ 主入口模式:
5
+ roborock-cli → 显示帮助(CLI 为主)
6
+ roborock-cli auth → 交互式认证
7
+ roborock-cli devices → 列出设备
8
+ roborock-cli status → 获取状态
9
+ ...
10
+ roborock-cli mcp → 启动 MCP stdio server(降级为子命令)
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+
16
+ from . import __version__
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser(
21
+ prog="roborock-cli",
22
+ description="Roborock 扫地机控制工具",
23
+ epilog="使用 'roborock-cli <command> --help' 查看子命令详情",
24
+ )
25
+ parser.add_argument("--version", "-V", action="version", version=f"%(prog)s {__version__}")
26
+ subparsers = parser.add_subparsers(dest="command")
27
+
28
+ # CLI 子命令(主入口)
29
+ from .cli import add_cli_subparsers
30
+ add_cli_subparsers(subparsers)
31
+
32
+ # MCP 子命令(降级)
33
+ mcp_parser = subparsers.add_parser("mcp", help="启动 MCP stdio server")
34
+ mcp_parser.set_defaults(func=cmd_mcp)
35
+
36
+ args = parser.parse_args()
37
+
38
+ if args.command is None:
39
+ # 无参数时显示帮助
40
+ parser.print_help()
41
+ sys.exit(0)
42
+ elif hasattr(args, "func"):
43
+ # 执行子命令
44
+ from .cli import CLIError, format_error
45
+ try:
46
+ args.func(args)
47
+ except CLIError as e:
48
+ print(format_error(e))
49
+ sys.exit(e.exit_code)
50
+ except KeyboardInterrupt:
51
+ sys.exit(130)
52
+ else:
53
+ parser.error(f"未知子命令: {args.command}")
54
+
55
+
56
+ def cmd_mcp(args):
57
+ """启动 MCP stdio server"""
58
+ import anyio
59
+ from .server import mcp
60
+
61
+ # Windows 的 ProactorEventLoop 不支持 add_reader/add_writer,
62
+ # 而 aiomqtt (paho-mqtt) 依赖这些方法。
63
+ # mcp.run() 内部调用 anyio.run() 但不传 loop_factory,
64
+ # 所以直接调用 anyio.run() 显式指定 SelectorEventLoop。
65
+ # MCP stdio transport 用 anyio.wrap_file()(线程模式),不受影响。
66
+ if sys.platform == "win32":
67
+ import asyncio
68
+ backend_options = {"loop_factory": asyncio.SelectorEventLoop}
69
+ else:
70
+ backend_options = {}
71
+
72
+ anyio.run(mcp.run_stdio_async, backend_options=backend_options)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,6 @@
1
+ python-roborock version: 4.20.0
2
+ Updated at: 2026-03-12T14:10:00+08:00
3
+
4
+ Applied patches:
5
+ - v1_channel_callback.patch: 修复 V1Channel.unsub() 未清除 callback
6
+ - file_cache_empty_file.patch: 修复空缓存文件导致 EOFError
File without changes
@@ -0,0 +1,27 @@
1
+ """Roborock API.
2
+
3
+ .. include:: ../README.md
4
+ """
5
+
6
+ from roborock_cli._vendor.roborock.data import *
7
+ from roborock_cli._vendor.roborock.exceptions import *
8
+ from roborock_cli._vendor.roborock.roborock_typing import *
9
+
10
+ from . import (
11
+ const,
12
+ data,
13
+ devices,
14
+ exceptions,
15
+ roborock_typing,
16
+ web_api,
17
+ )
18
+
19
+ __all__ = [
20
+ "devices",
21
+ "data",
22
+ "map",
23
+ "web_api",
24
+ "roborock_typing",
25
+ "exceptions",
26
+ "const",
27
+ ]
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ from asyncio import BaseTransport, Lock
8
+
9
+ from construct import ( # type: ignore
10
+ Bytes,
11
+ Checksum,
12
+ GreedyBytes,
13
+ Int16ub,
14
+ Int32ub,
15
+ Prefixed,
16
+ RawCopy,
17
+ Struct,
18
+ )
19
+ from Crypto.Cipher import AES
20
+
21
+ from roborock_cli._vendor.roborock import RoborockException
22
+ from roborock_cli._vendor.roborock.data import BroadcastMessage
23
+ from roborock_cli._vendor.roborock.protocol import EncryptionAdapter, Utils, _Parser
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+ BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
28
+
29
+
30
+ class RoborockProtocol(asyncio.DatagramProtocol):
31
+ def __init__(self, timeout: int = 5):
32
+ self.timeout = timeout
33
+ self.transport: BaseTransport | None = None
34
+ self.devices_found: list[BroadcastMessage] = []
35
+ self._mutex = Lock()
36
+
37
+ def datagram_received(self, data: bytes, _):
38
+ """Handle incoming broadcast datagrams."""
39
+ try:
40
+ version = data[:3]
41
+ if version == b"L01":
42
+ [parsed_msg], _ = L01Parser.parse(data)
43
+ encrypted_payload = parsed_msg.payload
44
+ if encrypted_payload is None:
45
+ raise RoborockException("No encrypted payload found in broadcast message")
46
+ ciphertext = encrypted_payload[:-16]
47
+ tag = encrypted_payload[-16:]
48
+
49
+ key = hashlib.sha256(BROADCAST_TOKEN).digest()
50
+ iv_digest_input = data[:9]
51
+ digest = hashlib.sha256(iv_digest_input).digest()
52
+ iv = digest[:12]
53
+
54
+ cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
55
+ decrypted_payload_bytes = cipher.decrypt_and_verify(ciphertext, tag)
56
+ json_payload = json.loads(decrypted_payload_bytes)
57
+ parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
58
+ _LOGGER.debug(f"Received L01 broadcast: {parsed_message}")
59
+ self.devices_found.append(parsed_message)
60
+ else:
61
+ # Fallback to the original protocol parser for other versions
62
+ [broadcast_message], _ = BroadcastParser.parse(data)
63
+ if broadcast_message.payload:
64
+ json_payload = json.loads(broadcast_message.payload)
65
+ parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
66
+ _LOGGER.debug(f"Received broadcast: {parsed_message}")
67
+ self.devices_found.append(parsed_message)
68
+ except Exception as e:
69
+ _LOGGER.warning(f"Failed to decode message: {data!r}. Error: {e}")
70
+
71
+ async def discover(self) -> list[BroadcastMessage]:
72
+ async with self._mutex:
73
+ try:
74
+ loop = asyncio.get_event_loop()
75
+ self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866))
76
+ await asyncio.sleep(self.timeout)
77
+ return self.devices_found
78
+ finally:
79
+ self.close()
80
+ self.devices_found = []
81
+
82
+ def close(self):
83
+ self.transport.close() if self.transport else None
84
+
85
+
86
+ _BroadcastMessage = Struct(
87
+ "message"
88
+ / RawCopy(
89
+ Struct(
90
+ "version" / Bytes(3),
91
+ "seq" / Int32ub,
92
+ "protocol" / Int16ub,
93
+ "payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN),
94
+ )
95
+ ),
96
+ "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
97
+ )
98
+
99
+ _L01BroadcastMessage = Struct(
100
+ "message"
101
+ / RawCopy(
102
+ Struct(
103
+ "version" / Bytes(3),
104
+ "field1" / Bytes(4), # Unknown field
105
+ "field2" / Bytes(2), # Unknown field
106
+ "payload" / Prefixed(Int16ub, GreedyBytes), # Encrypted payload with length prefix
107
+ )
108
+ ),
109
+ "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
110
+ )
111
+
112
+
113
+ BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
114
+ L01Parser: _Parser = _Parser(_L01BroadcastMessage, False)
@@ -0,0 +1,130 @@
1
+ """Module for managing callback utility functions."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from typing import Generic, TypeVar
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+ K = TypeVar("K")
10
+ V = TypeVar("V")
11
+
12
+
13
+ def safe_callback(
14
+ callback: Callable[[V], None], logger: logging.Logger | logging.LoggerAdapter | None = None
15
+ ) -> Callable[[V], None]:
16
+ """Wrap a callback to catch and log exceptions.
17
+
18
+ This is useful for ensuring that errors in callbacks do not propagate
19
+ and cause unexpected behavior. Any failures during callback execution will be logged.
20
+ """
21
+
22
+ if logger is None:
23
+ logger = _LOGGER
24
+
25
+ def wrapper(value: V) -> None:
26
+ try:
27
+ callback(value)
28
+ except Exception as ex: # noqa: BLE001
29
+ logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex)
30
+
31
+ return wrapper
32
+
33
+
34
+ class CallbackMap(Generic[K, V]):
35
+ """A mapping of callbacks for specific keys.
36
+
37
+ This allows for registering multiple callbacks for different keys and invoking them
38
+ when a value is received for a specific key.
39
+ """
40
+
41
+ def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None:
42
+ self._callbacks: dict[K, list[Callable[[V], None]]] = {}
43
+ self._logger = logger or _LOGGER
44
+
45
+ def keys(self) -> list[K]:
46
+ """Get all keys in the callback map."""
47
+ return list(self._callbacks.keys())
48
+
49
+ def add_callback(self, key: K, callback: Callable[[V], None]) -> Callable[[], None]:
50
+ """Add a callback for a specific key.
51
+
52
+ Any failures during callback execution will be logged.
53
+
54
+ Returns a callable that can be used to remove the callback.
55
+ """
56
+ self._callbacks.setdefault(key, []).append(callback)
57
+
58
+ def remove_callback() -> None:
59
+ """Remove the callback for the specific key."""
60
+ if cb_list := self._callbacks.get(key):
61
+ cb_list.remove(callback)
62
+ if not cb_list:
63
+ del self._callbacks[key]
64
+
65
+ return remove_callback
66
+
67
+ def get_callbacks(self, key: K) -> list[Callable[[V], None]]:
68
+ """Get all callbacks for a specific key."""
69
+ return self._callbacks.get(key, [])
70
+
71
+ def __call__(self, key: K, value: V) -> None:
72
+ """Invoke all callbacks for a specific key."""
73
+ for callback in self.get_callbacks(key):
74
+ safe_callback(callback, self._logger)(value)
75
+
76
+
77
+ class CallbackList(Generic[V]):
78
+ """A list of callbacks that can be invoked.
79
+
80
+ This combines a list of callbacks into a single callable. Callers can add
81
+ additional callbacks to the list at any time.
82
+ """
83
+
84
+ def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None:
85
+ self._callbacks: list[Callable[[V], None]] = []
86
+ self._logger = logger or _LOGGER
87
+
88
+ def add_callback(self, callback: Callable[[V], None]) -> Callable[[], None]:
89
+ """Add a callback to the list.
90
+
91
+ Any failures during callback execution will be logged.
92
+
93
+ Returns a callable that can be used to remove the callback.
94
+ """
95
+ self._callbacks.append(callback)
96
+
97
+ return lambda: self._callbacks.remove(callback)
98
+
99
+ def __call__(self, value: V) -> None:
100
+ """Invoke all callbacks in the list."""
101
+ for callback in self._callbacks:
102
+ safe_callback(callback, self._logger)(value)
103
+
104
+
105
+ def decoder_callback(
106
+ decoder: Callable[[K], list[V]],
107
+ callback: Callable[[V], None],
108
+ logger: logging.Logger | logging.LoggerAdapter | None = None,
109
+ ) -> Callable[[K], None]:
110
+ """Create a callback that decodes messages using a decoder and invokes a callback.
111
+
112
+ The decoder converts a value into a list of values. The callback is then invoked
113
+ for each value in the list.
114
+
115
+ Any failures during decoding or invoking the callbacks will be logged.
116
+ """
117
+ if logger is None:
118
+ logger = _LOGGER
119
+
120
+ safe_cb = safe_callback(callback, logger)
121
+
122
+ def wrapper(data: K) -> None:
123
+ if not (messages := decoder(data)):
124
+ logger.debug("Failed to decode message: %s", data)
125
+ return
126
+ for message in messages:
127
+ logger.debug("Decoded message: %s", message)
128
+ safe_cb(message)
129
+
130
+ return wrapper