roborock-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Roborock A01 Protocol encoding and decoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from Crypto.Cipher import AES
|
|
9
|
+
from Crypto.Util.Padding import pad, unpad
|
|
10
|
+
|
|
11
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException
|
|
12
|
+
from roborock_cli._vendor.roborock.roborock_message import (
|
|
13
|
+
RoborockDyadDataProtocol,
|
|
14
|
+
RoborockMessage,
|
|
15
|
+
RoborockMessageProtocol,
|
|
16
|
+
RoborockZeoProtocol,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
A01_VERSION = b"A01"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _no_encode(value: Any) -> Any:
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def encode_mqtt_payload(
|
|
29
|
+
data: dict[RoborockDyadDataProtocol, Any]
|
|
30
|
+
| dict[RoborockZeoProtocol, Any]
|
|
31
|
+
| dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any],
|
|
32
|
+
value_encoder: Callable[[Any], Any] | None = None,
|
|
33
|
+
) -> RoborockMessage:
|
|
34
|
+
"""Encode payload for A01 commands over MQTT.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
data: The data to encode.
|
|
38
|
+
value_encoder: A function to encode the values of the dictionary.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
RoborockMessage: The encoded message.
|
|
42
|
+
"""
|
|
43
|
+
if value_encoder is None:
|
|
44
|
+
value_encoder = _no_encode
|
|
45
|
+
dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}}
|
|
46
|
+
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
|
|
47
|
+
return RoborockMessage(
|
|
48
|
+
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
49
|
+
version=A01_VERSION,
|
|
50
|
+
payload=payload,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
|
|
55
|
+
"""Decode a V1 RPC_RESPONSE message."""
|
|
56
|
+
if not message.payload:
|
|
57
|
+
raise RoborockException("Invalid A01 message format: missing payload")
|
|
58
|
+
try:
|
|
59
|
+
unpadded = unpad(message.payload, AES.block_size)
|
|
60
|
+
except ValueError as err:
|
|
61
|
+
raise RoborockException(f"Unable to unpad A01 payload: {err}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(unpadded.decode())
|
|
65
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
66
|
+
raise RoborockException(f"Invalid A01 message payload: {e} for {message.payload!r}") from e
|
|
67
|
+
|
|
68
|
+
datapoints = payload.get("dps", {})
|
|
69
|
+
if not isinstance(datapoints, dict):
|
|
70
|
+
raise RoborockException(f"Invalid A01 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
71
|
+
try:
|
|
72
|
+
return {int(key): value for key, value in datapoints.items()}
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise RoborockException(f"Invalid A01 message format: 'dps' key should be an integer for {message.payload!r}")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Roborock B01 Protocol encoding and decoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
8
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException
|
|
9
|
+
from roborock_cli._vendor.roborock.roborock_message import (
|
|
10
|
+
RoborockMessage,
|
|
11
|
+
RoborockMessageProtocol,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
B01_VERSION = b"B01"
|
|
17
|
+
ParamsType = list | dict | int | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode_mqtt_payload(command: B01_Q10_DP, params: ParamsType) -> RoborockMessage:
|
|
21
|
+
"""Encode payload for B01 Q10 commands over MQTT.
|
|
22
|
+
|
|
23
|
+
This does not perform any special encoding for the command parameters and expects
|
|
24
|
+
them to already be in a request specific format.
|
|
25
|
+
"""
|
|
26
|
+
dps_data = {
|
|
27
|
+
"dps": {
|
|
28
|
+
# Important: some commands use falsy values so only default to `{}` when params is actually None.
|
|
29
|
+
command.code: params if params is not None else {},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return RoborockMessage(
|
|
33
|
+
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
34
|
+
version=B01_VERSION,
|
|
35
|
+
payload=json.dumps(dps_data).encode("utf-8"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _convert_datapoints(datapoints: dict[str, Any], message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
|
|
40
|
+
"""Convert the 'dps' dictionary keys from strings to B01_Q10_DP enums."""
|
|
41
|
+
result: dict[B01_Q10_DP, Any] = {}
|
|
42
|
+
for key, value in datapoints.items():
|
|
43
|
+
try:
|
|
44
|
+
code = int(key)
|
|
45
|
+
except ValueError as e:
|
|
46
|
+
raise ValueError(f"dps key is not a valid integer: {e} for {message.payload!r}") from e
|
|
47
|
+
if (dps := B01_Q10_DP.from_code_optional(code)) is not None:
|
|
48
|
+
result[dps] = value
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
|
|
53
|
+
"""Decode a B01 Q10 RPC_RESPONSE message.
|
|
54
|
+
|
|
55
|
+
This does not perform any special decoding for the response body, but does
|
|
56
|
+
convert the 'dps' keys from strings to B01_Q10_DP enums.
|
|
57
|
+
"""
|
|
58
|
+
if not message.payload:
|
|
59
|
+
raise RoborockException("Invalid B01 message format: missing payload")
|
|
60
|
+
try:
|
|
61
|
+
payload = json.loads(message.payload.decode())
|
|
62
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
63
|
+
raise RoborockException(f"Invalid B01 json payload: {e} for {message.payload!r}") from e
|
|
64
|
+
|
|
65
|
+
if (datapoints := payload.get("dps")) is None:
|
|
66
|
+
raise RoborockException(f"Invalid B01 json payload: missing 'dps' for {message.payload!r}")
|
|
67
|
+
if not isinstance(datapoints, dict):
|
|
68
|
+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
result = _convert_datapoints(datapoints, message)
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
raise RoborockException(f"Invalid B01 message format: {e}") from e
|
|
74
|
+
|
|
75
|
+
# The COMMON response contains nested datapoints need conversion. To simplify
|
|
76
|
+
# response handling at higher levels we flatten these into the main result.
|
|
77
|
+
if B01_Q10_DP.COMMON in result:
|
|
78
|
+
common_result = result.pop(B01_Q10_DP.COMMON)
|
|
79
|
+
if not isinstance(common_result, dict):
|
|
80
|
+
raise RoborockException(f"Invalid dpCommon format: expected dict, got {type(common_result).__name__}")
|
|
81
|
+
try:
|
|
82
|
+
common_dps_result = _convert_datapoints(common_result, message)
|
|
83
|
+
except ValueError as e:
|
|
84
|
+
raise RoborockException(f"Invalid dpCommon format: {e}") from e
|
|
85
|
+
result.update(common_dps_result)
|
|
86
|
+
|
|
87
|
+
return result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Roborock B01 Protocol encoding and decoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from Crypto.Cipher import AES
|
|
9
|
+
from Crypto.Util.Padding import pad, unpad
|
|
10
|
+
|
|
11
|
+
from roborock_cli._vendor.roborock import RoborockB01Q7Methods
|
|
12
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException
|
|
13
|
+
from roborock_cli._vendor.roborock.roborock_message import (
|
|
14
|
+
RoborockMessage,
|
|
15
|
+
RoborockMessageProtocol,
|
|
16
|
+
)
|
|
17
|
+
from roborock_cli._vendor.roborock.util import get_next_int
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
B01_VERSION = b"B01"
|
|
22
|
+
CommandType = RoborockB01Q7Methods | str
|
|
23
|
+
ParamsType = list | dict | int | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Q7RequestMessage:
|
|
28
|
+
"""Data class for B01 Q7 request message."""
|
|
29
|
+
|
|
30
|
+
dps: int
|
|
31
|
+
command: CommandType
|
|
32
|
+
params: ParamsType
|
|
33
|
+
msg_id: int = field(default_factory=lambda: get_next_int(100000000000, 999999999999))
|
|
34
|
+
|
|
35
|
+
def to_dps_value(self) -> dict[int, Any]:
|
|
36
|
+
"""Return the 'dps' payload dictionary."""
|
|
37
|
+
return {
|
|
38
|
+
self.dps: {
|
|
39
|
+
"method": str(self.command),
|
|
40
|
+
"msgId": str(self.msg_id),
|
|
41
|
+
# Important: some B01 methods use an empty object `{}` (not `[]`) for
|
|
42
|
+
# "no params", and some setters legitimately send `0` which is falsy.
|
|
43
|
+
# Only default to `[]` when params is actually None.
|
|
44
|
+
"params": self.params if self.params is not None else [],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_mqtt_payload(request: Q7RequestMessage) -> RoborockMessage:
|
|
50
|
+
"""Encode payload for B01 commands over MQTT."""
|
|
51
|
+
dps_data = {"dps": request.to_dps_value()}
|
|
52
|
+
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
|
|
53
|
+
return RoborockMessage(
|
|
54
|
+
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
55
|
+
version=B01_VERSION,
|
|
56
|
+
payload=payload,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
|
|
61
|
+
"""Decode a B01 RPC_RESPONSE message."""
|
|
62
|
+
if not message.payload:
|
|
63
|
+
raise RoborockException("Invalid B01 message format: missing payload")
|
|
64
|
+
try:
|
|
65
|
+
unpadded = unpad(message.payload, AES.block_size)
|
|
66
|
+
except ValueError:
|
|
67
|
+
# It would be better to fail down the line.
|
|
68
|
+
unpadded = message.payload
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
payload = json.loads(unpadded.decode())
|
|
72
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
73
|
+
raise RoborockException(f"Invalid B01 message payload: {e} for {message.payload!r}") from e
|
|
74
|
+
|
|
75
|
+
datapoints = payload.get("dps", {})
|
|
76
|
+
if not isinstance(datapoints, dict):
|
|
77
|
+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
78
|
+
try:
|
|
79
|
+
return {int(key): value for key, value in datapoints.items()}
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise RoborockException(f"Invalid B01 message format: 'dps' key should be an integer for {message.payload!r}")
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Roborock V1 Protocol Encoder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
import struct
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any, Protocol, TypeVar, overload
|
|
14
|
+
|
|
15
|
+
from roborock_cli._vendor.roborock.data import RoborockBase, RRiot
|
|
16
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException, RoborockInvalidStatus, RoborockUnsupportedFeature
|
|
17
|
+
from roborock_cli._vendor.roborock.protocol import Utils
|
|
18
|
+
from roborock_cli._vendor.roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
|
|
19
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
20
|
+
from roborock_cli._vendor.roborock.util import get_next_int, get_timestamp
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SecurityData",
|
|
26
|
+
"create_security_data",
|
|
27
|
+
"decode_rpc_response",
|
|
28
|
+
"V1RpcChannel",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
CommandType = RoborockCommand | str
|
|
32
|
+
ParamsType = list | dict | int | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LocalProtocolVersion(StrEnum):
|
|
36
|
+
"""Supported local protocol versions. Different from vacuum protocol versions."""
|
|
37
|
+
|
|
38
|
+
L01 = "L01"
|
|
39
|
+
V1 = "1.0"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, kw_only=True)
|
|
43
|
+
class SecurityData:
|
|
44
|
+
"""Security data included in the request for some V1 commands."""
|
|
45
|
+
|
|
46
|
+
endpoint: str
|
|
47
|
+
nonce: bytes
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
"""Convert security data to a dictionary for sending in the payload."""
|
|
51
|
+
return {"security": {"endpoint": self.endpoint, "nonce": self.nonce.hex().lower()}}
|
|
52
|
+
|
|
53
|
+
def to_diagnostic_data(self) -> dict[str, Any]:
|
|
54
|
+
"""Convert security data to a dictionary for debugging purposes."""
|
|
55
|
+
return {"nonce": self.nonce.hex().lower()}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_security_data(rriot: RRiot) -> SecurityData:
|
|
59
|
+
"""Create a SecurityData instance for the given endpoint and nonce."""
|
|
60
|
+
nonce = secrets.token_bytes(16)
|
|
61
|
+
endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
|
|
62
|
+
return SecurityData(endpoint=endpoint, nonce=nonce)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class RequestMessage:
|
|
67
|
+
"""Data structure for v1 RoborockMessage payloads."""
|
|
68
|
+
|
|
69
|
+
method: RoborockCommand | str
|
|
70
|
+
params: ParamsType
|
|
71
|
+
timestamp: int = field(default_factory=lambda: get_timestamp())
|
|
72
|
+
request_id: int = field(default_factory=lambda: get_next_int(10000, 32767))
|
|
73
|
+
|
|
74
|
+
def encode_message(
|
|
75
|
+
self,
|
|
76
|
+
protocol: RoborockMessageProtocol,
|
|
77
|
+
security_data: SecurityData | None = None,
|
|
78
|
+
version: LocalProtocolVersion = LocalProtocolVersion.V1,
|
|
79
|
+
) -> RoborockMessage:
|
|
80
|
+
"""Convert the request message to a RoborockMessage."""
|
|
81
|
+
return RoborockMessage(
|
|
82
|
+
timestamp=self.timestamp,
|
|
83
|
+
protocol=protocol,
|
|
84
|
+
payload=self._as_payload(security_data=security_data),
|
|
85
|
+
version=version.value.encode(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _as_payload(self, security_data: SecurityData | None) -> bytes:
|
|
89
|
+
"""Convert the request arguments to a dictionary."""
|
|
90
|
+
inner = {
|
|
91
|
+
"id": self.request_id,
|
|
92
|
+
"method": self.method,
|
|
93
|
+
"params": self.params or [],
|
|
94
|
+
**(security_data.to_dict() if security_data else {}),
|
|
95
|
+
}
|
|
96
|
+
return bytes(
|
|
97
|
+
json.dumps(
|
|
98
|
+
{
|
|
99
|
+
"dps": {"101": json.dumps(inner, separators=(",", ":"))},
|
|
100
|
+
"t": self.timestamp,
|
|
101
|
+
},
|
|
102
|
+
separators=(",", ":"),
|
|
103
|
+
).encode()
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
ResponseData = dict[str, Any] | list | int
|
|
108
|
+
|
|
109
|
+
# V1 RPC error code mappings to specific exception types
|
|
110
|
+
_V1_ERROR_CODE_EXCEPTIONS: dict[int, type[RoborockException]] = {
|
|
111
|
+
-10007: RoborockInvalidStatus, # "invalid status" - device action locked
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _create_api_error(error: Any) -> RoborockException:
|
|
116
|
+
"""Create an appropriate exception for a V1 RPC error response.
|
|
117
|
+
|
|
118
|
+
Maps known error codes to specific exception types for easier handling
|
|
119
|
+
at higher levels.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(error, dict):
|
|
122
|
+
code = error.get("code")
|
|
123
|
+
if isinstance(code, int) and (exc_type := _V1_ERROR_CODE_EXCEPTIONS.get(code)):
|
|
124
|
+
return exc_type(error)
|
|
125
|
+
return RoborockException(error)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(kw_only=True, frozen=True)
|
|
129
|
+
class ResponseMessage:
|
|
130
|
+
"""Data structure for v1 RoborockMessage responses."""
|
|
131
|
+
|
|
132
|
+
request_id: int | None
|
|
133
|
+
"""The request ID of the response."""
|
|
134
|
+
|
|
135
|
+
data: ResponseData
|
|
136
|
+
"""The data of the response, where the type depends on the command."""
|
|
137
|
+
|
|
138
|
+
api_error: RoborockException | None = None
|
|
139
|
+
"""The API error message of the response if any."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
|
|
143
|
+
"""Decode a V1 RPC_RESPONSE message.
|
|
144
|
+
|
|
145
|
+
This will raise a RoborockException if the message cannot be parsed. A
|
|
146
|
+
response object will be returned even if there is an error in the
|
|
147
|
+
response, as long as we can extract the request ID. This is so we can
|
|
148
|
+
associate an API response with a request even if there was an error.
|
|
149
|
+
"""
|
|
150
|
+
if not message.payload:
|
|
151
|
+
return ResponseMessage(request_id=message.seq, data={})
|
|
152
|
+
try:
|
|
153
|
+
payload = json.loads(message.payload.decode())
|
|
154
|
+
except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as e:
|
|
155
|
+
raise RoborockException(f"Invalid V1 message payload: {e} for {message.payload!r}") from e
|
|
156
|
+
|
|
157
|
+
_LOGGER.debug("Decoded V1 message payload: %s", payload)
|
|
158
|
+
datapoints = payload.get("dps", {})
|
|
159
|
+
if not isinstance(datapoints, dict):
|
|
160
|
+
raise RoborockException(f"Invalid V1 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
161
|
+
|
|
162
|
+
if not (data_point := datapoints.get(str(RoborockMessageProtocol.RPC_RESPONSE))):
|
|
163
|
+
raise RoborockException(
|
|
164
|
+
f"Invalid V1 message format: missing '{RoborockMessageProtocol.RPC_RESPONSE}' data point"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
data_point_response = json.loads(data_point)
|
|
169
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
170
|
+
raise RoborockException(
|
|
171
|
+
f"Invalid V1 message data point '{RoborockMessageProtocol.RPC_RESPONSE}': {e} for {message.payload!r}"
|
|
172
|
+
) from e
|
|
173
|
+
|
|
174
|
+
request_id: int | None = data_point_response.get("id")
|
|
175
|
+
api_error: RoborockException | None = None
|
|
176
|
+
if error := data_point_response.get("error"):
|
|
177
|
+
api_error = _create_api_error(error)
|
|
178
|
+
|
|
179
|
+
if (result := data_point_response.get("result")) is None:
|
|
180
|
+
# Some firmware versions return an error-only response (no "result" key).
|
|
181
|
+
# Preserve that error instead of overwriting it with a parsing exception.
|
|
182
|
+
if api_error is None:
|
|
183
|
+
api_error = RoborockException(
|
|
184
|
+
f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}"
|
|
185
|
+
)
|
|
186
|
+
result = {}
|
|
187
|
+
else:
|
|
188
|
+
_LOGGER.debug("Decoded V1 message result: %s", result)
|
|
189
|
+
if isinstance(result, str):
|
|
190
|
+
if result == "unknown_method":
|
|
191
|
+
api_error = RoborockUnsupportedFeature("The method called is not recognized by the device.")
|
|
192
|
+
elif result != "ok":
|
|
193
|
+
api_error = RoborockException(f"Unexpected API Result: {result}")
|
|
194
|
+
result = {}
|
|
195
|
+
if not isinstance(result, dict | list | int):
|
|
196
|
+
# If we already have an API error, prefer returning a response object
|
|
197
|
+
# rather than failing to decode the message entirely.
|
|
198
|
+
if api_error is None:
|
|
199
|
+
raise RoborockException(
|
|
200
|
+
f"Invalid V1 message format: 'result' was unexpected type {type(result)}. {message.payload!r}"
|
|
201
|
+
)
|
|
202
|
+
result = {}
|
|
203
|
+
|
|
204
|
+
if not request_id and api_error:
|
|
205
|
+
raise api_error
|
|
206
|
+
return ResponseMessage(request_id=request_id, data=result, api_error=api_error)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class MapResponse:
|
|
211
|
+
"""Data structure for the V1 Map response."""
|
|
212
|
+
|
|
213
|
+
request_id: int
|
|
214
|
+
"""The request ID of the map response."""
|
|
215
|
+
|
|
216
|
+
data: bytes
|
|
217
|
+
"""The map data, decrypted and decompressed."""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:
|
|
221
|
+
"""Create a decoder for V1 map response messages."""
|
|
222
|
+
|
|
223
|
+
def _decode_map_response(message: RoborockMessage) -> MapResponse | None:
|
|
224
|
+
"""Decode a V1 map response message."""
|
|
225
|
+
if not message.payload or len(message.payload) < 24:
|
|
226
|
+
raise RoborockException("Invalid V1 map response format: missing payload")
|
|
227
|
+
header, body = message.payload[:24], message.payload[24:]
|
|
228
|
+
[endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header)
|
|
229
|
+
if not endpoint.decode().startswith(security_data.endpoint):
|
|
230
|
+
_LOGGER.debug("Received map response not requested by this device, ignoring.")
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
decrypted = Utils.decrypt_cbc(body, security_data.nonce)
|
|
234
|
+
except ValueError as err:
|
|
235
|
+
raise RoborockException("Failed to decode map message payload") from err
|
|
236
|
+
decompressed = Utils.decompress(decrypted)
|
|
237
|
+
return MapResponse(request_id=request_id, data=decompressed)
|
|
238
|
+
|
|
239
|
+
return _decode_map_response
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_T = TypeVar("_T", bound=RoborockBase)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class V1RpcChannel(Protocol):
|
|
246
|
+
"""Protocol for V1 RPC channels.
|
|
247
|
+
|
|
248
|
+
This is a wrapper around a raw channel that provides a high-level interface
|
|
249
|
+
for sending commands and receiving responses.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
@overload
|
|
253
|
+
async def send_command(
|
|
254
|
+
self,
|
|
255
|
+
method: CommandType,
|
|
256
|
+
*,
|
|
257
|
+
params: ParamsType = None,
|
|
258
|
+
) -> Any:
|
|
259
|
+
"""Send a command and return a decoded response."""
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
@overload
|
|
263
|
+
async def send_command(
|
|
264
|
+
self,
|
|
265
|
+
method: CommandType,
|
|
266
|
+
*,
|
|
267
|
+
response_type: type[_T],
|
|
268
|
+
params: ParamsType = None,
|
|
269
|
+
) -> _T:
|
|
270
|
+
"""Send a command and return a parsed response RoborockBase type."""
|
|
271
|
+
...
|
|
File without changes
|