python-roborock 2.26.0__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.26.0 → python_roborock-2.27.0}/PKG-INFO +1 -1
- {python_roborock-2.26.0 → python_roborock-2.27.0}/pyproject.toml +1 -1
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/cli.py +10 -1
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/device.py +33 -21
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/device_manager.py +11 -4
- {python_roborock-2.26.0 → 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.26.0 → python_roborock-2.27.0}/roborock/protocols/v1_protocol.py +58 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/LICENSE +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/README.md +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/__init__.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/api.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/const.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/containers.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/mqtt_channel.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/local_api.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/protocol.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/py.typed +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/util.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
- {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
- {python_roborock-2.26.0 → 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)
|
|
@@ -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)
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_client_a01.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|