python-roborock 2.25.1__tar.gz → 2.27.0__tar.gz
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.
- {python_roborock-2.25.1 → python_roborock-2.27.0}/PKG-INFO +1 -1
- {python_roborock-2.25.1 → python_roborock-2.27.0}/pyproject.toml +1 -1
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/cli.py +10 -1
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/code_mappings.py +30 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/const.py +1 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/containers.py +12 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/device.py +33 -21
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/device_manager.py +11 -4
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/local_channel.py +23 -1
- python_roborock-2.27.0/roborock/devices/v1_channel.py +212 -0
- python_roborock-2.27.0/roborock/protocols/a01_protocol.py +54 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/protocols/v1_protocol.py +58 -0
- python_roborock-2.27.0/roborock/version_a01_apis/roborock_client_a01.py +159 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +7 -18
- python_roborock-2.25.1/roborock/version_a01_apis/roborock_client_a01.py +0 -153
- {python_roborock-2.25.1 → python_roborock-2.27.0}/LICENSE +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/README.md +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/__init__.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/api.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/devices/mqtt_channel.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/local_api.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/protocol.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/py.typed +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/util.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/web_api.py +0 -0
|
@@ -115,7 +115,16 @@ async def session(ctx, duration: int):
|
|
|
115
115
|
devices = await device_manager.get_devices()
|
|
116
116
|
click.echo(f"Discovered devices: {', '.join([device.name for device in devices])}")
|
|
117
117
|
|
|
118
|
-
click.echo("MQTT session started.
|
|
118
|
+
click.echo("MQTT session started. Querying devices...")
|
|
119
|
+
for device in devices:
|
|
120
|
+
try:
|
|
121
|
+
status = await device.get_status()
|
|
122
|
+
except RoborockException as e:
|
|
123
|
+
click.echo(f"Failed to get status for {device.name}: {e}")
|
|
124
|
+
else:
|
|
125
|
+
click.echo(f"Device {device.name} status: {status.as_dict()}")
|
|
126
|
+
|
|
127
|
+
click.echo("Listening for messages.")
|
|
119
128
|
await asyncio.sleep(duration)
|
|
120
129
|
|
|
121
130
|
# Close the device manager (this will close all devices and MQTT session)
|
|
@@ -300,6 +300,17 @@ class RoborockFanSpeedS8MaxVUltra(RoborockFanPowerCode):
|
|
|
300
300
|
smart_mode = 110
|
|
301
301
|
|
|
302
302
|
|
|
303
|
+
class RoborockFanSpeedSaros10(RoborockFanPowerCode):
|
|
304
|
+
off = 105
|
|
305
|
+
quiet = 101
|
|
306
|
+
balanced = 102
|
|
307
|
+
turbo = 103
|
|
308
|
+
max = 104
|
|
309
|
+
custom = 106
|
|
310
|
+
max_plus = 108
|
|
311
|
+
smart_mode = 110
|
|
312
|
+
|
|
313
|
+
|
|
303
314
|
class RoborockFanSpeedSaros10R(RoborockFanPowerCode):
|
|
304
315
|
off = 105
|
|
305
316
|
quiet = 101
|
|
@@ -379,6 +390,15 @@ class RoborockMopModeQRevoMaxV(RoborockMopModeCode):
|
|
|
379
390
|
smart_mode = 306
|
|
380
391
|
|
|
381
392
|
|
|
393
|
+
class RoborockMopModeSaros10(RoborockMopModeCode):
|
|
394
|
+
standard = 300
|
|
395
|
+
deep = 301
|
|
396
|
+
custom = 302
|
|
397
|
+
deep_plus = 303
|
|
398
|
+
fast = 304
|
|
399
|
+
smart_mode = 306
|
|
400
|
+
|
|
401
|
+
|
|
382
402
|
class RoborockMopIntensityCode(RoborockEnum):
|
|
383
403
|
"""Describes the mop intensity of the vacuum cleaner."""
|
|
384
404
|
|
|
@@ -458,6 +478,16 @@ class RoborockMopIntensityS8MaxVUltra(RoborockMopIntensityCode):
|
|
|
458
478
|
custom_water_flow = 207
|
|
459
479
|
|
|
460
480
|
|
|
481
|
+
class RoborockMopIntensitySaros10(RoborockMopIntensityCode):
|
|
482
|
+
off = 200
|
|
483
|
+
mild = 201
|
|
484
|
+
standard = 202
|
|
485
|
+
intense = 203
|
|
486
|
+
extreme = 208
|
|
487
|
+
custom = 204
|
|
488
|
+
smart_mode = 209
|
|
489
|
+
|
|
490
|
+
|
|
461
491
|
class RoborockMopIntensitySaros10R(RoborockMopIntensityCode):
|
|
462
492
|
off = 200
|
|
463
493
|
low = 201
|
|
@@ -51,6 +51,7 @@ ROBOROCK_QREVO_S = "roborock.vacuum.a104"
|
|
|
51
51
|
ROBOROCK_QREVO_PRO = "roborock.vacuum.a101"
|
|
52
52
|
ROBOROCK_QREVO_MAXV = "roborock.vacuum.a87"
|
|
53
53
|
ROBOROCK_SAROS_10R = "roborock.vacuum.a144"
|
|
54
|
+
ROBOROCK_SAROS_10 = "roborock.vacuum.a147"
|
|
54
55
|
|
|
55
56
|
ROBOROCK_DYAD_AIR = "roborock.wetdryvac.a107"
|
|
56
57
|
ROBOROCK_DYAD_PRO_COMBO = "roborock.wetdryvac.a83"
|
|
@@ -28,6 +28,7 @@ from .code_mappings import (
|
|
|
28
28
|
RoborockFanSpeedS7,
|
|
29
29
|
RoborockFanSpeedS7MaxV,
|
|
30
30
|
RoborockFanSpeedS8MaxVUltra,
|
|
31
|
+
RoborockFanSpeedSaros10,
|
|
31
32
|
RoborockFanSpeedSaros10R,
|
|
32
33
|
RoborockFinishReason,
|
|
33
34
|
RoborockInCleaning,
|
|
@@ -41,6 +42,7 @@ from .code_mappings import (
|
|
|
41
42
|
RoborockMopIntensityS6MaxV,
|
|
42
43
|
RoborockMopIntensityS7,
|
|
43
44
|
RoborockMopIntensityS8MaxVUltra,
|
|
45
|
+
RoborockMopIntensitySaros10,
|
|
44
46
|
RoborockMopIntensitySaros10R,
|
|
45
47
|
RoborockMopModeCode,
|
|
46
48
|
RoborockMopModeQRevoCurv,
|
|
@@ -49,6 +51,7 @@ from .code_mappings import (
|
|
|
49
51
|
RoborockMopModeS7,
|
|
50
52
|
RoborockMopModeS8MaxVUltra,
|
|
51
53
|
RoborockMopModeS8ProUltra,
|
|
54
|
+
RoborockMopModeSaros10,
|
|
52
55
|
RoborockMopModeSaros10R,
|
|
53
56
|
RoborockStartType,
|
|
54
57
|
RoborockStateCode,
|
|
@@ -77,6 +80,7 @@ from .const import (
|
|
|
77
80
|
ROBOROCK_S8,
|
|
78
81
|
ROBOROCK_S8_MAXV_ULTRA,
|
|
79
82
|
ROBOROCK_S8_PRO_ULTRA,
|
|
83
|
+
ROBOROCK_SAROS_10,
|
|
80
84
|
ROBOROCK_SAROS_10R,
|
|
81
85
|
SENSOR_DIRTY_REPLACE_TIME,
|
|
82
86
|
SIDE_BRUSH_REPLACE_TIME,
|
|
@@ -689,6 +693,13 @@ class Saros10RStatus(Status):
|
|
|
689
693
|
mop_mode: RoborockMopModeSaros10R | None = None
|
|
690
694
|
|
|
691
695
|
|
|
696
|
+
@dataclass
|
|
697
|
+
class Saros10Status(Status):
|
|
698
|
+
fan_power: RoborockFanSpeedSaros10 | None = None
|
|
699
|
+
water_box_mode: RoborockMopIntensitySaros10 | None = None
|
|
700
|
+
mop_mode: RoborockMopModeSaros10 | None = None
|
|
701
|
+
|
|
702
|
+
|
|
692
703
|
ModelStatus: dict[str, type[Status]] = {
|
|
693
704
|
ROBOROCK_S4_MAX: S4MaxStatus,
|
|
694
705
|
ROBOROCK_S5_MAX: S5MaxStatus,
|
|
@@ -713,6 +724,7 @@ ModelStatus: dict[str, type[Status]] = {
|
|
|
713
724
|
ROBOROCK_QREVO_PRO: P10Status,
|
|
714
725
|
ROBOROCK_S8_MAXV_ULTRA: S8MaxvUltraStatus,
|
|
715
726
|
ROBOROCK_SAROS_10R: Saros10RStatus,
|
|
727
|
+
ROBOROCK_SAROS_10: Saros10Status,
|
|
716
728
|
}
|
|
717
729
|
|
|
718
730
|
|
|
@@ -9,10 +9,18 @@ import logging
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
10
|
from functools import cached_property
|
|
11
11
|
|
|
12
|
-
from roborock.containers import
|
|
12
|
+
from roborock.containers import (
|
|
13
|
+
HomeDataDevice,
|
|
14
|
+
HomeDataProduct,
|
|
15
|
+
ModelStatus,
|
|
16
|
+
S7MaxVStatus,
|
|
17
|
+
Status,
|
|
18
|
+
UserData,
|
|
19
|
+
)
|
|
13
20
|
from roborock.roborock_message import RoborockMessage
|
|
21
|
+
from roborock.roborock_typing import RoborockCommand
|
|
14
22
|
|
|
15
|
-
from .
|
|
23
|
+
from .v1_channel import V1Channel
|
|
16
24
|
|
|
17
25
|
_LOGGER = logging.getLogger(__name__)
|
|
18
26
|
|
|
@@ -38,19 +46,18 @@ class RoborockDevice:
|
|
|
38
46
|
user_data: UserData,
|
|
39
47
|
device_info: HomeDataDevice,
|
|
40
48
|
product_info: HomeDataProduct,
|
|
41
|
-
|
|
49
|
+
v1_channel: V1Channel,
|
|
42
50
|
) -> None:
|
|
43
51
|
"""Initialize the RoborockDevice.
|
|
44
52
|
|
|
45
|
-
The device takes ownership of the
|
|
46
|
-
Use `connect()` to establish the connection, which will set up the
|
|
47
|
-
|
|
48
|
-
channel.
|
|
53
|
+
The device takes ownership of the V1 channel for communication with the device.
|
|
54
|
+
Use `connect()` to establish the connection, which will set up the appropriate
|
|
55
|
+
protocol channel. Use `close()` to clean up all connections.
|
|
49
56
|
"""
|
|
50
57
|
self._user_data = user_data
|
|
51
58
|
self._device_info = device_info
|
|
52
59
|
self._product_info = product_info
|
|
53
|
-
self.
|
|
60
|
+
self._v1_channel = v1_channel
|
|
54
61
|
self._unsub: Callable[[], None] | None = None
|
|
55
62
|
|
|
56
63
|
@property
|
|
@@ -82,27 +89,32 @@ class RoborockDevice:
|
|
|
82
89
|
)
|
|
83
90
|
return DeviceVersion.UNKNOWN
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
@property
|
|
93
|
+
def is_connected(self) -> bool:
|
|
94
|
+
"""Return whether the device is connected."""
|
|
95
|
+
return self._v1_channel.is_mqtt_connected or self._v1_channel.is_local_connected
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
"""
|
|
97
|
+
async def connect(self) -> None:
|
|
98
|
+
"""Connect to the device using the appropriate protocol channel."""
|
|
90
99
|
if self._unsub:
|
|
91
100
|
raise ValueError("Already connected to the device")
|
|
92
|
-
self._unsub = await self.
|
|
101
|
+
self._unsub = await self._v1_channel.subscribe(self._on_message)
|
|
102
|
+
_LOGGER.info("Connected to V1 device %s", self.name)
|
|
93
103
|
|
|
94
104
|
async def close(self) -> None:
|
|
95
|
-
"""Close
|
|
96
|
-
|
|
97
|
-
This method will unsubscribe from the MQTT channel and clean up resources.
|
|
98
|
-
"""
|
|
105
|
+
"""Close all connections to the device."""
|
|
99
106
|
if self._unsub:
|
|
100
107
|
self._unsub()
|
|
101
108
|
self._unsub = None
|
|
102
109
|
|
|
103
|
-
def
|
|
104
|
-
"""Handle incoming
|
|
110
|
+
def _on_message(self, message: RoborockMessage) -> None:
|
|
111
|
+
"""Handle incoming messages from the device."""
|
|
112
|
+
_LOGGER.debug("Received message from device: %s", message)
|
|
113
|
+
|
|
114
|
+
async def get_status(self) -> Status:
|
|
115
|
+
"""Get the current status of the device.
|
|
105
116
|
|
|
106
|
-
This
|
|
117
|
+
This is a placeholder command and will likely be changed/moved in the future.
|
|
107
118
|
"""
|
|
108
|
-
|
|
119
|
+
status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus)
|
|
120
|
+
return await self._v1_channel.send_decoded_command(RoborockCommand.GET_STATUS, response_type=status_type)
|
|
@@ -10,13 +10,13 @@ from roborock.containers import (
|
|
|
10
10
|
HomeDataProduct,
|
|
11
11
|
UserData,
|
|
12
12
|
)
|
|
13
|
-
from roborock.devices.device import RoborockDevice
|
|
13
|
+
from roborock.devices.device import DeviceVersion, RoborockDevice
|
|
14
14
|
from roborock.mqtt.roborock_session import create_mqtt_session
|
|
15
15
|
from roborock.mqtt.session import MqttSession
|
|
16
16
|
from roborock.protocol import create_mqtt_params
|
|
17
17
|
from roborock.web_api import RoborockApiClient
|
|
18
18
|
|
|
19
|
-
from .
|
|
19
|
+
from .v1_channel import create_v1_channel
|
|
20
20
|
|
|
21
21
|
_LOGGER = logging.getLogger(__name__)
|
|
22
22
|
|
|
@@ -114,8 +114,15 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
|
|
|
114
114
|
mqtt_session = await create_mqtt_session(mqtt_params)
|
|
115
115
|
|
|
116
116
|
def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
# Check device version and only support V1 for now
|
|
118
|
+
if device.pv != DeviceVersion.V1.value:
|
|
119
|
+
raise NotImplementedError(
|
|
120
|
+
f"Device {device.name} has version {device.pv}, but only V1 devices "
|
|
121
|
+
f"are supported through the unified interface."
|
|
122
|
+
)
|
|
123
|
+
# Create V1 channel that handles both MQTT and local connections
|
|
124
|
+
v1_channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device)
|
|
125
|
+
return RoborockDevice(user_data, device, product, v1_channel)
|
|
119
126
|
|
|
120
127
|
manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session)
|
|
121
128
|
await manager.discover_devices()
|
|
@@ -64,7 +64,7 @@ class LocalChannel:
|
|
|
64
64
|
except OSError as e:
|
|
65
65
|
raise RoborockConnectionException(f"Failed to connect to {self._host}:{_PORT}") from e
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
def close(self) -> None:
|
|
68
68
|
"""Disconnect from the device."""
|
|
69
69
|
if self._transport:
|
|
70
70
|
self._transport.close()
|
|
@@ -144,3 +144,25 @@ class LocalChannel:
|
|
|
144
144
|
async with self._queue_lock:
|
|
145
145
|
self._waiting_queue.pop(request_id, None)
|
|
146
146
|
raise
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# This module provides a factory function to create LocalChannel instances.
|
|
150
|
+
#
|
|
151
|
+
# TODO: Make a separate LocalSession and use it to manage retries with the host,
|
|
152
|
+
# similar to how MqttSession works. For now this is a simple factory function
|
|
153
|
+
# for creating channels.
|
|
154
|
+
LocalSession = Callable[[str], LocalChannel]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_local_session(local_key: str) -> LocalSession:
|
|
158
|
+
"""Creates a local session which can create local channels.
|
|
159
|
+
|
|
160
|
+
This plays a role similar to the MqttSession but is really just a factory
|
|
161
|
+
for creating LocalChannel instances with the same local key.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def create_local_channel(host: str) -> LocalChannel:
|
|
165
|
+
"""Create a LocalChannel instance for the given host."""
|
|
166
|
+
return LocalChannel(host, local_key)
|
|
167
|
+
|
|
168
|
+
return create_local_channel
|
|
@@ -0,0 +1,212 @@
|
|
|
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 logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from roborock.containers import HomeDataDevice, NetworkInfo, RoborockBase, UserData
|
|
12
|
+
from roborock.exceptions import RoborockException
|
|
13
|
+
from roborock.mqtt.session import MqttParams, MqttSession
|
|
14
|
+
from roborock.protocols.v1_protocol import (
|
|
15
|
+
CommandType,
|
|
16
|
+
ParamsType,
|
|
17
|
+
SecurityData,
|
|
18
|
+
create_mqtt_payload_encoder,
|
|
19
|
+
create_security_data,
|
|
20
|
+
decode_rpc_response,
|
|
21
|
+
encode_local_payload,
|
|
22
|
+
)
|
|
23
|
+
from roborock.roborock_message import RoborockMessage
|
|
24
|
+
from roborock.roborock_typing import RoborockCommand
|
|
25
|
+
|
|
26
|
+
from .local_channel import LocalChannel, LocalSession, create_local_session
|
|
27
|
+
from .mqtt_channel import MqttChannel
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"V1Channel",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_T = TypeVar("_T", bound=RoborockBase)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class V1Channel:
|
|
39
|
+
"""Unified V1 protocol channel with automatic MQTT/local connection handling.
|
|
40
|
+
|
|
41
|
+
This channel abstracts away the complexity of choosing between MQTT and local
|
|
42
|
+
connections, and provides high-level V1 protocol methods. It automatically
|
|
43
|
+
handles connection setup, fallback logic, and protocol encoding/decoding.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
device_uid: str,
|
|
49
|
+
security_data: SecurityData,
|
|
50
|
+
mqtt_channel: MqttChannel,
|
|
51
|
+
local_session: LocalSession,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize the V1Channel.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
mqtt_channel: MQTT channel for cloud communication
|
|
57
|
+
local_session: Factory that creates LocalChannels for a hostname.
|
|
58
|
+
"""
|
|
59
|
+
self._device_uid = device_uid
|
|
60
|
+
self._mqtt_channel = mqtt_channel
|
|
61
|
+
self._mqtt_payload_encoder = create_mqtt_payload_encoder(security_data)
|
|
62
|
+
self._local_session = local_session
|
|
63
|
+
self._local_channel: LocalChannel | None = None
|
|
64
|
+
self._mqtt_unsub: Callable[[], None] | None = None
|
|
65
|
+
self._local_unsub: Callable[[], None] | None = None
|
|
66
|
+
self._callback: Callable[[RoborockMessage], None] | None = None
|
|
67
|
+
self._networking_info: NetworkInfo | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_local_connected(self) -> bool:
|
|
71
|
+
"""Return whether local connection is available."""
|
|
72
|
+
return self._local_unsub is not None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_mqtt_connected(self) -> bool:
|
|
76
|
+
"""Return whether MQTT connection is available."""
|
|
77
|
+
return self._mqtt_unsub is not None
|
|
78
|
+
|
|
79
|
+
async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
|
|
80
|
+
"""Subscribe to all messages from the device.
|
|
81
|
+
|
|
82
|
+
This will establish MQTT connection first, and also attempt to set up
|
|
83
|
+
local connection if possible. Any failures to subscribe to MQTT will raise
|
|
84
|
+
a RoborockException. A local connection failure will not raise an exception,
|
|
85
|
+
since the local connection is optional.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if self._mqtt_unsub:
|
|
89
|
+
raise ValueError("Already connected to the device")
|
|
90
|
+
self._callback = callback
|
|
91
|
+
|
|
92
|
+
# First establish MQTT connection
|
|
93
|
+
self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
|
|
94
|
+
_LOGGER.debug("V1Channel connected to device %s via MQTT", self._device_uid)
|
|
95
|
+
|
|
96
|
+
# Try to establish an optional local connection as well.
|
|
97
|
+
try:
|
|
98
|
+
self._local_unsub = await self._local_connect()
|
|
99
|
+
except RoborockException as err:
|
|
100
|
+
_LOGGER.warning("Could not establish local connection for device %s: %s", self._device_uid, err)
|
|
101
|
+
else:
|
|
102
|
+
_LOGGER.debug("Local connection established for device %s", self._device_uid)
|
|
103
|
+
|
|
104
|
+
def unsub() -> None:
|
|
105
|
+
"""Unsubscribe from all messages."""
|
|
106
|
+
if self._mqtt_unsub:
|
|
107
|
+
self._mqtt_unsub()
|
|
108
|
+
self._mqtt_unsub = None
|
|
109
|
+
if self._local_unsub:
|
|
110
|
+
self._local_unsub()
|
|
111
|
+
self._local_unsub = None
|
|
112
|
+
_LOGGER.debug("Unsubscribed from device %s", self._device_uid)
|
|
113
|
+
|
|
114
|
+
return unsub
|
|
115
|
+
|
|
116
|
+
async def _get_networking_info(self) -> NetworkInfo:
|
|
117
|
+
"""Retrieve networking information for the device.
|
|
118
|
+
|
|
119
|
+
This is a cloud only command used to get the local device's IP address.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
return await self._send_mqtt_decoded_command(RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo)
|
|
123
|
+
except RoborockException as e:
|
|
124
|
+
raise RoborockException(f"Network info failed for device {self._device_uid}") from e
|
|
125
|
+
|
|
126
|
+
async def _local_connect(self) -> Callable[[], None]:
|
|
127
|
+
"""Set up local connection if possible."""
|
|
128
|
+
_LOGGER.debug("Attempting to connect to local channel for device %s", self._device_uid)
|
|
129
|
+
if self._networking_info is None:
|
|
130
|
+
self._networking_info = await self._get_networking_info()
|
|
131
|
+
host = self._networking_info.ip
|
|
132
|
+
_LOGGER.debug("Connecting to local channel at %s", host)
|
|
133
|
+
self._local_channel = self._local_session(host)
|
|
134
|
+
try:
|
|
135
|
+
await self._local_channel.connect()
|
|
136
|
+
except RoborockException as e:
|
|
137
|
+
self._local_channel = None
|
|
138
|
+
raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
|
|
139
|
+
|
|
140
|
+
return await self._local_channel.subscribe(self._on_local_message)
|
|
141
|
+
|
|
142
|
+
async def send_decoded_command(
|
|
143
|
+
self,
|
|
144
|
+
method: CommandType,
|
|
145
|
+
*,
|
|
146
|
+
response_type: type[_T],
|
|
147
|
+
params: ParamsType = None,
|
|
148
|
+
) -> _T:
|
|
149
|
+
"""Send a command using the best available transport.
|
|
150
|
+
|
|
151
|
+
Will prefer local connection if available, falling back to MQTT.
|
|
152
|
+
"""
|
|
153
|
+
connection = "local" if self.is_local_connected else "mqtt"
|
|
154
|
+
_LOGGER.debug("Sending command (%s): %s, params=%s", connection, method, params)
|
|
155
|
+
if self._local_channel:
|
|
156
|
+
return await self._send_local_decoded_command(method, response_type=response_type, params=params)
|
|
157
|
+
return await self._send_mqtt_decoded_command(method, response_type=response_type, params=params)
|
|
158
|
+
|
|
159
|
+
async def _send_mqtt_raw_command(self, method: CommandType, params: ParamsType | None = None) -> dict[str, Any]:
|
|
160
|
+
"""Send a raw command and return a raw unparsed response."""
|
|
161
|
+
message = self._mqtt_payload_encoder(method, params)
|
|
162
|
+
_LOGGER.debug("Sending MQTT message for device %s: %s", self._device_uid, message)
|
|
163
|
+
response = await self._mqtt_channel.send_command(message)
|
|
164
|
+
return decode_rpc_response(response)
|
|
165
|
+
|
|
166
|
+
async def _send_mqtt_decoded_command(
|
|
167
|
+
self, method: CommandType, *, response_type: type[_T], params: ParamsType | None = None
|
|
168
|
+
) -> _T:
|
|
169
|
+
"""Send a command over MQTT and decode the response."""
|
|
170
|
+
decoded_response = await self._send_mqtt_raw_command(method, params)
|
|
171
|
+
return response_type.from_dict(decoded_response)
|
|
172
|
+
|
|
173
|
+
async def _send_local_raw_command(self, method: CommandType, params: ParamsType | None = None) -> dict[str, Any]:
|
|
174
|
+
"""Send a raw command over local connection."""
|
|
175
|
+
if not self._local_channel:
|
|
176
|
+
raise RoborockException("Local channel is not connected")
|
|
177
|
+
|
|
178
|
+
message = encode_local_payload(method, params)
|
|
179
|
+
_LOGGER.debug("Sending local message for device %s: %s", self._device_uid, message)
|
|
180
|
+
response = await self._local_channel.send_command(message)
|
|
181
|
+
return decode_rpc_response(response)
|
|
182
|
+
|
|
183
|
+
async def _send_local_decoded_command(
|
|
184
|
+
self, method: CommandType, *, response_type: type[_T], params: ParamsType | None = None
|
|
185
|
+
) -> _T:
|
|
186
|
+
"""Send a command over local connection and decode the response."""
|
|
187
|
+
if not self._local_channel:
|
|
188
|
+
raise RoborockException("Local channel is not connected")
|
|
189
|
+
decoded_response = await self._send_local_raw_command(method, params)
|
|
190
|
+
return response_type.from_dict(decoded_response)
|
|
191
|
+
|
|
192
|
+
def _on_mqtt_message(self, message: RoborockMessage) -> None:
|
|
193
|
+
"""Handle incoming MQTT messages."""
|
|
194
|
+
_LOGGER.debug("V1Channel received MQTT message from device %s: %s", self._device_uid, message)
|
|
195
|
+
if self._callback:
|
|
196
|
+
self._callback(message)
|
|
197
|
+
|
|
198
|
+
def _on_local_message(self, message: RoborockMessage) -> None:
|
|
199
|
+
"""Handle incoming local messages."""
|
|
200
|
+
_LOGGER.debug("V1Channel received local message from device %s: %s", self._device_uid, message)
|
|
201
|
+
if self._callback:
|
|
202
|
+
self._callback(message)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def create_v1_channel(
|
|
206
|
+
user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice
|
|
207
|
+
) -> V1Channel:
|
|
208
|
+
"""Create a V1Channel for the given device."""
|
|
209
|
+
security_data = create_security_data(user_data.rriot)
|
|
210
|
+
mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
|
|
211
|
+
local_session = create_local_session(device.local_key)
|
|
212
|
+
return V1Channel(device.duid, security_data, mqtt_channel, local_session=local_session)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Roborock A01 Protocol encoding and decoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Util.Padding import pad, unpad
|
|
9
|
+
|
|
10
|
+
from roborock.exceptions import RoborockException
|
|
11
|
+
from roborock.roborock_message import (
|
|
12
|
+
RoborockDyadDataProtocol,
|
|
13
|
+
RoborockMessage,
|
|
14
|
+
RoborockMessageProtocol,
|
|
15
|
+
RoborockZeoProtocol,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
A01_VERSION = b"A01"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def encode_mqtt_payload(data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any]) -> RoborockMessage:
|
|
24
|
+
"""Encode payload for A01 commands over MQTT."""
|
|
25
|
+
dps_data = {"dps": data}
|
|
26
|
+
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
|
|
27
|
+
return RoborockMessage(
|
|
28
|
+
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
29
|
+
version=A01_VERSION,
|
|
30
|
+
payload=payload,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
|
|
35
|
+
"""Decode a V1 RPC_RESPONSE message."""
|
|
36
|
+
if not message.payload:
|
|
37
|
+
raise RoborockException("Invalid A01 message format: missing payload")
|
|
38
|
+
try:
|
|
39
|
+
unpadded = unpad(message.payload, AES.block_size)
|
|
40
|
+
except ValueError as err:
|
|
41
|
+
raise RoborockException(f"Unable to unpad A01 payload: {err}")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
payload = json.loads(unpadded.decode())
|
|
45
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
46
|
+
raise RoborockException(f"Invalid A01 message payload: {e} for {message.payload!r}") from e
|
|
47
|
+
|
|
48
|
+
datapoints = payload.get("dps", {})
|
|
49
|
+
if not isinstance(datapoints, dict):
|
|
50
|
+
raise RoborockException(f"Invalid A01 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
51
|
+
try:
|
|
52
|
+
return {int(key): value for key, value in datapoints.items()}
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise RoborockException(f"Invalid A01 message format: 'dps' key should be an integer for {message.payload!r}")
|
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import json
|
|
7
|
+
import logging
|
|
6
8
|
import math
|
|
9
|
+
import secrets
|
|
7
10
|
import time
|
|
8
11
|
from collections.abc import Callable
|
|
9
12
|
from dataclasses import dataclass, field
|
|
10
13
|
from typing import Any
|
|
11
14
|
|
|
15
|
+
from roborock.containers import RRiot
|
|
16
|
+
from roborock.exceptions import RoborockException
|
|
17
|
+
from roborock.protocol import Utils
|
|
12
18
|
from roborock.roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol
|
|
13
19
|
from roborock.roborock_typing import RoborockCommand
|
|
14
20
|
from roborock.util import get_next_int
|
|
15
21
|
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SecurityData",
|
|
26
|
+
"create_security_data",
|
|
27
|
+
"create_mqtt_payload_encoder",
|
|
28
|
+
"encode_local_payload",
|
|
29
|
+
"decode_rpc_response",
|
|
30
|
+
]
|
|
31
|
+
|
|
16
32
|
CommandType = RoborockCommand | str
|
|
17
33
|
ParamsType = list | dict | int | None
|
|
18
34
|
|
|
@@ -29,6 +45,13 @@ class SecurityData:
|
|
|
29
45
|
return {"security": {"endpoint": self.endpoint, "nonce": self.nonce.hex().lower()}}
|
|
30
46
|
|
|
31
47
|
|
|
48
|
+
def create_security_data(rriot: RRiot) -> SecurityData:
|
|
49
|
+
"""Create a SecurityData instance for the given endpoint and nonce."""
|
|
50
|
+
nonce = secrets.token_bytes(16)
|
|
51
|
+
endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
|
|
52
|
+
return SecurityData(endpoint=endpoint, nonce=nonce)
|
|
53
|
+
|
|
54
|
+
|
|
32
55
|
@dataclass
|
|
33
56
|
class RequestMessage:
|
|
34
57
|
"""Data structure for v1 RoborockMessage payloads."""
|
|
@@ -89,3 +112,38 @@ def encode_local_payload(method: CommandType, params: ParamsType) -> RoborockMes
|
|
|
89
112
|
payload=payload,
|
|
90
113
|
message_retry=message_retry,
|
|
91
114
|
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[str, Any]:
|
|
118
|
+
"""Decode a V1 RPC_RESPONSE message."""
|
|
119
|
+
if not message.payload:
|
|
120
|
+
raise RoborockException("Invalid V1 message format: missing payload")
|
|
121
|
+
try:
|
|
122
|
+
payload = json.loads(message.payload.decode())
|
|
123
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
124
|
+
raise RoborockException(f"Invalid V1 message payload: {e} for {message.payload!r}") from e
|
|
125
|
+
|
|
126
|
+
_LOGGER.debug("Decoded V1 message payload: %s", payload)
|
|
127
|
+
datapoints = payload.get("dps", {})
|
|
128
|
+
if not isinstance(datapoints, dict):
|
|
129
|
+
raise RoborockException(f"Invalid V1 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
130
|
+
|
|
131
|
+
if not (data_point := datapoints.get("102")):
|
|
132
|
+
raise RoborockException("Invalid V1 message format: missing '102' data point")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data_point_response = json.loads(data_point)
|
|
136
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
137
|
+
raise RoborockException(f"Invalid V1 message data point '102': {e} for {message.payload!r}") from e
|
|
138
|
+
|
|
139
|
+
if error := data_point_response.get("error"):
|
|
140
|
+
raise RoborockException(f"Error in message: {error}")
|
|
141
|
+
|
|
142
|
+
if not (result := data_point_response.get("result")):
|
|
143
|
+
raise RoborockException(f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}")
|
|
144
|
+
_LOGGER.debug("Decoded V1 message result: %s", result)
|
|
145
|
+
if isinstance(result, list) and result:
|
|
146
|
+
result = result[0]
|
|
147
|
+
if not isinstance(result, dict):
|
|
148
|
+
raise RoborockException(f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}")
|
|
149
|
+
return result
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roborock import DeviceData
|
|
8
|
+
from roborock.api import RoborockClient
|
|
9
|
+
from roborock.code_mappings import (
|
|
10
|
+
DyadBrushSpeed,
|
|
11
|
+
DyadCleanMode,
|
|
12
|
+
DyadError,
|
|
13
|
+
DyadSelfCleanLevel,
|
|
14
|
+
DyadSelfCleanMode,
|
|
15
|
+
DyadSuction,
|
|
16
|
+
DyadWarmLevel,
|
|
17
|
+
DyadWaterLevel,
|
|
18
|
+
RoborockDyadStateCode,
|
|
19
|
+
ZeoDetergentType,
|
|
20
|
+
ZeoDryingMode,
|
|
21
|
+
ZeoError,
|
|
22
|
+
ZeoMode,
|
|
23
|
+
ZeoProgram,
|
|
24
|
+
ZeoRinse,
|
|
25
|
+
ZeoSoftenerType,
|
|
26
|
+
ZeoSpin,
|
|
27
|
+
ZeoState,
|
|
28
|
+
ZeoTemperature,
|
|
29
|
+
)
|
|
30
|
+
from roborock.containers import DyadProductInfo, DyadSndState, RoborockCategory
|
|
31
|
+
from roborock.exceptions import RoborockException
|
|
32
|
+
from roborock.protocols.a01_protocol import decode_rpc_response
|
|
33
|
+
from roborock.roborock_message import (
|
|
34
|
+
RoborockDyadDataProtocol,
|
|
35
|
+
RoborockMessage,
|
|
36
|
+
RoborockMessageProtocol,
|
|
37
|
+
RoborockZeoProtocol,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = {
|
|
44
|
+
RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name,
|
|
45
|
+
RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name,
|
|
46
|
+
RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: lambda val: DyadSelfCleanLevel(val).name,
|
|
47
|
+
RoborockDyadDataProtocol.WARM_LEVEL: lambda val: DyadWarmLevel(val).name,
|
|
48
|
+
RoborockDyadDataProtocol.CLEAN_MODE: lambda val: DyadCleanMode(val).name,
|
|
49
|
+
RoborockDyadDataProtocol.SUCTION: lambda val: DyadSuction(val).name,
|
|
50
|
+
RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name,
|
|
51
|
+
RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name,
|
|
52
|
+
RoborockDyadDataProtocol.POWER: lambda val: int(val),
|
|
53
|
+
RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val),
|
|
54
|
+
RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60),
|
|
55
|
+
RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60),
|
|
56
|
+
RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name,
|
|
57
|
+
RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val),
|
|
58
|
+
RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val),
|
|
59
|
+
RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val),
|
|
60
|
+
RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes
|
|
61
|
+
RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val),
|
|
62
|
+
RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time(
|
|
63
|
+
hour=int(val / 60), minute=val % 60
|
|
64
|
+
), # in minutes since 00:00
|
|
65
|
+
RoborockDyadDataProtocol.SILENT_MODE_END_TIME: lambda val: time(
|
|
66
|
+
hour=int(val / 60), minute=val % 60
|
|
67
|
+
), # in minutes since 00:00
|
|
68
|
+
RoborockDyadDataProtocol.RECENT_RUN_TIME: lambda val: [
|
|
69
|
+
int(v) for v in val.split(",")
|
|
70
|
+
], # minutes of cleaning in past few days.
|
|
71
|
+
RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val),
|
|
72
|
+
RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val),
|
|
73
|
+
RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = {
|
|
77
|
+
# ro
|
|
78
|
+
RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name,
|
|
79
|
+
RoborockZeoProtocol.COUNTDOWN: lambda val: int(val),
|
|
80
|
+
RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val),
|
|
81
|
+
RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name,
|
|
82
|
+
RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
|
|
83
|
+
RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val),
|
|
84
|
+
RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val),
|
|
85
|
+
# rw
|
|
86
|
+
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
|
|
87
|
+
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
|
|
88
|
+
RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name,
|
|
89
|
+
RoborockZeoProtocol.RINSE_TIMES: lambda val: ZeoRinse(val).name,
|
|
90
|
+
RoborockZeoProtocol.SPIN_LEVEL: lambda val: ZeoSpin(val).name,
|
|
91
|
+
RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name,
|
|
92
|
+
RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name,
|
|
93
|
+
RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name,
|
|
94
|
+
RoborockZeoProtocol.SOUND_SET: lambda val: bool(val),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def convert_dyad_value(protocol: int, value: Any) -> Any:
|
|
99
|
+
"""Convert a dyad protocol value to its corresponding type."""
|
|
100
|
+
protocol_value = RoborockDyadDataProtocol(protocol)
|
|
101
|
+
if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None:
|
|
102
|
+
return converter(value)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def convert_zeo_value(protocol: int, value: Any) -> Any:
|
|
107
|
+
"""Convert a zeo protocol value to its corresponding type."""
|
|
108
|
+
protocol_value = RoborockZeoProtocol(protocol)
|
|
109
|
+
if (converter := ZEO_PROTOCOL_ENTRIES.get(protocol_value)) is not None:
|
|
110
|
+
return converter(value)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RoborockClientA01(RoborockClient, ABC):
|
|
115
|
+
"""Roborock client base class for A01 devices."""
|
|
116
|
+
|
|
117
|
+
value_converter: Callable[[int, Any], Any] | None = None
|
|
118
|
+
|
|
119
|
+
def __init__(self, device_info: DeviceData, category: RoborockCategory):
|
|
120
|
+
"""Initialize the Roborock client."""
|
|
121
|
+
super().__init__(device_info)
|
|
122
|
+
if category == RoborockCategory.WET_DRY_VAC:
|
|
123
|
+
self.value_converter = convert_dyad_value
|
|
124
|
+
elif category == RoborockCategory.WASHING_MACHINE:
|
|
125
|
+
self.value_converter = convert_zeo_value
|
|
126
|
+
else:
|
|
127
|
+
_LOGGER.debug("Device category %s is not (yet) supported", category)
|
|
128
|
+
self.value_converter = None
|
|
129
|
+
|
|
130
|
+
def on_message_received(self, messages: list[RoborockMessage]) -> None:
|
|
131
|
+
if self.value_converter is None:
|
|
132
|
+
return
|
|
133
|
+
for message in messages:
|
|
134
|
+
protocol = message.protocol
|
|
135
|
+
if message.payload and protocol in [
|
|
136
|
+
RoborockMessageProtocol.RPC_RESPONSE,
|
|
137
|
+
RoborockMessageProtocol.GENERAL_REQUEST,
|
|
138
|
+
]:
|
|
139
|
+
try:
|
|
140
|
+
data_points = decode_rpc_response(message)
|
|
141
|
+
except RoborockException as err:
|
|
142
|
+
self._logger.debug("Failed to decode message: %s", err)
|
|
143
|
+
continue
|
|
144
|
+
for data_point_number, data_point in data_points.items():
|
|
145
|
+
self._logger.debug("received msg with dps, protocol: %s, %s", data_point_number, protocol)
|
|
146
|
+
if converted_response := self.value_converter(data_point_number, data_point):
|
|
147
|
+
queue = self._waiting_queue.get(int(data_point_number))
|
|
148
|
+
if queue and queue.protocol == protocol:
|
|
149
|
+
queue.set_result(converted_response)
|
|
150
|
+
else:
|
|
151
|
+
self._logger.debug(
|
|
152
|
+
"Received unknown data point %s for protocol %s, ignoring", data_point_number, protocol
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
async def update_values(
|
|
157
|
+
self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]
|
|
158
|
+
) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any]:
|
|
159
|
+
"""This should handle updating for each given protocol."""
|
|
@@ -4,11 +4,12 @@ import logging
|
|
|
4
4
|
import typing
|
|
5
5
|
|
|
6
6
|
from Crypto.Cipher import AES
|
|
7
|
-
from Crypto.Util.Padding import
|
|
7
|
+
from Crypto.Util.Padding import unpad
|
|
8
8
|
|
|
9
9
|
from roborock.cloud_api import RoborockMqttClient
|
|
10
10
|
from roborock.containers import DeviceData, RoborockCategory, UserData
|
|
11
11
|
from roborock.exceptions import RoborockException
|
|
12
|
+
from roborock.protocols.a01_protocol import encode_mqtt_payload
|
|
12
13
|
from roborock.roborock_message import (
|
|
13
14
|
RoborockDyadDataProtocol,
|
|
14
15
|
RoborockMessage,
|
|
@@ -43,7 +44,6 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
|
|
|
43
44
|
response_protocol = RoborockMessageProtocol.RPC_RESPONSE
|
|
44
45
|
|
|
45
46
|
m = self._encoder(roborock_message)
|
|
46
|
-
# self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
47
47
|
payload = json.loads(unpad(roborock_message.payload, AES.block_size))
|
|
48
48
|
futures = []
|
|
49
49
|
if "10000" in payload["dps"]:
|
|
@@ -56,7 +56,6 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
|
|
|
56
56
|
for i, dps in enumerate(json.loads(payload["dps"]["10000"])):
|
|
57
57
|
response = responses[i]
|
|
58
58
|
if isinstance(response, BaseException):
|
|
59
|
-
self._logger.warning("Timed out get req for %s after %s s", dps, self.queue_timeout)
|
|
60
59
|
dps_responses[dps] = None
|
|
61
60
|
else:
|
|
62
61
|
dps_responses[dps] = response
|
|
@@ -65,24 +64,14 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
|
|
|
65
64
|
async def update_values(
|
|
66
65
|
self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]
|
|
67
66
|
) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, typing.Any]:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
RoborockMessage(
|
|
71
|
-
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
72
|
-
version=b"A01",
|
|
73
|
-
payload=pad(json.dumps(payload).encode("utf-8"), AES.block_size),
|
|
74
|
-
)
|
|
67
|
+
message = encode_mqtt_payload(
|
|
68
|
+
{RoborockDyadDataProtocol.ID_QUERY: str([int(protocol) for protocol in dyad_data_protocols])}
|
|
75
69
|
)
|
|
70
|
+
return await self.send_message(message)
|
|
76
71
|
|
|
77
72
|
async def set_value(
|
|
78
73
|
self, protocol: RoborockDyadDataProtocol | RoborockZeoProtocol, value: typing.Any
|
|
79
74
|
) -> dict[int, typing.Any]:
|
|
80
75
|
"""Set a value for a specific protocol on the A01 device."""
|
|
81
|
-
|
|
82
|
-
return await self.send_message(
|
|
83
|
-
RoborockMessage(
|
|
84
|
-
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
85
|
-
version=b"A01",
|
|
86
|
-
payload=pad(json.dumps(payload).encode("utf-8"), AES.block_size),
|
|
87
|
-
)
|
|
88
|
-
)
|
|
76
|
+
message = encode_mqtt_payload({protocol: value})
|
|
77
|
+
return await self.send_message(message)
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import dataclasses
|
|
2
|
-
import json
|
|
3
|
-
import logging
|
|
4
|
-
import typing
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
|
-
from collections.abc import Callable
|
|
7
|
-
from datetime import time
|
|
8
|
-
|
|
9
|
-
from Crypto.Cipher import AES
|
|
10
|
-
from Crypto.Util.Padding import unpad
|
|
11
|
-
|
|
12
|
-
from roborock import DeviceData
|
|
13
|
-
from roborock.api import RoborockClient
|
|
14
|
-
from roborock.code_mappings import (
|
|
15
|
-
DyadBrushSpeed,
|
|
16
|
-
DyadCleanMode,
|
|
17
|
-
DyadError,
|
|
18
|
-
DyadSelfCleanLevel,
|
|
19
|
-
DyadSelfCleanMode,
|
|
20
|
-
DyadSuction,
|
|
21
|
-
DyadWarmLevel,
|
|
22
|
-
DyadWaterLevel,
|
|
23
|
-
RoborockDyadStateCode,
|
|
24
|
-
ZeoDetergentType,
|
|
25
|
-
ZeoDryingMode,
|
|
26
|
-
ZeoError,
|
|
27
|
-
ZeoMode,
|
|
28
|
-
ZeoProgram,
|
|
29
|
-
ZeoRinse,
|
|
30
|
-
ZeoSoftenerType,
|
|
31
|
-
ZeoSpin,
|
|
32
|
-
ZeoState,
|
|
33
|
-
ZeoTemperature,
|
|
34
|
-
)
|
|
35
|
-
from roborock.containers import DyadProductInfo, DyadSndState, RoborockCategory
|
|
36
|
-
from roborock.roborock_message import (
|
|
37
|
-
RoborockDyadDataProtocol,
|
|
38
|
-
RoborockMessage,
|
|
39
|
-
RoborockMessageProtocol,
|
|
40
|
-
RoborockZeoProtocol,
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
_LOGGER = logging.getLogger(__name__)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@dataclasses.dataclass
|
|
47
|
-
class A01ProtocolCacheEntry:
|
|
48
|
-
post_process_fn: Callable
|
|
49
|
-
value: typing.Any | None = None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# Right now this cache is not active, it was too much complexity for the initial addition of dyad.
|
|
53
|
-
protocol_entries = {
|
|
54
|
-
RoborockDyadDataProtocol.STATUS: A01ProtocolCacheEntry(lambda val: RoborockDyadStateCode(val).name),
|
|
55
|
-
RoborockDyadDataProtocol.SELF_CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadSelfCleanMode(val).name),
|
|
56
|
-
RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: A01ProtocolCacheEntry(lambda val: DyadSelfCleanLevel(val).name),
|
|
57
|
-
RoborockDyadDataProtocol.WARM_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWarmLevel(val).name),
|
|
58
|
-
RoborockDyadDataProtocol.CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadCleanMode(val).name),
|
|
59
|
-
RoborockDyadDataProtocol.SUCTION: A01ProtocolCacheEntry(lambda val: DyadSuction(val).name),
|
|
60
|
-
RoborockDyadDataProtocol.WATER_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWaterLevel(val).name),
|
|
61
|
-
RoborockDyadDataProtocol.BRUSH_SPEED: A01ProtocolCacheEntry(lambda val: DyadBrushSpeed(val).name),
|
|
62
|
-
RoborockDyadDataProtocol.POWER: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
63
|
-
RoborockDyadDataProtocol.AUTO_DRY: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
64
|
-
RoborockDyadDataProtocol.MESH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)),
|
|
65
|
-
RoborockDyadDataProtocol.BRUSH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)),
|
|
66
|
-
RoborockDyadDataProtocol.ERROR: A01ProtocolCacheEntry(lambda val: DyadError(val).name),
|
|
67
|
-
RoborockDyadDataProtocol.VOLUME_SET: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
68
|
-
RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
69
|
-
RoborockDyadDataProtocol.AUTO_DRY_MODE: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
70
|
-
RoborockDyadDataProtocol.SILENT_DRY_DURATION: A01ProtocolCacheEntry(lambda val: int(val)), # in minutes
|
|
71
|
-
RoborockDyadDataProtocol.SILENT_MODE: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
72
|
-
RoborockDyadDataProtocol.SILENT_MODE_START_TIME: A01ProtocolCacheEntry(
|
|
73
|
-
lambda val: time(hour=int(val / 60), minute=val % 60)
|
|
74
|
-
), # in minutes since 00:00
|
|
75
|
-
RoborockDyadDataProtocol.SILENT_MODE_END_TIME: A01ProtocolCacheEntry(
|
|
76
|
-
lambda val: time(hour=int(val / 60), minute=val % 60)
|
|
77
|
-
), # in minutes since 00:00
|
|
78
|
-
RoborockDyadDataProtocol.RECENT_RUN_TIME: A01ProtocolCacheEntry(
|
|
79
|
-
lambda val: [int(v) for v in val.split(",")]
|
|
80
|
-
), # minutes of cleaning in past few days.
|
|
81
|
-
RoborockDyadDataProtocol.TOTAL_RUN_TIME: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
82
|
-
RoborockDyadDataProtocol.SND_STATE: A01ProtocolCacheEntry(lambda val: DyadSndState.from_dict(val)),
|
|
83
|
-
RoborockDyadDataProtocol.PRODUCT_INFO: A01ProtocolCacheEntry(lambda val: DyadProductInfo.from_dict(val)),
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
zeo_data_protocol_entries = {
|
|
87
|
-
# ro
|
|
88
|
-
RoborockZeoProtocol.STATE: A01ProtocolCacheEntry(lambda val: ZeoState(val).name),
|
|
89
|
-
RoborockZeoProtocol.COUNTDOWN: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
90
|
-
RoborockZeoProtocol.WASHING_LEFT: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
91
|
-
RoborockZeoProtocol.ERROR: A01ProtocolCacheEntry(lambda val: ZeoError(val).name),
|
|
92
|
-
RoborockZeoProtocol.TIMES_AFTER_CLEAN: A01ProtocolCacheEntry(lambda val: int(val)),
|
|
93
|
-
RoborockZeoProtocol.DETERGENT_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
94
|
-
RoborockZeoProtocol.SOFTENER_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
95
|
-
# rw
|
|
96
|
-
RoborockZeoProtocol.MODE: A01ProtocolCacheEntry(lambda val: ZeoMode(val).name),
|
|
97
|
-
RoborockZeoProtocol.PROGRAM: A01ProtocolCacheEntry(lambda val: ZeoProgram(val).name),
|
|
98
|
-
RoborockZeoProtocol.TEMP: A01ProtocolCacheEntry(lambda val: ZeoTemperature(val).name),
|
|
99
|
-
RoborockZeoProtocol.RINSE_TIMES: A01ProtocolCacheEntry(lambda val: ZeoRinse(val).name),
|
|
100
|
-
RoborockZeoProtocol.SPIN_LEVEL: A01ProtocolCacheEntry(lambda val: ZeoSpin(val).name),
|
|
101
|
-
RoborockZeoProtocol.DRYING_MODE: A01ProtocolCacheEntry(lambda val: ZeoDryingMode(val).name),
|
|
102
|
-
RoborockZeoProtocol.DETERGENT_TYPE: A01ProtocolCacheEntry(lambda val: ZeoDetergentType(val).name),
|
|
103
|
-
RoborockZeoProtocol.SOFTENER_TYPE: A01ProtocolCacheEntry(lambda val: ZeoSoftenerType(val).name),
|
|
104
|
-
RoborockZeoProtocol.SOUND_SET: A01ProtocolCacheEntry(lambda val: bool(val)),
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
class RoborockClientA01(RoborockClient, ABC):
|
|
109
|
-
"""Roborock client base class for A01 devices."""
|
|
110
|
-
|
|
111
|
-
def __init__(self, device_info: DeviceData, category: RoborockCategory):
|
|
112
|
-
"""Initialize the Roborock client."""
|
|
113
|
-
super().__init__(device_info)
|
|
114
|
-
self.category = category
|
|
115
|
-
|
|
116
|
-
def on_message_received(self, messages: list[RoborockMessage]) -> None:
|
|
117
|
-
for message in messages:
|
|
118
|
-
protocol = message.protocol
|
|
119
|
-
if message.payload and protocol in [
|
|
120
|
-
RoborockMessageProtocol.RPC_RESPONSE,
|
|
121
|
-
RoborockMessageProtocol.GENERAL_REQUEST,
|
|
122
|
-
]:
|
|
123
|
-
payload = message.payload
|
|
124
|
-
try:
|
|
125
|
-
payload = unpad(payload, AES.block_size)
|
|
126
|
-
except Exception as err:
|
|
127
|
-
self._logger.debug("Failed to unpad payload: %s", err)
|
|
128
|
-
continue
|
|
129
|
-
payload_json = json.loads(payload.decode())
|
|
130
|
-
for data_point_number, data_point in payload_json.get("dps").items():
|
|
131
|
-
data_point_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
|
|
132
|
-
self._logger.debug("received msg with dps, protocol: %s, %s", data_point_number, protocol)
|
|
133
|
-
entries: dict
|
|
134
|
-
if self.category == RoborockCategory.WET_DRY_VAC:
|
|
135
|
-
data_point_protocol = RoborockDyadDataProtocol(int(data_point_number))
|
|
136
|
-
entries = protocol_entries
|
|
137
|
-
elif self.category == RoborockCategory.WASHING_MACHINE:
|
|
138
|
-
data_point_protocol = RoborockZeoProtocol(int(data_point_number))
|
|
139
|
-
entries = zeo_data_protocol_entries
|
|
140
|
-
else:
|
|
141
|
-
continue
|
|
142
|
-
if data_point_protocol in entries:
|
|
143
|
-
# Auto convert into data struct we want.
|
|
144
|
-
converted_response = entries[data_point_protocol].post_process_fn(data_point)
|
|
145
|
-
queue = self._waiting_queue.get(int(data_point_number))
|
|
146
|
-
if queue and queue.protocol == protocol:
|
|
147
|
-
queue.set_result(converted_response)
|
|
148
|
-
|
|
149
|
-
@abstractmethod
|
|
150
|
-
async def update_values(
|
|
151
|
-
self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]
|
|
152
|
-
) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, typing.Any]:
|
|
153
|
-
"""This should handle updating for each given protocol."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.25.1 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|