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,457 @@
1
+ """V1 Channel for Roborock devices.
2
+
3
+ This module provides a unified channel interface for V1 protocol devices,
4
+ handling both MQTT and local connections with automatic fallback.
5
+ """
6
+
7
+ import asyncio
8
+ import datetime
9
+ import logging
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Any, TypeVar
13
+
14
+ from roborock_cli._vendor.roborock.data import HomeDataDevice, NetworkInfo, RoborockBase, UserData
15
+ from roborock_cli._vendor.roborock.devices.cache import DeviceCache
16
+ from roborock_cli._vendor.roborock.devices.transport.channel import Channel
17
+ from roborock_cli._vendor.roborock.devices.transport.local_channel import LocalChannel, LocalSession, create_local_session
18
+ from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
19
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
20
+ from roborock_cli._vendor.roborock.mqtt.health_manager import HealthManager
21
+ from roborock_cli._vendor.roborock.mqtt.session import MqttParams, MqttSession
22
+ from roborock_cli._vendor.roborock.protocols.v1_protocol import (
23
+ CommandType,
24
+ MapResponse,
25
+ ParamsType,
26
+ RequestMessage,
27
+ ResponseData,
28
+ ResponseMessage,
29
+ SecurityData,
30
+ V1RpcChannel,
31
+ create_map_response_decoder,
32
+ create_security_data,
33
+ decode_rpc_response,
34
+ )
35
+ from roborock_cli._vendor.roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
36
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
37
+ from roborock_cli._vendor.roborock.util import RoborockLoggerAdapter
38
+
39
+ _LOGGER = logging.getLogger(__name__)
40
+
41
+ __all__ = [
42
+ "create_v1_channel",
43
+ ]
44
+
45
+ _T = TypeVar("_T", bound=RoborockBase)
46
+ _TIMEOUT = 10.0
47
+
48
+
49
+ # Exponential backoff parameters for reconnecting to local
50
+ MIN_RECONNECT_INTERVAL = datetime.timedelta(minutes=1)
51
+ MAX_RECONNECT_INTERVAL = datetime.timedelta(minutes=10)
52
+ RECONNECT_MULTIPLIER = 1.5
53
+ # After this many hours, the network info is refreshed
54
+ NETWORK_INFO_REFRESH_INTERVAL = datetime.timedelta(hours=12)
55
+ # Interval to check that the local connection is healthy
56
+ LOCAL_CONNECTION_CHECK_INTERVAL = datetime.timedelta(seconds=15)
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class RpcStrategy:
61
+ """Strategy for encoding/sending/decoding RPC commands."""
62
+
63
+ name: str # For debug logging
64
+ channel: LocalChannel | MqttChannel
65
+ encoder: Callable[[RequestMessage], RoborockMessage]
66
+ decoder: Callable[[RoborockMessage], ResponseMessage | MapResponse | None]
67
+ health_manager: HealthManager | None = None
68
+
69
+
70
+ class RpcChannel(V1RpcChannel):
71
+ """Provides an RPC interface around a pub/sub transport channel."""
72
+
73
+ def __init__(self, rpc_strategies_cb: Callable[[], list[RpcStrategy]], logger: RoborockLoggerAdapter) -> None:
74
+ """Initialize the RpcChannel with an ordered list of strategies."""
75
+ self._rpc_strategies_cb = rpc_strategies_cb
76
+ self._logger = logger
77
+
78
+ async def send_command(
79
+ self,
80
+ method: CommandType,
81
+ *,
82
+ response_type: type[_T] | None = None,
83
+ params: ParamsType = None,
84
+ ) -> _T | Any:
85
+ """Send a command and return either a decoded or parsed response."""
86
+ request = RequestMessage(method, params=params)
87
+
88
+ # Try each channel in order until one succeeds
89
+ last_exception = None
90
+ for strategy in self._rpc_strategies_cb():
91
+ try:
92
+ decoded_response = await self._send_rpc(strategy, request, self._logger)
93
+ except RoborockException as e:
94
+ self._logger.debug("Command %s failed on %s channel: %s", method, strategy.name, e)
95
+ last_exception = e
96
+ except Exception as e:
97
+ self._logger.exception("Unexpected error sending command %s on %s channel", method, strategy.name)
98
+ last_exception = RoborockException(f"Unexpected error: {e}")
99
+ else:
100
+ if response_type is not None:
101
+ if not isinstance(decoded_response, dict):
102
+ raise RoborockException(
103
+ f"Expected dict response to parse {response_type.__name__}, got {type(decoded_response)}"
104
+ )
105
+ return response_type.from_dict(decoded_response)
106
+ return decoded_response
107
+
108
+ raise last_exception or RoborockException("No available connection to send command")
109
+
110
+ @staticmethod
111
+ async def _send_rpc(
112
+ strategy: RpcStrategy, request: RequestMessage, logger: RoborockLoggerAdapter
113
+ ) -> ResponseData | bytes:
114
+ """Send a command and return a decoded response type.
115
+
116
+ This provides an RPC interface over a given channel strategy. The device
117
+ channel only supports publish and subscribe, so this function handles
118
+ associating requests with their corresponding responses.
119
+ """
120
+ future: asyncio.Future[ResponseData | bytes] = asyncio.Future()
121
+ logger.debug(
122
+ "Sending command (%s, request_id=%s): %s, params=%s",
123
+ strategy.name,
124
+ request.request_id,
125
+ request.method,
126
+ request.params,
127
+ )
128
+
129
+ message = strategy.encoder(request)
130
+
131
+ def find_response(response_message: RoborockMessage) -> None:
132
+ try:
133
+ decoded = strategy.decoder(response_message)
134
+ except RoborockException as ex:
135
+ logger.debug("Exception while decoding message (%s): %s", response_message, ex)
136
+ return
137
+ if decoded is None:
138
+ return
139
+ logger.debug("Received response (%s, request_id=%s)", strategy.name, decoded.request_id)
140
+ if decoded.request_id == request.request_id:
141
+ if isinstance(decoded, ResponseMessage) and decoded.api_error:
142
+ future.set_exception(decoded.api_error)
143
+ else:
144
+ future.set_result(decoded.data)
145
+
146
+ unsub = await strategy.channel.subscribe(find_response)
147
+ try:
148
+ await strategy.channel.publish(message)
149
+ result = await asyncio.wait_for(future, timeout=_TIMEOUT)
150
+ except TimeoutError as ex:
151
+ if strategy.health_manager:
152
+ await strategy.health_manager.on_timeout()
153
+ future.cancel()
154
+ raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
155
+ finally:
156
+ unsub()
157
+ if strategy.health_manager:
158
+ await strategy.health_manager.on_success()
159
+ return result
160
+
161
+
162
+ class V1Channel(Channel):
163
+ """Unified V1 protocol channel with automatic MQTT/local connection handling.
164
+
165
+ This channel abstracts away the complexity of choosing between MQTT and local
166
+ connections, and provides high-level V1 protocol methods. It automatically
167
+ handles connection setup, fallback logic, and protocol encoding/decoding.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ device_uid: str,
173
+ security_data: SecurityData,
174
+ mqtt_channel: MqttChannel,
175
+ local_session: LocalSession,
176
+ device_cache: DeviceCache,
177
+ ) -> None:
178
+ """Initialize the V1Channel."""
179
+ self._device_uid = device_uid
180
+ self._logger = RoborockLoggerAdapter(duid=device_uid, logger=_LOGGER)
181
+ self._security_data = security_data
182
+ self._mqtt_channel = mqtt_channel
183
+ self._local_session = local_session
184
+ self._local_channel: LocalChannel | None = None
185
+ self._mqtt_unsub: Callable[[], None] | None = None
186
+ self._local_unsub: Callable[[], None] | None = None
187
+ self._callback: Callable[[RoborockMessage], None] | None = None
188
+ self._device_cache = device_cache
189
+ self._reconnect_task: asyncio.Task[None] | None = None
190
+ self._last_network_info_refresh: datetime.datetime | None = None
191
+
192
+ @property
193
+ def is_connected(self) -> bool:
194
+ """Return whether any connection is available."""
195
+ return self.is_mqtt_connected or self.is_local_connected
196
+
197
+ @property
198
+ def is_local_connected(self) -> bool:
199
+ """Return whether local connection is available."""
200
+ return self._local_channel is not None and self._local_channel.is_connected
201
+
202
+ @property
203
+ def is_mqtt_connected(self) -> bool:
204
+ """Return whether MQTT connection is available."""
205
+ return self._mqtt_channel.is_connected
206
+
207
+ @property
208
+ def rpc_channel(self) -> V1RpcChannel:
209
+ """Return the combined RPC channel that prefers local with a fallback to MQTT.
210
+
211
+ The returned V1RpcChannel may be long lived and will respect the
212
+ current connection state of the underlying channels.
213
+ """
214
+
215
+ def rpc_strategies_cb() -> list[RpcStrategy]:
216
+ strategies = []
217
+ if local_rpc_strategy := self._create_local_rpc_strategy():
218
+ strategies.append(local_rpc_strategy)
219
+ strategies.append(self._create_mqtt_rpc_strategy())
220
+ return strategies
221
+
222
+ return RpcChannel(rpc_strategies_cb, self._logger)
223
+
224
+ @property
225
+ def mqtt_rpc_channel(self) -> V1RpcChannel:
226
+ """Return the MQTT-only RPC channel.
227
+
228
+ The returned V1RpcChannel may be long lived and will respect the
229
+ current connection state of the underlying channels.
230
+ """
231
+ return RpcChannel(lambda: [self._create_mqtt_rpc_strategy()], self._logger)
232
+
233
+ @property
234
+ def map_rpc_channel(self) -> V1RpcChannel:
235
+ """Return the map RPC channel used for fetching map content."""
236
+ decoder = create_map_response_decoder(security_data=self._security_data)
237
+ return RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
238
+
239
+ def _create_local_rpc_strategy(self) -> RpcStrategy | None:
240
+ """Create the RPC strategy for local transport."""
241
+ if self._local_channel is None or not self.is_local_connected:
242
+ return None
243
+ return RpcStrategy(
244
+ name="local",
245
+ channel=self._local_channel,
246
+ encoder=self._local_encoder,
247
+ decoder=decode_rpc_response,
248
+ )
249
+
250
+ def _local_encoder(self, x: RequestMessage) -> RoborockMessage:
251
+ """Encode a request message for local transport.
252
+
253
+ This will read the current local channel's protocol version which
254
+ changes as the protocol version is discovered.
255
+ """
256
+ if self._local_channel is None:
257
+ raise ValueError("Local channel unavailable for encoding")
258
+ return x.encode_message(
259
+ RoborockMessageProtocol.GENERAL_REQUEST,
260
+ version=self._local_channel.protocol_version,
261
+ )
262
+
263
+ def _create_mqtt_rpc_strategy(self, decoder: Callable[[RoborockMessage], Any] = decode_rpc_response) -> RpcStrategy:
264
+ """Create the RPC strategy for MQTT transport with optional custom decoder."""
265
+ return RpcStrategy(
266
+ name="mqtt",
267
+ channel=self._mqtt_channel,
268
+ encoder=lambda x: x.encode_message(
269
+ RoborockMessageProtocol.RPC_REQUEST,
270
+ security_data=self._security_data,
271
+ ),
272
+ decoder=decoder,
273
+ health_manager=self._mqtt_channel.health_manager,
274
+ )
275
+
276
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
277
+ """Subscribe to all messages from the device.
278
+
279
+ This will first attempt to establish a local connection to the device
280
+ using cached network information if available. If that fails, it will
281
+ fall back to using the MQTT connection.
282
+
283
+ A background task will be started to monitor and maintain the local
284
+ connection, attempting to reconnect as needed.
285
+
286
+ Args:
287
+ callback: Callback to invoke for each received message.
288
+
289
+ Returns:
290
+ Unsubscribe function to stop receiving messages and clean up resources.
291
+ """
292
+ if self._callback is not None:
293
+ raise ValueError("Only one subscription allowed at a time")
294
+
295
+ # Make an initial, optimistic attempt to connect to local with the
296
+ # cache. The cache information will be refreshed by the background task.
297
+ try:
298
+ await self._local_connect(prefer_cache=True)
299
+ except RoborockException as err:
300
+ self._logger.debug("First local connection attempt failed, will retry: %s", err)
301
+
302
+ # Start a background task to manage the local connection health. This
303
+ # happens independent of whether we were able to connect locally now.
304
+ if self._reconnect_task is None:
305
+ loop = asyncio.get_running_loop()
306
+ self._reconnect_task = loop.create_task(self._background_reconnect())
307
+
308
+ if not self.is_local_connected:
309
+ # We were not able to connect locally, so fallback to MQTT and at least
310
+ # establish that connection explicitly. If this fails then raise an
311
+ # error and let the caller know we failed to subscribe.
312
+ self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
313
+ self._logger.debug("V1Channel connected to device via MQTT")
314
+
315
+ def unsub() -> None:
316
+ """Unsubscribe from all messages."""
317
+ if self._reconnect_task:
318
+ self._reconnect_task.cancel()
319
+ self._reconnect_task = None
320
+ if self._mqtt_unsub:
321
+ self._mqtt_unsub()
322
+ self._mqtt_unsub = None
323
+ if self._local_unsub:
324
+ self._local_unsub()
325
+ self._local_unsub = None
326
+ self._callback = None # 修复:清除 callback,允许重新订阅
327
+ self._logger.debug("Unsubscribed from device")
328
+
329
+ self._callback = callback
330
+ return unsub
331
+
332
+ async def _get_networking_info(self, *, prefer_cache: bool = True) -> NetworkInfo:
333
+ """Retrieve networking information for the device.
334
+
335
+ This is a cloud only command used to get the local device's IP address.
336
+ """
337
+ device_cache_data = await self._device_cache.get()
338
+
339
+ if prefer_cache and device_cache_data.network_info:
340
+ self._logger.debug("Using cached network info")
341
+ return device_cache_data.network_info
342
+ try:
343
+ network_info = await self.mqtt_rpc_channel.send_command(
344
+ RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
345
+ )
346
+ except RoborockException as e:
347
+ self._logger.debug("Error fetching network info for device")
348
+ if device_cache_data.network_info:
349
+ self._logger.debug("Falling back to cached network info after error")
350
+ return device_cache_data.network_info
351
+ raise RoborockException(f"Network info failed for device {self._device_uid}") from e
352
+ self._logger.debug("Network info for device: %s", network_info)
353
+ self._last_network_info_refresh = datetime.datetime.now(datetime.UTC)
354
+
355
+ device_cache_data = await self._device_cache.get()
356
+ device_cache_data.network_info = network_info
357
+ await self._device_cache.set(device_cache_data)
358
+ return network_info
359
+
360
+ async def _local_connect(self, *, prefer_cache: bool = True) -> None:
361
+ """Set up local connection if possible."""
362
+ self._logger.debug("Attempting to connect to local channel (prefer_cache=%s)", prefer_cache)
363
+ networking_info = await self._get_networking_info(prefer_cache=prefer_cache)
364
+ host = networking_info.ip
365
+ self._logger.debug("Connecting to local channel at %s", host)
366
+ # Create a new local channel and connect
367
+ local_channel = self._local_session(host)
368
+ try:
369
+ await local_channel.connect()
370
+ except RoborockException as e:
371
+ raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
372
+ # Wire up the new channel
373
+ self._local_channel = local_channel
374
+ self._local_unsub = await self._local_channel.subscribe(self._on_local_message)
375
+ self._logger.info("Connected to local channel successfully")
376
+
377
+ async def _background_reconnect(self) -> None:
378
+ """Task to run in the background to manage the local connection."""
379
+ self._logger.debug("Starting background task to manage local connection")
380
+ reconnect_backoff = MIN_RECONNECT_INTERVAL
381
+ local_connect_failures = 0
382
+
383
+ while True:
384
+ try:
385
+ if self.is_local_connected:
386
+ await asyncio.sleep(LOCAL_CONNECTION_CHECK_INTERVAL.total_seconds())
387
+ continue
388
+
389
+ # Not connected, so wait with backoff before trying to connect.
390
+ # The first time through, we don't sleep, we just try to connect.
391
+ local_connect_failures += 1
392
+ if local_connect_failures > 1:
393
+ await asyncio.sleep(reconnect_backoff.total_seconds())
394
+ reconnect_backoff = min(reconnect_backoff * RECONNECT_MULTIPLIER, MAX_RECONNECT_INTERVAL)
395
+
396
+ use_cache = self._should_use_cache(local_connect_failures)
397
+ await self._local_connect(prefer_cache=use_cache)
398
+ # Reset backoff and failures on success
399
+ reconnect_backoff = MIN_RECONNECT_INTERVAL
400
+ local_connect_failures = 0
401
+
402
+ except asyncio.CancelledError:
403
+ self._logger.debug("Background reconnect task cancelled")
404
+ if self._local_channel:
405
+ self._local_channel.close()
406
+ return
407
+ except RoborockException as err:
408
+ self._logger.debug("Background reconnect failed: %s", err)
409
+ except Exception:
410
+ self._logger.exception("Unhandled exception in background reconnect task")
411
+
412
+ def _should_use_cache(self, local_connect_failures: int) -> bool:
413
+ """Determine whether to use cached network info on retries.
414
+
415
+ On the first retry we'll avoid the cache to handle the case where
416
+ the network ip may have recently changed. Otherwise, use the cache
417
+ if available then expire at some point.
418
+ """
419
+ if local_connect_failures == 1:
420
+ return False
421
+ elif self._last_network_info_refresh and (
422
+ datetime.datetime.now(datetime.UTC) - self._last_network_info_refresh > NETWORK_INFO_REFRESH_INTERVAL
423
+ ):
424
+ return False
425
+ return True
426
+
427
+ def _on_mqtt_message(self, message: RoborockMessage) -> None:
428
+ """Handle incoming MQTT messages."""
429
+ self._logger.debug("V1Channel received MQTT message: %s", message)
430
+ if self._callback:
431
+ self._callback(message)
432
+
433
+ def _on_local_message(self, message: RoborockMessage) -> None:
434
+ """Handle incoming local messages."""
435
+ self._logger.debug("V1Channel received local message: %s", message)
436
+ if self._callback:
437
+ self._callback(message)
438
+
439
+
440
+ def create_v1_channel(
441
+ user_data: UserData,
442
+ mqtt_params: MqttParams,
443
+ mqtt_session: MqttSession,
444
+ device: HomeDataDevice,
445
+ device_cache: DeviceCache,
446
+ ) -> V1Channel:
447
+ """Create a V1Channel for the given device."""
448
+ security_data = create_security_data(user_data.rriot)
449
+ mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
450
+ local_session = create_local_session(device.local_key, device.duid)
451
+ return V1Channel(
452
+ device.duid,
453
+ security_data,
454
+ mqtt_channel,
455
+ local_session=local_session,
456
+ device_cache=device_cache,
457
+ )
@@ -0,0 +1,28 @@
1
+ """Module for device traits.
2
+
3
+ This package contains the trait definitions for different device protocols supported
4
+ by Roborock devices.
5
+
6
+ Submodules
7
+ ----------
8
+ * `v1`: Contains traits for standard Roborock vacuums (e.g., S-series, Q-series).
9
+ These devices use the V1 protocol and have rich feature sets split into
10
+ granular traits (e.g., `StatusTrait`, `ConsumableTrait`).
11
+ * `a01`: Contains APIs for A01 protocol devices, such as the Dyad (wet/dry vacuum)
12
+ and Zeo (washing machine). These devices use a different communication structure.
13
+ * `b01`: Contains APIs for B01 protocol devices.
14
+ """
15
+
16
+ from abc import ABC
17
+
18
+ __all__ = [
19
+ "Trait",
20
+ "traits_mixin",
21
+ "v1",
22
+ "a01",
23
+ "b01",
24
+ ]
25
+
26
+
27
+ class Trait(ABC):
28
+ """Base class for all traits."""
@@ -0,0 +1,191 @@
1
+ """Create traits for A01 devices.
2
+
3
+ This module provides the API implementations for A01 protocol devices, which include
4
+ Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines).
5
+
6
+ Using A01 APIs
7
+ --------------
8
+ A01 devices expose a single API object that handles all device interactions. This API is
9
+ available on the device instance (typically via `device.a01_properties`).
10
+
11
+ The API provides two main methods:
12
+ 1. **query_values(protocols)**: Fetches current state for specific data points.
13
+ You must pass a list of protocol enums (e.g. `RoborockDyadDataProtocol` or
14
+ `RoborockZeoProtocol`) to request specific data.
15
+ 2. **set_value(protocol, value)**: Sends a command to the device to change a setting
16
+ or perform an action.
17
+
18
+ Note that these APIs fetch data directly from the device upon request and do not
19
+ cache state internally.
20
+ """
21
+
22
+ import json
23
+ from collections.abc import Callable
24
+ from datetime import time
25
+ from typing import Any
26
+
27
+ from roborock_cli._vendor.roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory
28
+ from roborock_cli._vendor.roborock.data.dyad.dyad_code_mappings import (
29
+ DyadBrushSpeed,
30
+ DyadCleanMode,
31
+ DyadError,
32
+ DyadSelfCleanLevel,
33
+ DyadSelfCleanMode,
34
+ DyadSuction,
35
+ DyadWarmLevel,
36
+ DyadWaterLevel,
37
+ RoborockDyadStateCode,
38
+ )
39
+ from roborock_cli._vendor.roborock.data.zeo.zeo_code_mappings import (
40
+ ZeoDetergentType,
41
+ ZeoDryingMode,
42
+ ZeoError,
43
+ ZeoMode,
44
+ ZeoProgram,
45
+ ZeoRinse,
46
+ ZeoSoftenerType,
47
+ ZeoSpin,
48
+ ZeoState,
49
+ ZeoTemperature,
50
+ )
51
+ from roborock_cli._vendor.roborock.devices.rpc.a01_channel import send_decoded_command
52
+ from roborock_cli._vendor.roborock.devices.traits import Trait
53
+ from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
54
+ from roborock_cli._vendor.roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
55
+
56
+ __init__ = [
57
+ "DyadApi",
58
+ "ZeoApi",
59
+ ]
60
+
61
+
62
+ DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = {
63
+ RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name,
64
+ RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name,
65
+ RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: lambda val: DyadSelfCleanLevel(val).name,
66
+ RoborockDyadDataProtocol.WARM_LEVEL: lambda val: DyadWarmLevel(val).name,
67
+ RoborockDyadDataProtocol.CLEAN_MODE: lambda val: DyadCleanMode(val).name,
68
+ RoborockDyadDataProtocol.SUCTION: lambda val: DyadSuction(val).name,
69
+ RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name,
70
+ RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name,
71
+ RoborockDyadDataProtocol.POWER: lambda val: int(val),
72
+ RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val),
73
+ RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60),
74
+ RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60),
75
+ RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name,
76
+ RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val),
77
+ RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val),
78
+ RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val),
79
+ RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes
80
+ RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val),
81
+ RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time(
82
+ hour=int(val / 60), minute=val % 60
83
+ ), # in minutes since 00:00
84
+ RoborockDyadDataProtocol.SILENT_MODE_END_TIME: lambda val: time(
85
+ hour=int(val / 60), minute=val % 60
86
+ ), # in minutes since 00:00
87
+ RoborockDyadDataProtocol.RECENT_RUN_TIME: lambda val: [
88
+ int(v) for v in val.split(",")
89
+ ], # minutes of cleaning in past few days.
90
+ RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val),
91
+ RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val),
92
+ RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val),
93
+ }
94
+
95
+ ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = {
96
+ # read-only
97
+ RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name,
98
+ RoborockZeoProtocol.COUNTDOWN: lambda val: int(val),
99
+ RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val),
100
+ RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name,
101
+ RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
102
+ RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val),
103
+ RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val),
104
+ # read-write
105
+ RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
106
+ RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
107
+ RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name,
108
+ RoborockZeoProtocol.RINSE_TIMES: lambda val: ZeoRinse(val).name,
109
+ RoborockZeoProtocol.SPIN_LEVEL: lambda val: ZeoSpin(val).name,
110
+ RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name,
111
+ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name,
112
+ RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name,
113
+ RoborockZeoProtocol.SOUND_SET: lambda val: bool(val),
114
+ }
115
+
116
+
117
+ def convert_dyad_value(protocol_value: RoborockDyadDataProtocol, value: Any) -> Any:
118
+ """Convert a dyad protocol value to its corresponding type."""
119
+ if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None:
120
+ try:
121
+ return converter(value)
122
+ except (ValueError, TypeError):
123
+ return None
124
+ return None
125
+
126
+
127
+ def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any:
128
+ """Convert a zeo protocol value to its corresponding type."""
129
+ if (converter := ZEO_PROTOCOL_ENTRIES.get(protocol_value)) is not None:
130
+ try:
131
+ return converter(value)
132
+ except (ValueError, TypeError):
133
+ return None
134
+ return None
135
+
136
+
137
+ class DyadApi(Trait):
138
+ """API for interacting with Dyad devices."""
139
+
140
+ def __init__(self, channel: MqttChannel) -> None:
141
+ """Initialize the Dyad API."""
142
+ self._channel = channel
143
+
144
+ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
145
+ """Query the device for the values of the given Dyad protocols."""
146
+ response = await send_decoded_command(
147
+ self._channel,
148
+ {RoborockDyadDataProtocol.ID_QUERY: protocols},
149
+ value_encoder=json.dumps,
150
+ )
151
+ return {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols}
152
+
153
+ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
154
+ """Set a value for a specific protocol on the device."""
155
+ params = {protocol: value}
156
+ return await send_decoded_command(self._channel, params)
157
+
158
+
159
+ class ZeoApi(Trait):
160
+ """API for interacting with Zeo devices."""
161
+
162
+ name = "zeo"
163
+
164
+ def __init__(self, channel: MqttChannel) -> None:
165
+ """Initialize the Zeo API."""
166
+ self._channel = channel
167
+
168
+ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
169
+ """Query the device for the values of the given protocols."""
170
+ response = await send_decoded_command(
171
+ self._channel,
172
+ {RoborockZeoProtocol.ID_QUERY: protocols},
173
+ value_encoder=json.dumps,
174
+ )
175
+ return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
176
+
177
+ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
178
+ """Set a value for a specific protocol on the device."""
179
+ params = {protocol: value}
180
+ return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
181
+
182
+
183
+ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi:
184
+ """Create traits for A01 devices."""
185
+ match product.category:
186
+ case RoborockCategory.WET_DRY_VAC:
187
+ return DyadApi(mqtt_channel)
188
+ case RoborockCategory.WASHING_MACHINE:
189
+ return ZeoApi(mqtt_channel)
190
+ case _:
191
+ raise NotImplementedError(f"Unsupported category {product.category}")
@@ -0,0 +1,12 @@
1
+ """Traits for B01 devices."""
2
+
3
+ from . import q7, q10
4
+ from .q7 import Q7PropertiesApi
5
+ from .q10 import Q10PropertiesApi
6
+
7
+ __all__ = [
8
+ "Q7PropertiesApi",
9
+ "Q10PropertiesApi",
10
+ "q7",
11
+ "q10",
12
+ ]