roborock-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
|
@@ -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."""
|