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