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.
Files changed (40) hide show
  1. {python_roborock-2.26.0 → python_roborock-2.27.0}/PKG-INFO +1 -1
  2. {python_roborock-2.26.0 → python_roborock-2.27.0}/pyproject.toml +1 -1
  3. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/cli.py +10 -1
  4. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/device.py +33 -21
  5. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/device_manager.py +11 -4
  6. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/local_channel.py +23 -1
  7. python_roborock-2.27.0/roborock/devices/v1_channel.py +212 -0
  8. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/protocols/v1_protocol.py +58 -0
  9. {python_roborock-2.26.0 → python_roborock-2.27.0}/LICENSE +0 -0
  10. {python_roborock-2.26.0 → python_roborock-2.27.0}/README.md +0 -0
  11. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/__init__.py +0 -0
  12. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/api.py +0 -0
  13. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/cloud_api.py +0 -0
  14. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/code_mappings.py +0 -0
  15. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/command_cache.py +0 -0
  16. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/const.py +0 -0
  17. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/containers.py +0 -0
  18. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/README.md +0 -0
  19. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/__init__.py +0 -0
  20. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/devices/mqtt_channel.py +0 -0
  21. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/exceptions.py +0 -0
  22. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/local_api.py +0 -0
  23. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/__init__.py +0 -0
  24. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/roborock_session.py +0 -0
  25. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/mqtt/session.py +0 -0
  26. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/protocol.py +0 -0
  27. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/protocols/a01_protocol.py +0 -0
  28. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/py.typed +0 -0
  29. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_future.py +0 -0
  30. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_message.py +0 -0
  31. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/roborock_typing.py +0 -0
  32. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/util.py +0 -0
  33. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/__init__.py +0 -0
  34. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  35. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  36. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  37. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/__init__.py +0 -0
  38. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  39. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  40. {python_roborock-2.26.0 → python_roborock-2.27.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.26.0
3
+ Version: 2.27.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.26.0"
3
+ version = "2.27.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -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. Listening for messages...")
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 HomeDataDevice, HomeDataProduct, UserData
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 .mqtt_channel import MqttChannel
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
- mqtt_channel: MqttChannel,
49
+ v1_channel: V1Channel,
42
50
  ) -> None:
43
51
  """Initialize the RoborockDevice.
44
52
 
45
- The device takes ownership of the MQTT channel for communication with the device.
46
- Use `connect()` to establish the connection, which will set up the MQTT channel
47
- for receiving messages from the device. Use `close()` to unsubscribe from the MQTT
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._mqtt_channel = mqtt_channel
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
- async def connect(self) -> None:
86
- """Connect to the device using MQTT.
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
- This method will set up the MQTT channel for communication with the device.
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._mqtt_channel.subscribe(self._on_mqtt_message)
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 the MQTT connection to the device.
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 _on_mqtt_message(self, message: RoborockMessage) -> None:
104
- """Handle incoming MQTT messages from the device.
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 method should be overridden in subclasses to handle specific device messages.
117
+ This is a placeholder command and will likely be changed/moved in the future.
107
118
  """
108
- _LOGGER.debug("Received message from device %s: %s", self.duid, message)
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 .mqtt_channel import MqttChannel
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
- mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
118
- return RoborockDevice(user_data, device, product, mqtt_channel)
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
- async def close(self) -> None:
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