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,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}")
|