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,269 @@
1
+ """Module for discovering Roborock devices."""
2
+
3
+ import asyncio
4
+ import enum
5
+ import logging
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import aiohttp
11
+
12
+ from roborock_cli._vendor.roborock.data import (
13
+ HomeData,
14
+ HomeDataDevice,
15
+ HomeDataProduct,
16
+ UserData,
17
+ )
18
+ from roborock_cli._vendor.roborock.devices.device import DeviceReadyCallback, RoborockDevice
19
+ from roborock_cli._vendor.roborock.diagnostics import Diagnostics, redact_device_data
20
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
21
+ from roborock_cli._vendor.roborock.map.map_parser import MapParserConfig
22
+ from roborock_cli._vendor.roborock.mqtt.roborock_session import create_lazy_mqtt_session
23
+ from roborock_cli._vendor.roborock.mqtt.session import MqttSession, SessionUnauthorizedHook
24
+ from roborock_cli._vendor.roborock.protocol import create_mqtt_params
25
+ from roborock_cli._vendor.roborock.web_api import RoborockApiClient, UserWebApiClient
26
+
27
+ from .cache import Cache, DeviceCache, NoCache
28
+ from .rpc.v1_channel import create_v1_channel
29
+ from .traits import Trait, a01, b01, v1
30
+ from .transport.channel import Channel
31
+ from .transport.mqtt_channel import create_mqtt_channel
32
+
33
+ _LOGGER = logging.getLogger(__name__)
34
+
35
+ __all__ = [
36
+ "create_device_manager",
37
+ "UserParams",
38
+ "DeviceManager",
39
+ ]
40
+
41
+
42
+ DeviceCreator = Callable[[HomeData, HomeDataDevice, HomeDataProduct], RoborockDevice]
43
+
44
+
45
+ class DeviceVersion(enum.StrEnum):
46
+ """Enum for device versions."""
47
+
48
+ V1 = "1.0"
49
+ A01 = "A01"
50
+ B01 = "B01"
51
+ UNKNOWN = "unknown"
52
+
53
+
54
+ class UnsupportedDeviceError(RoborockException):
55
+ """Exception raised when a device is unsupported."""
56
+
57
+
58
+ class DeviceManager:
59
+ """Central manager for Roborock device discovery and connections."""
60
+
61
+ def __init__(
62
+ self,
63
+ web_api: UserWebApiClient,
64
+ device_creator: DeviceCreator,
65
+ mqtt_session: MqttSession,
66
+ cache: Cache,
67
+ diagnostics: Diagnostics,
68
+ ) -> None:
69
+ """Initialize the DeviceManager with user data and optional cache storage.
70
+
71
+ This takes ownership of the MQTT session and will close it when the manager is closed.
72
+ """
73
+ self._web_api = web_api
74
+ self._cache = cache
75
+ self._device_creator = device_creator
76
+ self._devices: dict[str, RoborockDevice] = {}
77
+ self._mqtt_session = mqtt_session
78
+ self._diagnostics = diagnostics
79
+ self._home_data: HomeData | None = None
80
+
81
+ async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]:
82
+ """Discover all devices for the logged-in user."""
83
+ self._diagnostics.increment("discover_devices")
84
+ cache_data = await self._cache.get()
85
+ if not cache_data.home_data or not prefer_cache:
86
+ _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache)
87
+ self._diagnostics.increment("fetch_home_data")
88
+ try:
89
+ cache_data.home_data = await self._web_api.get_home_data()
90
+ except RoborockException as ex:
91
+ if not cache_data.home_data:
92
+ raise
93
+ _LOGGER.debug("Failed to fetch home data, using cached data: %s", ex)
94
+ await self._cache.set(cache_data)
95
+ self._home_data = cache_data.home_data
96
+
97
+ device_products = self._home_data.device_products
98
+ _LOGGER.debug("Discovered %d devices", len(device_products))
99
+
100
+ # These are connected serially to avoid overwhelming the MQTT broker
101
+ new_devices = {}
102
+ start_tasks = []
103
+ supported_devices_counter = self._diagnostics.subkey("supported_devices")
104
+ unsupported_devices_counter = self._diagnostics.subkey("unsupported_devices")
105
+ for duid, (device, product) in device_products.items():
106
+ _LOGGER.debug("[%s] Discovered device %s %s", duid, product.summary_info(), device.summary_info())
107
+ if duid in self._devices:
108
+ continue
109
+ try:
110
+ new_device = self._device_creator(self._home_data, device, product)
111
+ except UnsupportedDeviceError:
112
+ _LOGGER.info("Skipping unsupported device %s %s", product.summary_info(), device.summary_info())
113
+ unsupported_devices_counter.increment(device.pv or "unknown")
114
+ continue
115
+ supported_devices_counter.increment(device.pv or "unknown")
116
+ start_tasks.append(new_device.start_connect())
117
+ new_devices[duid] = new_device
118
+
119
+ self._devices.update(new_devices)
120
+ await asyncio.gather(*start_tasks)
121
+ return list(self._devices.values())
122
+
123
+ async def get_device(self, duid: str) -> RoborockDevice | None:
124
+ """Get a specific device by DUID."""
125
+ return self._devices.get(duid)
126
+
127
+ async def get_devices(self) -> list[RoborockDevice]:
128
+ """Get all discovered devices."""
129
+ return list(self._devices.values())
130
+
131
+ async def close(self) -> None:
132
+ """Close all MQTT connections and clean up resources."""
133
+ tasks = [device.close() for device in self._devices.values()]
134
+ self._devices.clear()
135
+ tasks.append(self._mqtt_session.close())
136
+ await asyncio.gather(*tasks)
137
+
138
+ def diagnostic_data(self) -> Mapping[str, Any]:
139
+ """Return diagnostics information about the device manager."""
140
+ return {
141
+ "home_data": redact_device_data(self._home_data.as_dict()) if self._home_data else None,
142
+ "devices": [device.diagnostic_data() for device in self._devices.values()],
143
+ "diagnostics": self._diagnostics.as_dict(),
144
+ }
145
+
146
+
147
+ @dataclass
148
+ class UserParams:
149
+ """Parameters for creating a new session with Roborock devices.
150
+
151
+ These parameters include the username, user data for authentication,
152
+ and an optional base URL for the Roborock API. The `user_data` and `base_url`
153
+ parameters are obtained from `RoborockApiClient` during the login process.
154
+ """
155
+
156
+ username: str
157
+ """The username (email) used for logging in."""
158
+
159
+ user_data: UserData
160
+ """This is the user data containing authentication information."""
161
+
162
+ base_url: str | None = None
163
+ """Optional base URL for the Roborock API.
164
+
165
+ This is used to speed up connection times by avoiding the need to
166
+ discover the API base URL each time. If not provided, the API client
167
+ will attempt to discover it automatically which may take multiple requests.
168
+ """
169
+
170
+
171
+ def create_web_api_wrapper(
172
+ user_params: UserParams,
173
+ *,
174
+ cache: Cache | None = None,
175
+ session: aiohttp.ClientSession | None = None,
176
+ ) -> UserWebApiClient:
177
+ """Create a home data API wrapper from an existing API client."""
178
+
179
+ # Note: This will auto discover the API base URL. This can be improved
180
+ # by caching this next to `UserData` if needed to avoid unnecessary API calls.
181
+ client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session)
182
+
183
+ return UserWebApiClient(client, user_params.user_data)
184
+
185
+
186
+ async def create_device_manager(
187
+ user_params: UserParams,
188
+ *,
189
+ cache: Cache | None = None,
190
+ map_parser_config: MapParserConfig | None = None,
191
+ session: aiohttp.ClientSession | None = None,
192
+ ready_callback: DeviceReadyCallback | None = None,
193
+ mqtt_session_unauthorized_hook: SessionUnauthorizedHook | None = None,
194
+ prefer_cache: bool = True,
195
+ ) -> DeviceManager:
196
+ """Convenience function to create and initialize a DeviceManager.
197
+
198
+ Args:
199
+ user_params: Parameters for creating the user session.
200
+ cache: Optional cache implementation to use for caching device data.
201
+ map_parser_config: Optional configuration for parsing maps.
202
+ session: Optional aiohttp ClientSession to use for HTTP requests.
203
+ ready_callback: Optional callback to be notified when a device is ready.
204
+ mqtt_session_unauthorized_hook: Optional hook for MQTT session unauthorized
205
+ events which may indicate rate limiting or revoked credentials. The
206
+ caller may use this to refresh authentication tokens as needed.
207
+ prefer_cache: Whether to prefer cached device data over always fetching it from the API.
208
+
209
+ Returns:
210
+ An initialized DeviceManager with discovered devices.
211
+ """
212
+ if cache is None:
213
+ cache = NoCache()
214
+
215
+ web_api = create_web_api_wrapper(user_params, session=session, cache=cache)
216
+ user_data = user_params.user_data
217
+
218
+ diagnostics = Diagnostics()
219
+
220
+ mqtt_params = create_mqtt_params(user_data.rriot)
221
+ mqtt_params.diagnostics = diagnostics.subkey("mqtt_session")
222
+ mqtt_params.unauthorized_hook = mqtt_session_unauthorized_hook
223
+ mqtt_session = await create_lazy_mqtt_session(mqtt_params)
224
+
225
+ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
226
+ channel: Channel
227
+ trait: Trait
228
+ device_cache: DeviceCache = DeviceCache(device.duid, cache)
229
+ match device.pv:
230
+ case DeviceVersion.V1:
231
+ channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
232
+ trait = v1.create(
233
+ device.duid,
234
+ product,
235
+ home_data,
236
+ channel.rpc_channel,
237
+ channel.mqtt_rpc_channel,
238
+ channel.map_rpc_channel,
239
+ web_api,
240
+ device_cache=device_cache,
241
+ map_parser_config=map_parser_config,
242
+ region=user_data.region,
243
+ )
244
+ case DeviceVersion.A01:
245
+ channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
246
+ trait = a01.create(product, channel)
247
+ case DeviceVersion.B01:
248
+ channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
249
+ model_part = product.model.split(".")[-1]
250
+ if "ss" in model_part:
251
+ trait = b01.q10.create(channel)
252
+ elif "sc" in model_part:
253
+ # Q7 devices start with 'sc' in their model naming.
254
+ trait = b01.q7.create(channel)
255
+ else:
256
+ raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
257
+ case _:
258
+ raise UnsupportedDeviceError(
259
+ f"Device {device.name} has unsupported version {device.pv} {product.model}"
260
+ )
261
+
262
+ dev = RoborockDevice(device, product, channel, trait)
263
+ if ready_callback:
264
+ dev.add_ready_callback(ready_callback)
265
+ return dev
266
+
267
+ manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache, diagnostics=diagnostics)
268
+ await manager.discover_devices(prefer_cache)
269
+ return manager
@@ -0,0 +1,79 @@
1
+ """This module implements a file-backed cache for device information.
2
+
3
+ This module provides a `FileCache` class that implements the `Cache` protocol
4
+ to store and retrieve cached device information from a file on disk. This allows
5
+ persistent caching of device data across application restarts.
6
+ """
7
+
8
+ import asyncio
9
+ import pathlib
10
+ import pickle
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from .cache import Cache, CacheData
15
+
16
+
17
+ class FileCache(Cache):
18
+ """File backed cache implementation."""
19
+
20
+ def __init__(
21
+ self,
22
+ file_path: pathlib.Path,
23
+ init_fn: Callable[[], CacheData] = CacheData,
24
+ serialize_fn: Callable[[Any], bytes] = pickle.dumps,
25
+ deserialize_fn: Callable[[bytes], Any] = pickle.loads,
26
+ ) -> None:
27
+ """Initialize the file cache with the given file path."""
28
+ self._init_fn = init_fn
29
+ self._file_path = file_path
30
+ self._cache_data: CacheData | None = None
31
+ self._serialize_fn = serialize_fn
32
+ self._deserialize_fn = deserialize_fn
33
+
34
+ async def get(self) -> CacheData:
35
+ """Get cached value."""
36
+ if self._cache_data is not None:
37
+ return self._cache_data
38
+ data = await load_value(self._file_path, self._deserialize_fn)
39
+ if data is not None and not isinstance(data, CacheData):
40
+ raise TypeError(f"Invalid cache data loaded from {self._file_path}")
41
+
42
+ self._cache_data = data or self._init_fn()
43
+ return self._cache_data
44
+
45
+ async def set(self, value: CacheData) -> None: # type: ignore[override]
46
+ """Set value in the cache."""
47
+ self._cache_data = value
48
+
49
+ async def flush(self) -> None:
50
+ """Flush the cache to disk."""
51
+ if self._cache_data is None:
52
+ return
53
+ await store_value(self._file_path, self._cache_data, self._serialize_fn)
54
+
55
+
56
+ async def store_value(file_path: pathlib.Path, value: Any, serialize_fn: Callable[[Any], bytes] = pickle.dumps) -> None:
57
+ """Store a value to the given file path."""
58
+
59
+ def _store_to_disk(file_path: pathlib.Path, value: Any) -> None:
60
+ with open(file_path, "wb") as f:
61
+ data = serialize_fn(value)
62
+ f.write(data)
63
+
64
+ await asyncio.to_thread(_store_to_disk, file_path, value)
65
+
66
+
67
+ async def load_value(file_path: pathlib.Path, deserialize_fn: Callable[[bytes], Any] = pickle.loads) -> Any | None:
68
+ """Load a value from the given file path."""
69
+
70
+ def _load_from_disk(file_path: pathlib.Path) -> Any | None:
71
+ if not file_path.exists():
72
+ return None
73
+ with open(file_path, "rb") as f:
74
+ data = f.read()
75
+ if not data: # 空文件视为不存在
76
+ return None
77
+ return deserialize_fn(data)
78
+
79
+ return await asyncio.to_thread(_load_from_disk, file_path)
@@ -0,0 +1,14 @@
1
+ """Module for sending device specific commands to Roborock devices.
2
+
3
+ This module provides a application-level interface for sending commands to Roborock
4
+ devices. These modules can be used by traits (higher level APIs) to send commands.
5
+
6
+ Each module may contain details that are common across all traits, and may depend
7
+ on the transport level modules (e.g. MQTT, Local device) for issuing the
8
+ commands.
9
+
10
+ The lowest level protocol encoding is handled in `roborock.protocols` which
11
+ have no dependencies on the transport level modules.
12
+ """
13
+
14
+ __all__: list[str] = []
@@ -0,0 +1,94 @@
1
+ """Thin wrapper around the MQTT channel for Roborock A01 devices."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from typing import Any, overload
7
+
8
+ from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
9
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
10
+ from roborock_cli._vendor.roborock.protocols.a01_protocol import (
11
+ decode_rpc_response,
12
+ encode_mqtt_payload,
13
+ )
14
+ from roborock_cli._vendor.roborock.roborock_message import (
15
+ RoborockDyadDataProtocol,
16
+ RoborockMessage,
17
+ RoborockZeoProtocol,
18
+ )
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+ _TIMEOUT = 10.0
22
+
23
+ # Both RoborockDyadDataProtocol and RoborockZeoProtocol have the same
24
+ # value for ID_QUERY
25
+ _ID_QUERY = int(RoborockDyadDataProtocol.ID_QUERY)
26
+
27
+
28
+ @overload
29
+ async def send_decoded_command(
30
+ mqtt_channel: MqttChannel,
31
+ params: dict[RoborockDyadDataProtocol, Any],
32
+ value_encoder: Callable[[Any], Any] | None = None,
33
+ ) -> dict[RoborockDyadDataProtocol, Any]: ...
34
+
35
+
36
+ @overload
37
+ async def send_decoded_command(
38
+ mqtt_channel: MqttChannel,
39
+ params: dict[RoborockZeoProtocol, Any],
40
+ value_encoder: Callable[[Any], Any] | None = None,
41
+ ) -> dict[RoborockZeoProtocol, Any]: ...
42
+
43
+
44
+ async def send_decoded_command(
45
+ mqtt_channel: MqttChannel,
46
+ params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
47
+ value_encoder: Callable[[Any], Any] | None = None,
48
+ ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
49
+ """Send a command on the MQTT channel and get a decoded response."""
50
+ _LOGGER.debug("Sending MQTT command: %s", params)
51
+ roborock_message = encode_mqtt_payload(params, value_encoder)
52
+
53
+ # For commands that set values: send the command and do not
54
+ # block waiting for a response. Queries are handled below.
55
+ param_values = {int(k): v for k, v in params.items()}
56
+ if not (query_values := param_values.get(_ID_QUERY)):
57
+ await mqtt_channel.publish(roborock_message)
58
+ return {}
59
+
60
+ # Merge any results together than contain the requested data. This
61
+ # does not use a future since it needs to merge results across responses.
62
+ # This could be simplified if we can assume there is a single response.
63
+ finished = asyncio.Event()
64
+ result: dict[int, Any] = {}
65
+
66
+ def find_response(response_message: RoborockMessage) -> None:
67
+ """Handle incoming messages and resolve the future."""
68
+ try:
69
+ decoded = decode_rpc_response(response_message)
70
+ except RoborockException as ex:
71
+ _LOGGER.info("Failed to decode a01 message: %s: %s", response_message, ex)
72
+ return
73
+ for key, value in decoded.items():
74
+ if key in query_values:
75
+ result[key] = value
76
+ if len(result) != len(query_values):
77
+ _LOGGER.debug("Incomplete query response: %s != %s", result, query_values)
78
+ return
79
+ _LOGGER.debug("Received query response: %s", result)
80
+ if not finished.is_set():
81
+ finished.set()
82
+
83
+ unsub = await mqtt_channel.subscribe(find_response)
84
+
85
+ try:
86
+ await mqtt_channel.publish(roborock_message)
87
+ try:
88
+ await asyncio.wait_for(finished.wait(), timeout=_TIMEOUT)
89
+ except TimeoutError as ex:
90
+ raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
91
+ finally:
92
+ unsub()
93
+
94
+ return result # type: ignore[return-value]
@@ -0,0 +1,57 @@
1
+ """Thin wrapper around the MQTT channel for Roborock B01 Q10 devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import AsyncGenerator
7
+ from typing import Any
8
+
9
+ from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
10
+ from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
11
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
12
+ from roborock_cli._vendor.roborock.protocols.b01_q10_protocol import (
13
+ ParamsType,
14
+ decode_rpc_response,
15
+ encode_mqtt_payload,
16
+ )
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ async def stream_decoded_responses(
22
+ mqtt_channel: MqttChannel,
23
+ ) -> AsyncGenerator[dict[B01_Q10_DP, Any], None]:
24
+ """Stream decoded DPS messages received via MQTT."""
25
+
26
+ async for response_message in mqtt_channel.subscribe_stream():
27
+ try:
28
+ decoded_dps = decode_rpc_response(response_message)
29
+ except RoborockException as ex:
30
+ _LOGGER.debug(
31
+ "Failed to decode B01 Q10 RPC response: %s: %s",
32
+ response_message,
33
+ ex,
34
+ )
35
+ continue
36
+ yield decoded_dps
37
+
38
+
39
+ async def send_command(
40
+ mqtt_channel: MqttChannel,
41
+ command: B01_Q10_DP,
42
+ params: ParamsType,
43
+ ) -> None:
44
+ """Send a command on the MQTT channel, without waiting for a response"""
45
+ _LOGGER.debug("Sending B01 MQTT command: cmd=%s params=%s", command, params)
46
+ roborock_message = encode_mqtt_payload(command, params)
47
+ _LOGGER.debug("Sending MQTT message: %s", roborock_message)
48
+ try:
49
+ await mqtt_channel.publish(roborock_message)
50
+ except RoborockException as ex:
51
+ _LOGGER.debug(
52
+ "Error sending B01 decoded command (method=%s params=%s): %s",
53
+ command,
54
+ params,
55
+ ex,
56
+ )
57
+ raise
@@ -0,0 +1,101 @@
1
+ """Thin wrapper around the MQTT channel for Roborock B01 Q7 devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
11
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
12
+ from roborock_cli._vendor.roborock.protocols.b01_q7_protocol import (
13
+ Q7RequestMessage,
14
+ decode_rpc_response,
15
+ encode_mqtt_payload,
16
+ )
17
+ from roborock_cli._vendor.roborock.roborock_message import RoborockMessage
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+ _TIMEOUT = 10.0
21
+
22
+
23
+ async def send_decoded_command(
24
+ mqtt_channel: MqttChannel,
25
+ request_message: Q7RequestMessage,
26
+ ) -> dict[str, Any] | None:
27
+ """Send a command on the MQTT channel and get a decoded response."""
28
+ _LOGGER.debug("Sending B01 MQTT command: %s", request_message)
29
+ roborock_message = encode_mqtt_payload(request_message)
30
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
31
+
32
+ def find_response(response_message: RoborockMessage) -> None:
33
+ """Handle incoming messages and resolve the future."""
34
+ try:
35
+ decoded_dps = decode_rpc_response(response_message)
36
+ except RoborockException as ex:
37
+ _LOGGER.debug(
38
+ "Failed to decode B01 RPC response (expecting method=%s msg_id=%s): %s: %s",
39
+ request_message.command,
40
+ request_message.msg_id,
41
+ response_message,
42
+ ex,
43
+ )
44
+ return
45
+ for dps_value in decoded_dps.values():
46
+ # valid responses are JSON strings wrapped in the dps value
47
+ if not isinstance(dps_value, str):
48
+ _LOGGER.debug("Received unexpected response: %s", dps_value)
49
+ continue
50
+
51
+ try:
52
+ inner = json.loads(dps_value)
53
+ except (json.JSONDecodeError, TypeError):
54
+ _LOGGER.debug("Received unexpected response: %s", dps_value)
55
+ continue
56
+ if isinstance(inner, dict) and inner.get("msgId") == str(request_message.msg_id):
57
+ _LOGGER.debug("Received query response: %s", inner)
58
+ # Check for error code (0 = success, non-zero = error)
59
+ code = inner.get("code", 0)
60
+ if code != 0:
61
+ error_msg = f"B01 command failed with code {code} ({request_message})"
62
+ _LOGGER.debug("B01 error response: %s", error_msg)
63
+ if not future.done():
64
+ future.set_exception(RoborockException(error_msg))
65
+ return
66
+ data = inner.get("data")
67
+ # All get commands should be dicts
68
+ if request_message.command.endswith(".get") and not isinstance(data, dict):
69
+ if not future.done():
70
+ future.set_exception(
71
+ RoborockException(f"Unexpected data type for response {data} ({request_message})")
72
+ )
73
+ return
74
+ if not future.done():
75
+ future.set_result(data)
76
+
77
+ unsub = await mqtt_channel.subscribe(find_response)
78
+
79
+ _LOGGER.debug("Sending MQTT message: %s", roborock_message)
80
+ try:
81
+ await mqtt_channel.publish(roborock_message)
82
+ return await asyncio.wait_for(future, timeout=_TIMEOUT)
83
+ except TimeoutError as ex:
84
+ raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex
85
+ except RoborockException as ex:
86
+ _LOGGER.warning(
87
+ "Error sending B01 decoded command (%ss): %s",
88
+ request_message,
89
+ ex,
90
+ )
91
+ raise
92
+
93
+ except Exception as ex:
94
+ _LOGGER.exception(
95
+ "Error sending B01 decoded command (%ss): %s",
96
+ request_message,
97
+ ex,
98
+ )
99
+ raise
100
+ finally:
101
+ unsub()