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,295 @@
1
+ """Module for communicating with Roborock devices over a local network."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from roborock_cli._vendor.roborock.callbacks import CallbackList, decoder_callback
9
+ from roborock_cli._vendor.roborock.exceptions import RoborockConnectionException, RoborockException
10
+ from roborock_cli._vendor.roborock.protocol import create_local_decoder, create_local_encoder
11
+ from roborock_cli._vendor.roborock.protocols.v1_protocol import LocalProtocolVersion
12
+ from roborock_cli._vendor.roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
13
+ from roborock_cli._vendor.roborock.util import RoborockLoggerAdapter, get_next_int
14
+
15
+ from .channel import Channel
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+ _PORT = 58867
19
+ _TIMEOUT = 5.0
20
+ _PING_INTERVAL = 10
21
+
22
+
23
+ @dataclass
24
+ class LocalChannelParams:
25
+ """Parameters for local channel encoder/decoder."""
26
+
27
+ local_key: str
28
+ connect_nonce: int
29
+ ack_nonce: int | None
30
+
31
+
32
+ @dataclass
33
+ class _LocalProtocol(asyncio.Protocol):
34
+ """Callbacks for the Roborock local client transport."""
35
+
36
+ messages_cb: Callable[[bytes], None]
37
+ connection_lost_cb: Callable[[Exception | None], None]
38
+
39
+ def data_received(self, data: bytes) -> None:
40
+ """Called when data is received from the transport."""
41
+ self.messages_cb(data)
42
+
43
+ def connection_lost(self, exc: Exception | None) -> None:
44
+ """Called when the transport connection is lost."""
45
+ self.connection_lost_cb(exc)
46
+
47
+
48
+ def get_running_loop() -> asyncio.AbstractEventLoop:
49
+ """Get the running event loop, extracted for mocking purposes."""
50
+ return asyncio.get_running_loop()
51
+
52
+
53
+ class LocalChannel(Channel):
54
+ """Simple RPC-style channel for communicating with a device over a local network.
55
+
56
+ Handles request/response correlation and timeouts, but leaves message
57
+ format most parsing to higher-level components.
58
+ """
59
+
60
+ def __init__(self, host: str, local_key: str, device_uid: str) -> None:
61
+ self._host = host
62
+ self._logger = RoborockLoggerAdapter(duid=device_uid, logger=_LOGGER)
63
+ self._transport: asyncio.Transport | None = None
64
+ self._protocol: _LocalProtocol | None = None
65
+ self._subscribers: CallbackList[RoborockMessage] = CallbackList(self._logger)
66
+ self._is_connected = False
67
+ self._local_protocol_version: LocalProtocolVersion | None = None
68
+ self._keep_alive_task: asyncio.Task[None] | None = None
69
+ self._update_encoder_decoder(
70
+ LocalChannelParams(local_key=local_key, connect_nonce=get_next_int(10000, 32767), ack_nonce=None)
71
+ )
72
+
73
+ def _update_encoder_decoder(self, params: LocalChannelParams) -> None:
74
+ """Update the encoder and decoder with new parameters.
75
+
76
+ This is invoked once with an initial set of values used for protocol
77
+ negotiation. Once negotiation completes, it is updated again to set the
78
+ correct nonces for the follow up communications and updates the encoder
79
+ and decoder functions accordingly.
80
+ """
81
+ self._params = params
82
+ self._encoder = create_local_encoder(
83
+ local_key=params.local_key, connect_nonce=params.connect_nonce, ack_nonce=params.ack_nonce
84
+ )
85
+ self._decoder = create_local_decoder(
86
+ local_key=params.local_key, connect_nonce=params.connect_nonce, ack_nonce=params.ack_nonce
87
+ )
88
+ # Callback to decode messages and dispatch to subscribers
89
+ self._dispatch = decoder_callback(self._decoder, self._subscribers, self._logger)
90
+
91
+ async def _do_hello(self, local_protocol_version: LocalProtocolVersion) -> LocalChannelParams | None:
92
+ """Perform the initial handshaking and return encoder params if successful."""
93
+ self._logger.debug(
94
+ "Attempting to use the %s protocol for client %s...",
95
+ local_protocol_version,
96
+ self._host,
97
+ )
98
+ request = RoborockMessage(
99
+ protocol=RoborockMessageProtocol.HELLO_REQUEST,
100
+ version=local_protocol_version.encode(),
101
+ random=self._params.connect_nonce,
102
+ seq=1,
103
+ )
104
+ try:
105
+ response = await self._send_message(
106
+ roborock_message=request,
107
+ request_id=request.seq,
108
+ response_protocol=RoborockMessageProtocol.HELLO_RESPONSE,
109
+ )
110
+ self._logger.debug(
111
+ "Client %s speaks the %s protocol.",
112
+ self._host,
113
+ local_protocol_version,
114
+ )
115
+ return LocalChannelParams(
116
+ local_key=self._params.local_key, connect_nonce=self._params.connect_nonce, ack_nonce=response.random
117
+ )
118
+ except RoborockException as e:
119
+ self._logger.debug(
120
+ "Client %s did not respond or does not speak the %s protocol. %s",
121
+ self._host,
122
+ local_protocol_version,
123
+ e,
124
+ )
125
+ return None
126
+
127
+ async def _hello(self):
128
+ """Send hello to the device to negotiate protocol."""
129
+ attempt_versions = [LocalProtocolVersion.V1, LocalProtocolVersion.L01]
130
+ if self._local_protocol_version:
131
+ # Sort to try the preferred version first
132
+ attempt_versions.sort(key=lambda v: v != self._local_protocol_version)
133
+
134
+ for version in attempt_versions:
135
+ params = await self._do_hello(version)
136
+ if params is not None:
137
+ self._local_protocol_version = version
138
+ self._update_encoder_decoder(params)
139
+ return
140
+
141
+ raise RoborockException("Failed to connect to device with any known protocol")
142
+
143
+ async def _ping(self) -> None:
144
+ ping_message = RoborockMessage(
145
+ protocol=RoborockMessageProtocol.PING_REQUEST, version=self.protocol_version.encode()
146
+ )
147
+ await self._send_message(
148
+ roborock_message=ping_message,
149
+ request_id=ping_message.seq,
150
+ response_protocol=RoborockMessageProtocol.PING_RESPONSE,
151
+ )
152
+
153
+ async def _keep_alive_loop(self) -> None:
154
+ while self._is_connected:
155
+ try:
156
+ await asyncio.sleep(_PING_INTERVAL)
157
+ if self._is_connected:
158
+ await self._ping()
159
+ except asyncio.CancelledError:
160
+ break
161
+ except Exception:
162
+ self._logger.debug("Keep-alive ping failed", exc_info=True)
163
+ # Retry next interval
164
+
165
+ @property
166
+ def protocol_version(self) -> LocalProtocolVersion:
167
+ """Return the negotiated local protocol version, or a sensible default."""
168
+ if self._local_protocol_version is not None:
169
+ return self._local_protocol_version
170
+ return LocalProtocolVersion.V1
171
+
172
+ @property
173
+ def is_connected(self) -> bool:
174
+ """Check if the channel is currently connected."""
175
+ return self._is_connected
176
+
177
+ @property
178
+ def is_local_connected(self) -> bool:
179
+ """Check if the channel is currently connected locally."""
180
+ return self._is_connected
181
+
182
+ async def connect(self) -> None:
183
+ """Connect to the device and negotiate protocol."""
184
+ if self._is_connected:
185
+ self._logger.debug("Unexpected call to connect when already connected")
186
+ return
187
+ loop = get_running_loop()
188
+ protocol = _LocalProtocol(self._data_received, self._connection_lost)
189
+ try:
190
+ self._transport, self._protocol = await loop.create_connection(lambda: protocol, self._host, _PORT)
191
+ self._is_connected = True
192
+ except OSError as e:
193
+ raise RoborockConnectionException(f"Failed to connect to {self._host}:{_PORT}") from e
194
+
195
+ # Perform protocol negotiation
196
+ try:
197
+ await self._hello()
198
+ self._keep_alive_task = asyncio.create_task(self._keep_alive_loop())
199
+ except RoborockException:
200
+ # If protocol negotiation fails, clean up the connection state
201
+ self.close()
202
+ raise
203
+
204
+ def _data_received(self, data: bytes) -> None:
205
+ """Invoked when data is received on the stream."""
206
+ self._dispatch(data)
207
+
208
+ def close(self) -> None:
209
+ """Disconnect from the device."""
210
+ if self._keep_alive_task:
211
+ self._keep_alive_task.cancel()
212
+ self._keep_alive_task = None
213
+ if self._transport:
214
+ self._transport.close()
215
+ else:
216
+ self._logger.warning("Close called but transport is already None")
217
+ self._transport = None
218
+ self._is_connected = False
219
+
220
+ def _connection_lost(self, exc: Exception | None) -> None:
221
+ """Handle connection loss."""
222
+ self._logger.debug("Connection lost to %s", self._host, exc_info=exc)
223
+ if self._keep_alive_task:
224
+ self._keep_alive_task.cancel()
225
+ self._keep_alive_task = None
226
+ self._transport = None
227
+ self._is_connected = False
228
+
229
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
230
+ """Subscribe to all messages from the device."""
231
+ return self._subscribers.add_callback(callback)
232
+
233
+ async def publish(self, message: RoborockMessage) -> None:
234
+ """Send a command message.
235
+
236
+ The caller is responsible for associating the message with its response.
237
+ """
238
+ if not self._transport or not self._is_connected:
239
+ raise RoborockConnectionException("Not connected to device")
240
+
241
+ try:
242
+ encoded_msg = self._encoder(message)
243
+ except Exception as err:
244
+ self._logger.exception("Error encoding MQTT message: %s", err)
245
+ raise RoborockException(f"Failed to encode MQTT message: {err}") from err
246
+ try:
247
+ self._transport.write(encoded_msg)
248
+ except Exception as err:
249
+ self._logger.exception("Uncaught error sending command")
250
+ raise RoborockException(f"Failed to send message: {message}") from err
251
+
252
+ async def _send_message(
253
+ self,
254
+ roborock_message: RoborockMessage,
255
+ request_id: int,
256
+ response_protocol: int,
257
+ ) -> RoborockMessage:
258
+ """Send a raw message and wait for a raw response."""
259
+ future: asyncio.Future[RoborockMessage] = asyncio.Future()
260
+
261
+ def find_response(response_message: RoborockMessage) -> None:
262
+ if response_message.protocol == response_protocol and response_message.seq == request_id:
263
+ future.set_result(response_message)
264
+
265
+ unsub = await self.subscribe(find_response)
266
+ try:
267
+ await self.publish(roborock_message)
268
+ return await asyncio.wait_for(future, timeout=_TIMEOUT)
269
+ except TimeoutError as ex:
270
+ future.cancel()
271
+ raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
272
+ finally:
273
+ unsub()
274
+
275
+
276
+ # This module provides a factory function to create LocalChannel instances.
277
+ #
278
+ # TODO: Make a separate LocalSession and use it to manage retries with the host,
279
+ # similar to how MqttSession works. For now this is a simple factory function
280
+ # for creating channels.
281
+ LocalSession = Callable[[str], LocalChannel]
282
+
283
+
284
+ def create_local_session(local_key: str, device_uid: str) -> LocalSession:
285
+ """Creates a local session which can create local channels.
286
+
287
+ This plays a role similar to the MqttSession but is really just a factory
288
+ for creating LocalChannel instances with the same local key.
289
+ """
290
+
291
+ def create_local_channel(host: str) -> LocalChannel:
292
+ """Create a LocalChannel instance for the given host."""
293
+ return LocalChannel(host, local_key, device_uid)
294
+
295
+ return create_local_channel
@@ -0,0 +1,118 @@
1
+ """Modules for communicating with specific Roborock devices over MQTT."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncGenerator, Callable
6
+
7
+ from roborock_cli._vendor.roborock.callbacks import decoder_callback
8
+ from roborock_cli._vendor.roborock.data import HomeDataDevice, RRiot, UserData
9
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
10
+ from roborock_cli._vendor.roborock.mqtt.health_manager import HealthManager
11
+ from roborock_cli._vendor.roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
12
+ from roborock_cli._vendor.roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
13
+ from roborock_cli._vendor.roborock.roborock_message import RoborockMessage
14
+ from roborock_cli._vendor.roborock.util import RoborockLoggerAdapter
15
+
16
+ from .channel import Channel
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ class MqttChannel(Channel):
22
+ """Simple RPC-style channel for communicating with a device over MQTT.
23
+
24
+ Handles request/response correlation and timeouts, but leaves message
25
+ format most parsing to higher-level components.
26
+ """
27
+
28
+ def __init__(self, mqtt_session: MqttSession, duid: str, local_key: str, rriot: RRiot, mqtt_params: MqttParams):
29
+ self._mqtt_session = mqtt_session
30
+ self._duid = duid
31
+ self._logger = RoborockLoggerAdapter(duid=duid, logger=_LOGGER)
32
+ self._local_key = local_key
33
+ self._rriot = rriot
34
+ self._mqtt_params = mqtt_params
35
+
36
+ self._decoder = create_mqtt_decoder(local_key)
37
+ self._encoder = create_mqtt_encoder(local_key)
38
+
39
+ @property
40
+ def is_connected(self) -> bool:
41
+ """Return true if the channel is connected.
42
+
43
+ This passes through the underlying MQTT session's connected state.
44
+ """
45
+ return self._mqtt_session.connected
46
+
47
+ @property
48
+ def health_manager(self) -> HealthManager:
49
+ """Return the health manager for the session."""
50
+ return self._mqtt_session.health_manager
51
+
52
+ @property
53
+ def is_local_connected(self) -> bool:
54
+ """Return true if the channel is connected locally."""
55
+ return False
56
+
57
+ @property
58
+ def _publish_topic(self) -> str:
59
+ """Topic to send commands to the device."""
60
+ return f"rr/m/i/{self._rriot.u}/{self._mqtt_params.username}/{self._duid}"
61
+
62
+ @property
63
+ def _subscribe_topic(self) -> str:
64
+ """Topic to receive responses from the device."""
65
+ return f"rr/m/o/{self._rriot.u}/{self._mqtt_params.username}/{self._duid}"
66
+
67
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
68
+ """Subscribe to the device's response topic.
69
+
70
+ The callback will be called with the message payload when a message is received.
71
+
72
+ Returns a callable that can be used to unsubscribe from the topic.
73
+ """
74
+ dispatch = decoder_callback(self._decoder, callback, _LOGGER)
75
+ return await self._mqtt_session.subscribe(self._subscribe_topic, dispatch)
76
+
77
+ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
78
+ """Subscribe to the device's message stream.
79
+
80
+ This is useful for processing all incoming messages in an async for loop,
81
+ when they are not necessarily associated with a specific request.
82
+ """
83
+ message_queue: asyncio.Queue[RoborockMessage] = asyncio.Queue()
84
+ unsub = await self.subscribe(message_queue.put_nowait)
85
+ try:
86
+ while True:
87
+ message = await message_queue.get()
88
+ yield message
89
+ finally:
90
+ unsub()
91
+
92
+ async def publish(self, message: RoborockMessage) -> None:
93
+ """Publish a command message.
94
+
95
+ The caller is responsible for handling any responses and associating them
96
+ with the incoming request.
97
+ """
98
+ try:
99
+ encoded_msg = self._encoder(message)
100
+ except Exception as e:
101
+ self._logger.exception("Error encoding MQTT message: %s", e)
102
+ raise RoborockException(f"Failed to encode MQTT message: {e}") from e
103
+ try:
104
+ return await self._mqtt_session.publish(self._publish_topic, encoded_msg)
105
+ except MqttSessionException as e:
106
+ self._logger.debug("Error publishing MQTT message: %s", e)
107
+ raise RoborockException(f"Failed to publish MQTT message: {e}") from e
108
+
109
+ async def restart(self) -> None:
110
+ """Restart the underlying MQTT session."""
111
+ await self._mqtt_session.restart()
112
+
113
+
114
+ def create_mqtt_channel(
115
+ user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice
116
+ ) -> MqttChannel:
117
+ """Create a MQTT channel for the given device."""
118
+ return MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
@@ -0,0 +1,166 @@
1
+ """Diagnostics for debugging.
2
+
3
+ A Diagnostics object can be used to track counts and latencies of various
4
+ operations within a module. This can be useful for debugging performance issues
5
+ or understanding usage patterns.
6
+
7
+ This is an internal facing module and is not intended for public use. Diagnostics
8
+ data is collected and exposed to clients via higher level APIs like the
9
+ DeviceManager.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections import Counter
16
+ from collections.abc import Generator, Mapping
17
+ from contextlib import contextmanager
18
+ from typing import Any, TypeVar, cast
19
+
20
+
21
+ class Diagnostics:
22
+ """A class that holds diagnostics information for a module.
23
+
24
+ You can use this class to hold counter or for recording timing information
25
+ that can be exported as a dictionary for debugging purposes.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize Diagnostics."""
30
+ self._counter: Counter = Counter()
31
+ self._subkeys: dict[str, Diagnostics] = {}
32
+
33
+ def increment(self, key: str, count: int = 1) -> None:
34
+ """Increment a counter for the specified key/event."""
35
+ self._counter.update(Counter({key: count}))
36
+
37
+ def elapsed(self, key_prefix: str, elapsed_ms: int = 1) -> None:
38
+ """Track a latency event for the specified key/event prefix."""
39
+ self.increment(f"{key_prefix}_count", 1)
40
+ self.increment(f"{key_prefix}_sum", elapsed_ms)
41
+
42
+ def as_dict(self) -> Mapping[str, Any]:
43
+ """Return diagnostics as a debug dictionary."""
44
+ data: dict[str, Any] = {k: self._counter[k] for k in self._counter}
45
+ for k, d in self._subkeys.items():
46
+ v = d.as_dict()
47
+ if not v:
48
+ continue
49
+ data[k] = v
50
+ return data
51
+
52
+ def subkey(self, key: str) -> Diagnostics:
53
+ """Return sub-Diagnostics object with the specified subkey.
54
+
55
+ This will create a new Diagnostics object if one does not already exist
56
+ for the specified subkey. Stats from the sub-Diagnostics will be included
57
+ in the parent Diagnostics when exported as a dictionary.
58
+
59
+ Args:
60
+ key: The subkey for the diagnostics.
61
+
62
+ Returns:
63
+ The Diagnostics object for the specified subkey.
64
+ """
65
+ if key not in self._subkeys:
66
+ self._subkeys[key] = Diagnostics()
67
+ return self._subkeys[key]
68
+
69
+ @contextmanager
70
+ def timer(self, key_prefix: str) -> Generator[None, None, None]:
71
+ """A context manager that records the timing of operations as a diagnostic."""
72
+ start = time.perf_counter()
73
+ try:
74
+ yield
75
+ finally:
76
+ end = time.perf_counter()
77
+ ms = int((end - start) * 1000)
78
+ self.elapsed(key_prefix, ms)
79
+
80
+ def reset(self) -> None:
81
+ """Clear all diagnostics, for testing."""
82
+ self._counter = Counter()
83
+ for d in self._subkeys.values():
84
+ d.reset()
85
+
86
+
87
+ T = TypeVar("T")
88
+
89
+ REDACT_KEYS = {
90
+ # Potential identifiers
91
+ "localKey",
92
+ "mac",
93
+ "bssid",
94
+ "sn",
95
+ "ip",
96
+ "u",
97
+ "s",
98
+ "h",
99
+ "k",
100
+ # Large binary blobs are entirely omitted
101
+ "imageContent",
102
+ "mapData",
103
+ "rawApiResponse",
104
+ # Home data
105
+ "id", # We want to redact home_data.id but keep some other ids, see below
106
+ "name",
107
+ "productId",
108
+ "ipAddress",
109
+ "wifiName",
110
+ "lat",
111
+ "long",
112
+ }
113
+ KEEP_KEYS = {
114
+ # Product information not unique per user
115
+ "product.id",
116
+ "product.schema.id",
117
+ "product.schema.name",
118
+ # Room ids are likely unique per user, but don't seem too sensitive and are
119
+ # useful for debugging
120
+ "rooms.id",
121
+ }
122
+ DEVICE_UID = "duid"
123
+ REDACTED = "**REDACTED**"
124
+
125
+
126
+ def redact_device_data(data: T, path: str = "") -> T | dict[str, Any]:
127
+ """Redact sensitive data in a dict."""
128
+ if not isinstance(data, (Mapping, list)):
129
+ return data
130
+
131
+ if isinstance(data, list):
132
+ return cast(T, [redact_device_data(item, path) for item in data])
133
+
134
+ redacted = {**data}
135
+
136
+ for key, value in redacted.items():
137
+ curr_path = f"{path}.{key}" if path else key
138
+ if key in KEEP_KEYS or curr_path in KEEP_KEYS:
139
+ continue
140
+ if key in REDACT_KEYS or curr_path in REDACT_KEYS:
141
+ redacted[key] = REDACTED
142
+ elif key == DEVICE_UID and isinstance(value, str):
143
+ redacted[key] = redact_device_uid(value)
144
+ elif isinstance(value, dict):
145
+ redacted[key] = redact_device_data(value, curr_path)
146
+ elif isinstance(value, list):
147
+ redacted[key] = [redact_device_data(item, curr_path) for item in value]
148
+
149
+ return redacted
150
+
151
+
152
+ def redact_topic_name(topic: str) -> str:
153
+ """Redact potentially identifying information from a topic name."""
154
+ parts = topic.split("/")
155
+ redacted_parts = parts[:4]
156
+ for part in parts[4:]:
157
+ if len(part) <= 5:
158
+ redacted_parts.append("*****")
159
+ else:
160
+ redacted_parts.append("*****" + part[-5:])
161
+ return "/".join(redacted_parts)
162
+
163
+
164
+ def redact_device_uid(duid: str) -> str:
165
+ """Redact a device UID to hide identifying information."""
166
+ return "******" + duid[-5:]
@@ -0,0 +1,95 @@
1
+ """Roborock exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RoborockException(Exception):
7
+ """Class for Roborock exceptions."""
8
+
9
+
10
+ class RoborockTimeout(RoborockException):
11
+ """Class for Roborock timeout exceptions."""
12
+
13
+
14
+ class RoborockConnectionException(RoborockException):
15
+ """Class for Roborock connection exceptions."""
16
+
17
+
18
+ class RoborockBackoffException(RoborockException):
19
+ """Class for Roborock exceptions when many retries were made."""
20
+
21
+
22
+ class VacuumError(RoborockException):
23
+ """Class for vacuum errors."""
24
+
25
+
26
+ class CommandVacuumError(RoborockException):
27
+ """Class for command vacuum errors."""
28
+
29
+ def __init__(self, command: str | None, vacuum_error: VacuumError):
30
+ self.message = f"{command or 'unknown'}: {str(vacuum_error)}"
31
+ super().__init__(self.message)
32
+
33
+
34
+ class UnknownMethodError(RoborockException):
35
+ """Class for an invalid method being sent."""
36
+
37
+
38
+ class RoborockAccountDoesNotExist(RoborockException):
39
+ """Class for Roborock account does not exist exceptions."""
40
+
41
+
42
+ class RoborockUrlException(RoborockException):
43
+ """Class for being unable to get the URL for the Roborock account."""
44
+
45
+
46
+ class RoborockInvalidCode(RoborockException):
47
+ """Class for Roborock invalid code exceptions."""
48
+
49
+
50
+ class RoborockInvalidEmail(RoborockException):
51
+ """Class for Roborock invalid formatted email exceptions."""
52
+
53
+
54
+ class RoborockInvalidUserAgreement(RoborockException):
55
+ """Class for Roborock invalid user agreement exceptions."""
56
+
57
+
58
+ class RoborockNoUserAgreement(RoborockException):
59
+ """Class for Roborock no user agreement exceptions."""
60
+
61
+
62
+ class RoborockInvalidCredentials(RoborockException):
63
+ """Class for Roborock credentials have expired or changed."""
64
+
65
+
66
+ class RoborockTooFrequentCodeRequests(RoborockException):
67
+ """Class for Roborock too frequent code requests exceptions."""
68
+
69
+
70
+ class RoborockMissingParameters(RoborockException):
71
+ """Class for Roborock missing parameters exceptions."""
72
+
73
+
74
+ class RoborockTooManyRequest(RoborockException):
75
+ """Class for Roborock too many request exceptions."""
76
+
77
+
78
+ class RoborockRateLimit(RoborockException):
79
+ """Class for our rate limits exceptions."""
80
+
81
+
82
+ class RoborockNoResponseFromBaseURL(RoborockException):
83
+ """We could not find an url that had a record of the given account."""
84
+
85
+
86
+ class RoborockDeviceBusy(RoborockException):
87
+ """Class for Roborock device busy exceptions."""
88
+
89
+
90
+ class RoborockInvalidStatus(RoborockException):
91
+ """Class for Roborock invalid status exceptions (device action locked)."""
92
+
93
+
94
+ class RoborockUnsupportedFeature(RoborockException):
95
+ """Class for Roborock unsupported feature exceptions."""
@@ -0,0 +1,7 @@
1
+ """Module for Roborock map related data classes."""
2
+
3
+ from .map_parser import MapParserConfig, ParsedMapData
4
+
5
+ __all__ = [
6
+ "MapParserConfig",
7
+ ]