python-roborock 2.20.0__tar.gz → 2.22.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.20.0 → python_roborock-2.22.0}/PKG-INFO +1 -1
- {python_roborock-2.20.0 → python_roborock-2.22.0}/pyproject.toml +2 -2
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/cli.py +67 -4
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/cloud_api.py +16 -14
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/containers.py +16 -0
- python_roborock-2.22.0/roborock/devices/README.md +98 -0
- python_roborock-2.22.0/roborock/devices/__init__.py +6 -0
- python_roborock-2.22.0/roborock/devices/device.py +65 -0
- python_roborock-2.22.0/roborock/devices/device_manager.py +91 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/local_api.py +5 -7
- python_roborock-2.22.0/roborock/mqtt/__init__.py +10 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/mqtt/roborock_session.py +3 -4
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/protocol.py +75 -1
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/roborock_local_client_v1.py +1 -3
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +2 -4
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +1 -3
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/web_api.py +41 -41
- python_roborock-2.20.0/roborock/mqtt/__init__.py +0 -7
- {python_roborock-2.20.0 → python_roborock-2.22.0}/LICENSE +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/README.md +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/__init__.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/api.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/const.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/py.typed +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/util.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-roborock"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.22.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"
|
|
@@ -75,4 +75,4 @@ select=["E", "F", "UP", "I"]
|
|
|
75
75
|
[tool.pytest.ini_options]
|
|
76
76
|
asyncio_mode = "auto"
|
|
77
77
|
asyncio_default_fixture_loop_scope = "function"
|
|
78
|
-
timeout =
|
|
78
|
+
timeout = 30
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
from pathlib import Path
|
|
@@ -12,7 +13,8 @@ from pyshark.packet.packet import Packet # type: ignore
|
|
|
12
13
|
|
|
13
14
|
from roborock import RoborockException
|
|
14
15
|
from roborock.containers import DeviceData, HomeDataProduct, LoginData
|
|
15
|
-
from roborock.
|
|
16
|
+
from roborock.mqtt.roborock_session import create_mqtt_session
|
|
17
|
+
from roborock.protocol import MessageParser, create_mqtt_params
|
|
16
18
|
from roborock.util import run_sync
|
|
17
19
|
from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
|
|
18
20
|
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
|
|
@@ -45,7 +47,8 @@ class RoborockContext:
|
|
|
45
47
|
if self._login_data is None:
|
|
46
48
|
raise RoborockException("You must login first")
|
|
47
49
|
|
|
48
|
-
def login_data(self):
|
|
50
|
+
def login_data(self) -> LoginData:
|
|
51
|
+
"""Get the login data."""
|
|
49
52
|
self.validate()
|
|
50
53
|
return self._login_data
|
|
51
54
|
|
|
@@ -62,7 +65,11 @@ def cli(ctx, debug: int):
|
|
|
62
65
|
|
|
63
66
|
@click.command()
|
|
64
67
|
@click.option("--email", required=True)
|
|
65
|
-
@click.option(
|
|
68
|
+
@click.option(
|
|
69
|
+
"--password",
|
|
70
|
+
required=False,
|
|
71
|
+
help="Password for the Roborock account. If not provided, an email code will be requested.",
|
|
72
|
+
)
|
|
66
73
|
@click.pass_context
|
|
67
74
|
@run_sync()
|
|
68
75
|
async def login(ctx, email, password):
|
|
@@ -75,10 +82,65 @@ async def login(ctx, email, password):
|
|
|
75
82
|
except RoborockException:
|
|
76
83
|
pass
|
|
77
84
|
client = RoborockApiClient(email)
|
|
78
|
-
|
|
85
|
+
if password is not None:
|
|
86
|
+
user_data = await client.pass_login(password)
|
|
87
|
+
else:
|
|
88
|
+
print(f"Requesting code for {email}")
|
|
89
|
+
await client.request_code()
|
|
90
|
+
code = click.prompt("A code has been sent to your email, please enter the code", type=str)
|
|
91
|
+
user_data = await client.code_login(code)
|
|
92
|
+
print("Login successful")
|
|
79
93
|
context.update(LoginData(user_data=user_data, email=email))
|
|
80
94
|
|
|
81
95
|
|
|
96
|
+
@click.command()
|
|
97
|
+
@click.pass_context
|
|
98
|
+
@click.option("--duration", default=10, help="Duration to run the MQTT session in seconds")
|
|
99
|
+
@run_sync()
|
|
100
|
+
async def session(ctx, duration: int):
|
|
101
|
+
context: RoborockContext = ctx.obj
|
|
102
|
+
login_data = context.login_data()
|
|
103
|
+
|
|
104
|
+
# Discovery devices if not already available
|
|
105
|
+
if not login_data.home_data:
|
|
106
|
+
await _discover(ctx)
|
|
107
|
+
login_data = context.login_data()
|
|
108
|
+
if not login_data.home_data or not login_data.home_data.devices:
|
|
109
|
+
raise RoborockException("Unable to discover devices")
|
|
110
|
+
|
|
111
|
+
all_devices = login_data.home_data.devices + login_data.home_data.received_devices
|
|
112
|
+
click.echo(f"Discovered devices: {', '.join([device.name for device in all_devices])}")
|
|
113
|
+
|
|
114
|
+
rriot = login_data.user_data.rriot
|
|
115
|
+
params = create_mqtt_params(rriot)
|
|
116
|
+
|
|
117
|
+
mqtt_session = await create_mqtt_session(params)
|
|
118
|
+
click.echo("Starting MQTT session...")
|
|
119
|
+
if not mqtt_session.connected:
|
|
120
|
+
raise RoborockException("Failed to connect to MQTT broker")
|
|
121
|
+
|
|
122
|
+
def on_message(bytes: bytes):
|
|
123
|
+
"""Callback function to handle incoming MQTT messages."""
|
|
124
|
+
# Decode the first 20 bytes of the message for display
|
|
125
|
+
bytes = bytes[:20]
|
|
126
|
+
|
|
127
|
+
click.echo(f"Received message: {bytes}...")
|
|
128
|
+
|
|
129
|
+
unsubs = []
|
|
130
|
+
for device in all_devices:
|
|
131
|
+
device_topic = f"rr/m/o/{rriot.u}/{params.username}/{device.duid}"
|
|
132
|
+
unsub = await mqtt_session.subscribe(device_topic, on_message)
|
|
133
|
+
unsubs.append(unsub)
|
|
134
|
+
|
|
135
|
+
click.echo("MQTT session started. Listening for messages...")
|
|
136
|
+
await asyncio.sleep(duration)
|
|
137
|
+
|
|
138
|
+
click.echo("Stopping MQTT session...")
|
|
139
|
+
for unsub in unsubs:
|
|
140
|
+
unsub()
|
|
141
|
+
await mqtt_session.close()
|
|
142
|
+
|
|
143
|
+
|
|
82
144
|
async def _discover(ctx):
|
|
83
145
|
context: RoborockContext = ctx.obj
|
|
84
146
|
login_data = context.login_data()
|
|
@@ -253,6 +315,7 @@ cli.add_command(execute_scene)
|
|
|
253
315
|
cli.add_command(status)
|
|
254
316
|
cli.add_command(command)
|
|
255
317
|
cli.add_command(parser)
|
|
318
|
+
cli.add_command(session)
|
|
256
319
|
|
|
257
320
|
|
|
258
321
|
def main():
|
|
@@ -6,14 +6,19 @@ import threading
|
|
|
6
6
|
from abc import ABC
|
|
7
7
|
from asyncio import Lock
|
|
8
8
|
from typing import Any
|
|
9
|
-
from urllib.parse import urlparse
|
|
10
9
|
|
|
11
10
|
import paho.mqtt.client as mqtt
|
|
12
11
|
|
|
13
12
|
from .api import KEEPALIVE, RoborockClient
|
|
14
13
|
from .containers import DeviceData, UserData
|
|
15
14
|
from .exceptions import RoborockException, VacuumError
|
|
16
|
-
from .protocol import
|
|
15
|
+
from .protocol import (
|
|
16
|
+
Decoder,
|
|
17
|
+
Encoder,
|
|
18
|
+
create_mqtt_decoder,
|
|
19
|
+
create_mqtt_encoder,
|
|
20
|
+
create_mqtt_params,
|
|
21
|
+
)
|
|
17
22
|
from .roborock_future import RoborockFuture
|
|
18
23
|
|
|
19
24
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -53,27 +58,24 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
53
58
|
if rriot is None:
|
|
54
59
|
raise RoborockException("Got no rriot data from user_data")
|
|
55
60
|
RoborockClient.__init__(self, device_info)
|
|
61
|
+
mqtt_params = create_mqtt_params(rriot)
|
|
56
62
|
self._mqtt_user = rriot.u
|
|
57
|
-
self._hashed_user =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
raise RoborockException("Url parsing returned an invalid hostname")
|
|
61
|
-
self._mqtt_host = str(url.hostname)
|
|
62
|
-
self._mqtt_port = url.port
|
|
63
|
-
self._mqtt_ssl = url.scheme == "ssl"
|
|
63
|
+
self._hashed_user = mqtt_params.username
|
|
64
|
+
self._mqtt_host = mqtt_params.host
|
|
65
|
+
self._mqtt_port = mqtt_params.port
|
|
64
66
|
|
|
65
67
|
self._mqtt_client = _Mqtt()
|
|
66
68
|
self._mqtt_client.on_connect = self._mqtt_on_connect
|
|
67
69
|
self._mqtt_client.on_message = self._mqtt_on_message
|
|
68
70
|
self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
|
|
69
|
-
if
|
|
71
|
+
if mqtt_params.tls:
|
|
70
72
|
self._mqtt_client.tls_set()
|
|
71
73
|
|
|
72
|
-
self.
|
|
73
|
-
self._hashed_password = md5hex(self._mqtt_password + ":" + rriot.k)[16:]
|
|
74
|
-
self._mqtt_client.username_pw_set(self._hashed_user, self._hashed_password)
|
|
74
|
+
self._mqtt_client.username_pw_set(mqtt_params.username, mqtt_params.password)
|
|
75
75
|
self._waiting_queue: dict[int, RoborockFuture] = {}
|
|
76
76
|
self._mutex = Lock()
|
|
77
|
+
self._decoder: Decoder = create_mqtt_decoder(device_info.device.local_key)
|
|
78
|
+
self._encoder: Encoder = create_mqtt_encoder(device_info.device.local_key)
|
|
77
79
|
|
|
78
80
|
def _mqtt_on_connect(self, *args, **kwargs):
|
|
79
81
|
_, __, ___, rc, ____ = args
|
|
@@ -102,7 +104,7 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
102
104
|
def _mqtt_on_message(self, *args, **kwargs):
|
|
103
105
|
client, __, msg = args
|
|
104
106
|
try:
|
|
105
|
-
messages
|
|
107
|
+
messages = self._decoder(msg.payload)
|
|
106
108
|
super().on_message_received(messages)
|
|
107
109
|
except Exception as ex:
|
|
108
110
|
self._logger.exception(ex)
|
|
@@ -7,6 +7,7 @@ import re
|
|
|
7
7
|
from dataclasses import asdict, dataclass, field
|
|
8
8
|
from datetime import timezone
|
|
9
9
|
from enum import Enum
|
|
10
|
+
from functools import cached_property
|
|
10
11
|
from typing import Any, NamedTuple, get_args, get_origin
|
|
11
12
|
|
|
12
13
|
from .code_mappings import (
|
|
@@ -469,6 +470,21 @@ class HomeData(RoborockBase):
|
|
|
469
470
|
devices += self.received_devices
|
|
470
471
|
return devices
|
|
471
472
|
|
|
473
|
+
@cached_property
|
|
474
|
+
def product_map(self) -> dict[str, HomeDataProduct]:
|
|
475
|
+
"""Returns a dictionary of product IDs to HomeDataProduct objects."""
|
|
476
|
+
return {product.id: product for product in self.products}
|
|
477
|
+
|
|
478
|
+
@cached_property
|
|
479
|
+
def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
|
|
480
|
+
"""Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects."""
|
|
481
|
+
product_map = self.product_map
|
|
482
|
+
return {
|
|
483
|
+
device.duid: (device, product)
|
|
484
|
+
for device in self.get_all_devices()
|
|
485
|
+
if (product := product_map.get(device.product_id)) is not None
|
|
486
|
+
}
|
|
487
|
+
|
|
472
488
|
|
|
473
489
|
@dataclass
|
|
474
490
|
class LoginData(RoborockBase):
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Roborock Device Discovery
|
|
2
|
+
|
|
3
|
+
This page documents the full lifecycle of device discovery across Cloud and Network.
|
|
4
|
+
|
|
5
|
+
## Init account setup
|
|
6
|
+
|
|
7
|
+
### Login
|
|
8
|
+
|
|
9
|
+
- Login can happen with either email and password or email and sending a code. We
|
|
10
|
+
currently prefer email with sending a code -- however the roborock no longer
|
|
11
|
+
supports this method of login. In the future we may want to migrate to password
|
|
12
|
+
if this login method is no longer supported.
|
|
13
|
+
- The Login API provides a `userData` object with information on connecting to the cloud APIs
|
|
14
|
+
- This `rriot` data contains per-session information, unique each time you login.
|
|
15
|
+
- This contains information used to connect to MQTT
|
|
16
|
+
- You get an `-eu` suffix in the API URLs if you are in the eu and `-us` if you are in the us
|
|
17
|
+
|
|
18
|
+
## Home Data
|
|
19
|
+
|
|
20
|
+
The `HomeData` includes information about the various devices in the home. We use `v3`
|
|
21
|
+
and it is notable that if devices don't show up in the `home_data` response it is likely
|
|
22
|
+
that a newer version of the API should be used.
|
|
23
|
+
|
|
24
|
+
- `products`: This is a list of all of the products you have on your account. These objects are always the same (i.e. a s7 maxv is always the exact same.)
|
|
25
|
+
- It only shows the products for devices available on your account
|
|
26
|
+
- `devices` and `received_devices`:
|
|
27
|
+
- These both share the same objects, but one is for devices that have been shared with you and one is those that are on your account.
|
|
28
|
+
- The big things here are (MOST are static):
|
|
29
|
+
- `duid`: A unique identifier for your device (this is always the same i think)
|
|
30
|
+
- `name`: The name of the device in your app
|
|
31
|
+
- `local_key`: The local key that is needed for encoding and decoding messages for the device. This stays the same unless someone sets their vacuum back up.
|
|
32
|
+
- `pv`: the protocol version (i.e. 1.0 or A1 or B1)
|
|
33
|
+
- `product_id`: The id of the product from the above products list.
|
|
34
|
+
- `device_status`: An initial status for some of the data we care about, though this changes on each update.
|
|
35
|
+
- `rooms`: The rooms in the home.
|
|
36
|
+
- This changes if the user adds a new room or changes its name.
|
|
37
|
+
- We have to combine this with the room numbers from `GET_ROOM_MAPPING` on the device
|
|
38
|
+
- There is another REST request `get_rooms` that will do the same thing.
|
|
39
|
+
- Note: If we cache home_data, we likely need to use `get_rooms` to get rooms fresh
|
|
40
|
+
|
|
41
|
+
## Device Connections
|
|
42
|
+
|
|
43
|
+
### MQTT connection
|
|
44
|
+
|
|
45
|
+
- Initial device information must be obtained from MQTT
|
|
46
|
+
- We typically set up the MQTT device connection before the local device connection.
|
|
47
|
+
- The `NetworkingInfo` needs to be fetched to get additional information about connecting to the device:
|
|
48
|
+
- e.g. Local IP Address
|
|
49
|
+
- This networking info can be cached to reduce network calls
|
|
50
|
+
- MQTT also is the only way to get the device Map
|
|
51
|
+
- Incoming and outgoing messages are decoded/encoded using the device `local_key`
|
|
52
|
+
- Otherwise all commands may be performed locally.
|
|
53
|
+
|
|
54
|
+
## Local connection
|
|
55
|
+
|
|
56
|
+
- We can use the `ip` from the `NetworkingInfo` to find the device
|
|
57
|
+
- The local connection is preferred to for improved latency and reducing load on the cloud servers to avoid rate limiting.
|
|
58
|
+
- Connections are made using a normal TCP socket on port `58867`
|
|
59
|
+
- Incoming and outgoing messages are decoded/encoded using the device `local_key`
|
|
60
|
+
- Messages received on the stream may be partially received so we keep a running as messages are partially decoded
|
|
61
|
+
|
|
62
|
+
## Design
|
|
63
|
+
|
|
64
|
+
### Current API Issues
|
|
65
|
+
|
|
66
|
+
- Complex Inheritance Hierarchy: Multiple inheritance with classes like RoborockMqttClientV1 inheriting from both RoborockMqttClient and RoborockClientV1
|
|
67
|
+
|
|
68
|
+
- Callback-Heavy Design: Heavy reliance on callbacks and listeners in RoborockClientV1.on_message_received and the ListenerModel system
|
|
69
|
+
|
|
70
|
+
- Version Fragmentation: Separate v1 and A01 APIs with different patterns and abstractions
|
|
71
|
+
|
|
72
|
+
- Mixed Concerns: Classes handle both communication protocols (MQTT/local) and device-specific logic
|
|
73
|
+
|
|
74
|
+
- Complex Caching: The AttributeCache system with RepeatableTask adds complexity
|
|
75
|
+
|
|
76
|
+
- Manual Connection Management: Users need to manually set up both MQTT and local clients as shown in the README example
|
|
77
|
+
|
|
78
|
+
## Design Changes
|
|
79
|
+
|
|
80
|
+
- Prefer a single unfieid client that handles both MQTT and local connections internally.
|
|
81
|
+
|
|
82
|
+
- Home and device discovery (fetching home data and device setup) will be behind a single API.
|
|
83
|
+
|
|
84
|
+
- Asyncio First: Everything should be asyncio as much as possible, with fewer callbacks.
|
|
85
|
+
|
|
86
|
+
- The clients should be working in terms of devices. We need to detect capabilities for each device and not expose details about API versions.
|
|
87
|
+
|
|
88
|
+
- Reliability issues: The current Home Assistant integration has issues with reliability and needs to be simplified. It may be that there are bugs with the exception handling and it's too heavy the cloud APIs and could benefit from more seamless caching.
|
|
89
|
+
|
|
90
|
+
## Implementation Details
|
|
91
|
+
|
|
92
|
+
- We don't really need to worry about backwards compatibility for the new set of APIs.
|
|
93
|
+
|
|
94
|
+
- We'll have a `RoborockManager` responsible for managing the connections and getting devices.
|
|
95
|
+
|
|
96
|
+
- Caching can be persisted to disk. The caller can implement the cache storage themselves, but we need to give them an API to do so.
|
|
97
|
+
|
|
98
|
+
- Users don't really choose between cloud vs local. However, we will want to allow the caller to know if its using the locale connection so we can show a warnings.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Module for Roborock devices.
|
|
2
|
+
|
|
3
|
+
This interface is experimental and subject to breaking changes without notice
|
|
4
|
+
until the API is stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
import logging
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
|
|
11
|
+
from roborock.containers import HomeDataDevice, HomeDataProduct, UserData
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RoborockDevice",
|
|
17
|
+
"DeviceVersion",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeviceVersion(enum.StrEnum):
|
|
22
|
+
"""Enum for device versions."""
|
|
23
|
+
|
|
24
|
+
V1 = "1.0"
|
|
25
|
+
A01 = "A01"
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RoborockDevice:
|
|
30
|
+
"""Unified Roborock device class with automatic connection setup."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, user_data: UserData, device_info: HomeDataDevice, product_info: HomeDataProduct) -> None:
|
|
33
|
+
"""Initialize the RoborockDevice with device info, user data, and capabilities."""
|
|
34
|
+
self._user_data = user_data
|
|
35
|
+
self._device_info = device_info
|
|
36
|
+
self._product_info = product_info
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def duid(self) -> str:
|
|
40
|
+
"""Return the device unique identifier (DUID)."""
|
|
41
|
+
return self._device_info.duid
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
"""Return the device name."""
|
|
46
|
+
return self._device_info.name
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def device_version(self) -> str:
|
|
50
|
+
"""Return the device version.
|
|
51
|
+
|
|
52
|
+
At the moment this is a simple check against the product version (pv) of the device
|
|
53
|
+
and used as a placeholder for upcoming functionality for devices that will behave
|
|
54
|
+
differently based on the version and capabilities.
|
|
55
|
+
"""
|
|
56
|
+
if self._device_info.pv == DeviceVersion.V1.value:
|
|
57
|
+
return DeviceVersion.V1
|
|
58
|
+
elif self._device_info.pv == DeviceVersion.A01.value:
|
|
59
|
+
return DeviceVersion.A01
|
|
60
|
+
_LOGGER.warning(
|
|
61
|
+
"Unknown device version %s for device %s, using default UNKNOWN",
|
|
62
|
+
self._device_info.pv,
|
|
63
|
+
self._device_info.name,
|
|
64
|
+
)
|
|
65
|
+
return DeviceVersion.UNKNOWN
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Module for discovering Roborock devices."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from roborock.containers import (
|
|
7
|
+
HomeData,
|
|
8
|
+
HomeDataDevice,
|
|
9
|
+
HomeDataProduct,
|
|
10
|
+
UserData,
|
|
11
|
+
)
|
|
12
|
+
from roborock.devices.device import RoborockDevice
|
|
13
|
+
from roborock.web_api import RoborockApiClient
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"create_device_manager",
|
|
19
|
+
"create_home_data_api",
|
|
20
|
+
"DeviceManager",
|
|
21
|
+
"HomeDataApi",
|
|
22
|
+
"DeviceCreator",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
HomeDataApi = Callable[[], Awaitable[HomeData]]
|
|
27
|
+
DeviceCreator = Callable[[HomeDataDevice, HomeDataProduct], RoborockDevice]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DeviceManager:
|
|
31
|
+
"""Central manager for Roborock device discovery and connections."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
home_data_api: HomeDataApi,
|
|
36
|
+
device_creator: DeviceCreator,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the DeviceManager with user data and optional cache storage."""
|
|
39
|
+
self._home_data_api = home_data_api
|
|
40
|
+
self._device_creator = device_creator
|
|
41
|
+
self._devices: dict[str, RoborockDevice] = {}
|
|
42
|
+
|
|
43
|
+
async def discover_devices(self) -> list[RoborockDevice]:
|
|
44
|
+
"""Discover all devices for the logged-in user."""
|
|
45
|
+
home_data = await self._home_data_api()
|
|
46
|
+
device_products = home_data.device_products
|
|
47
|
+
_LOGGER.debug("Discovered %d devices %s", len(device_products), home_data)
|
|
48
|
+
|
|
49
|
+
self._devices = {
|
|
50
|
+
duid: self._device_creator(device, product) for duid, (device, product) in device_products.items()
|
|
51
|
+
}
|
|
52
|
+
return list(self._devices.values())
|
|
53
|
+
|
|
54
|
+
async def get_device(self, duid: str) -> RoborockDevice | None:
|
|
55
|
+
"""Get a specific device by DUID."""
|
|
56
|
+
return self._devices.get(duid)
|
|
57
|
+
|
|
58
|
+
async def get_devices(self) -> list[RoborockDevice]:
|
|
59
|
+
"""Get all discovered devices."""
|
|
60
|
+
return list(self._devices.values())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_home_data_api(email: str, user_data: UserData) -> HomeDataApi:
|
|
64
|
+
"""Create a home data API wrapper.
|
|
65
|
+
|
|
66
|
+
This function creates a wrapper around the Roborock API client to fetch
|
|
67
|
+
home data for the user.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
client = RoborockApiClient(email, user_data)
|
|
71
|
+
|
|
72
|
+
async def home_data_api() -> HomeData:
|
|
73
|
+
return await client.get_home_data(user_data)
|
|
74
|
+
|
|
75
|
+
return home_data_api
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi) -> DeviceManager:
|
|
79
|
+
"""Convenience function to create and initialize a DeviceManager.
|
|
80
|
+
|
|
81
|
+
The Home Data is fetched using the provided home_data_api callable which
|
|
82
|
+
is exposed this way to allow for swapping out other implementations to
|
|
83
|
+
include caching or other optimizations.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
|
|
87
|
+
return RoborockDevice(user_data, device, product)
|
|
88
|
+
|
|
89
|
+
manager = DeviceManager(home_data_api, device_creator)
|
|
90
|
+
await manager.discover_devices()
|
|
91
|
+
return manager
|
|
@@ -12,7 +12,7 @@ import async_timeout
|
|
|
12
12
|
from . import DeviceData
|
|
13
13
|
from .api import RoborockClient
|
|
14
14
|
from .exceptions import RoborockConnectionException, RoborockException
|
|
15
|
-
from .protocol import
|
|
15
|
+
from .protocol import Decoder, Encoder, create_local_decoder, create_local_encoder
|
|
16
16
|
from .roborock_message import RoborockMessage, RoborockMessageProtocol
|
|
17
17
|
|
|
18
18
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -44,20 +44,18 @@ class RoborockLocalClient(RoborockClient, ABC):
|
|
|
44
44
|
self.host = device_data.host
|
|
45
45
|
self._batch_structs: list[RoborockMessage] = []
|
|
46
46
|
self._executing = False
|
|
47
|
-
self.remaining = b""
|
|
48
47
|
self.transport: Transport | None = None
|
|
49
48
|
self._mutex = Lock()
|
|
50
49
|
self.keep_alive_task: TimerHandle | None = None
|
|
51
50
|
RoborockClient.__init__(self, device_data)
|
|
52
51
|
self._local_protocol = _LocalProtocol(self._data_received, self._connection_lost)
|
|
52
|
+
self._encoder: Encoder = create_local_encoder(device_data.device.local_key)
|
|
53
|
+
self._decoder: Decoder = create_local_decoder(device_data.device.local_key)
|
|
53
54
|
|
|
54
55
|
def _data_received(self, message):
|
|
55
56
|
"""Called when data is received from the transport."""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
self.remaining = b""
|
|
59
|
-
parser_msg, self.remaining = MessageParser.parse(message, local_key=self.device_info.device.local_key)
|
|
60
|
-
self.on_message_received(parser_msg)
|
|
57
|
+
parsed_msg = self._decoder(message)
|
|
58
|
+
self.on_message_received(parsed_msg)
|
|
61
59
|
|
|
62
60
|
def _connection_lost(self, exc: Exception | None):
|
|
63
61
|
"""Called when the transport connection is lost."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""This module contains the low level MQTT client for the Roborock vacuum cleaner.
|
|
2
|
+
|
|
3
|
+
This is not meant to be used directly, but rather as a base for the higher level
|
|
4
|
+
modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# This module is part of the Roborock Python library, which provides a way to
|
|
8
|
+
# interact with Roborock devices using MQTT. It is not intended to be used directly,
|
|
9
|
+
# but rather as a base for higher level modules.
|
|
10
|
+
__all__: list[str] = []
|
|
@@ -116,9 +116,9 @@ class RoborockMqttSession(MqttSession):
|
|
|
116
116
|
_LOGGER.info("MQTT error: %s", err)
|
|
117
117
|
except asyncio.CancelledError as err:
|
|
118
118
|
if start_future:
|
|
119
|
-
_LOGGER.debug("MQTT loop was cancelled")
|
|
119
|
+
_LOGGER.debug("MQTT loop was cancelled while starting")
|
|
120
120
|
start_future.set_exception(err)
|
|
121
|
-
_LOGGER.debug("MQTT loop was cancelled
|
|
121
|
+
_LOGGER.debug("MQTT loop was cancelled")
|
|
122
122
|
return
|
|
123
123
|
# Catch exceptions to avoid crashing the loop
|
|
124
124
|
# and to allow the loop to retry.
|
|
@@ -171,8 +171,7 @@ class RoborockMqttSession(MqttSession):
|
|
|
171
171
|
self._client = None
|
|
172
172
|
|
|
173
173
|
async def _process_message_loop(self, client: aiomqtt.Client) -> None:
|
|
174
|
-
_LOGGER.debug("
|
|
175
|
-
_LOGGER.debug("Processing MQTT messages: %s", client.messages)
|
|
174
|
+
_LOGGER.debug("Processing MQTT messages")
|
|
176
175
|
async for message in client.messages:
|
|
177
176
|
_LOGGER.debug("Received message: %s", message)
|
|
178
177
|
for listener in self._listeners.get(message.topic.value, []):
|
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import logging
|
|
9
9
|
from asyncio import BaseTransport, Lock
|
|
10
10
|
from collections.abc import Callable
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
from construct import ( # type: ignore
|
|
13
14
|
Bytes,
|
|
@@ -30,7 +31,9 @@ from construct import ( # type: ignore
|
|
|
30
31
|
from Crypto.Cipher import AES
|
|
31
32
|
from Crypto.Util.Padding import pad, unpad
|
|
32
33
|
|
|
33
|
-
from roborock import BroadcastMessage,
|
|
34
|
+
from roborock.containers import BroadcastMessage, RRiot
|
|
35
|
+
from roborock.exceptions import RoborockException
|
|
36
|
+
from roborock.mqtt.session import MqttParams
|
|
34
37
|
from roborock.roborock_message import RoborockMessage
|
|
35
38
|
|
|
36
39
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -359,3 +362,74 @@ class _Parser:
|
|
|
359
362
|
|
|
360
363
|
MessageParser: _Parser = _Parser(_Messages, True)
|
|
361
364
|
BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def create_mqtt_params(rriot: RRiot) -> MqttParams:
|
|
368
|
+
"""Return the MQTT parameters for this user."""
|
|
369
|
+
url = urlparse(rriot.r.m)
|
|
370
|
+
if not isinstance(url.hostname, str):
|
|
371
|
+
raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid hostname")
|
|
372
|
+
if not url.port:
|
|
373
|
+
raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid port")
|
|
374
|
+
hashed_user = md5hex(rriot.u + ":" + rriot.k)[2:10]
|
|
375
|
+
hashed_password = md5hex(rriot.s + ":" + rriot.k)[16:]
|
|
376
|
+
return MqttParams(
|
|
377
|
+
host=str(url.hostname),
|
|
378
|
+
port=url.port,
|
|
379
|
+
tls=(url.scheme == "ssl"),
|
|
380
|
+
username=hashed_user,
|
|
381
|
+
password=hashed_password,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
Decoder = Callable[[bytes], list[RoborockMessage]]
|
|
386
|
+
Encoder = Callable[[RoborockMessage], bytes]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def create_mqtt_decoder(local_key: str) -> Decoder:
|
|
390
|
+
"""Create a decoder for MQTT messages."""
|
|
391
|
+
|
|
392
|
+
def decode(data: bytes) -> list[RoborockMessage]:
|
|
393
|
+
"""Parse the given data into Roborock messages."""
|
|
394
|
+
messages, _ = MessageParser.parse(data, local_key)
|
|
395
|
+
return messages
|
|
396
|
+
|
|
397
|
+
return decode
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def create_mqtt_encoder(local_key: str) -> Encoder:
|
|
401
|
+
"""Create an encoder for MQTT messages."""
|
|
402
|
+
|
|
403
|
+
def encode(messages: RoborockMessage) -> bytes:
|
|
404
|
+
"""Build the given Roborock messages into a byte string."""
|
|
405
|
+
return MessageParser.build(messages, local_key, prefixed=False)
|
|
406
|
+
|
|
407
|
+
return encode
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def create_local_decoder(local_key: str) -> Decoder:
|
|
411
|
+
"""Create a decoder for local API messages."""
|
|
412
|
+
|
|
413
|
+
# This buffer is used to accumulate bytes until a complete message can be parsed.
|
|
414
|
+
# It is defined outside the decode function to maintain state across calls.
|
|
415
|
+
buffer: bytes = b""
|
|
416
|
+
|
|
417
|
+
def decode(bytes: bytes) -> list[RoborockMessage]:
|
|
418
|
+
"""Parse the given data into Roborock messages."""
|
|
419
|
+
nonlocal buffer
|
|
420
|
+
buffer += bytes
|
|
421
|
+
parsed_messages, remaining = MessageParser.parse(buffer, local_key=local_key)
|
|
422
|
+
buffer = remaining
|
|
423
|
+
return parsed_messages
|
|
424
|
+
|
|
425
|
+
return decode
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def create_local_encoder(local_key: str) -> Encoder:
|
|
429
|
+
"""Create an encoder for local API messages."""
|
|
430
|
+
|
|
431
|
+
def encode(message: RoborockMessage) -> bytes:
|
|
432
|
+
"""Called when data is sent to the transport."""
|
|
433
|
+
return MessageParser.build(message, local_key=local_key)
|
|
434
|
+
|
|
435
|
+
return encode
|
|
@@ -4,7 +4,6 @@ from roborock.local_api import RoborockLocalClient
|
|
|
4
4
|
|
|
5
5
|
from .. import CommandVacuumError, DeviceData, RoborockCommand, RoborockException
|
|
6
6
|
from ..exceptions import VacuumError
|
|
7
|
-
from ..protocol import MessageParser
|
|
8
7
|
from ..roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol
|
|
9
8
|
from ..util import RoborockLoggerAdapter
|
|
10
9
|
from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1
|
|
@@ -57,8 +56,7 @@ class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1):
|
|
|
57
56
|
response_protocol = RoborockMessageProtocol.GENERAL_REQUEST
|
|
58
57
|
if request_id is None:
|
|
59
58
|
raise RoborockException(f"Failed build message {roborock_message}")
|
|
60
|
-
|
|
61
|
-
msg = MessageParser.build(roborock_message, local_key=local_key)
|
|
59
|
+
msg = self._encoder(roborock_message)
|
|
62
60
|
if method:
|
|
63
61
|
self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
64
62
|
# Send the command to the Roborock device
|
{python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
@@ -10,7 +10,7 @@ from roborock.cloud_api import RoborockMqttClient
|
|
|
10
10
|
|
|
11
11
|
from ..containers import DeviceData, UserData
|
|
12
12
|
from ..exceptions import CommandVacuumError, RoborockException, VacuumError
|
|
13
|
-
from ..protocol import
|
|
13
|
+
from ..protocol import Utils
|
|
14
14
|
from ..roborock_message import (
|
|
15
15
|
RoborockMessage,
|
|
16
16
|
RoborockMessageProtocol,
|
|
@@ -47,9 +47,7 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
|
|
|
47
47
|
response_protocol = (
|
|
48
48
|
RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE
|
|
49
49
|
)
|
|
50
|
-
|
|
51
|
-
local_key = self.device_info.device.local_key
|
|
52
|
-
msg = MessageParser.build(roborock_message, local_key, False)
|
|
50
|
+
msg = self._encoder(roborock_message)
|
|
53
51
|
self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
54
52
|
async_response = self._async_response(request_id, response_protocol)
|
|
55
53
|
self._send_msg_raw(msg)
|
|
@@ -9,7 +9,6 @@ from Crypto.Util.Padding import pad, unpad
|
|
|
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.protocol import MessageParser
|
|
13
12
|
from roborock.roborock_message import (
|
|
14
13
|
RoborockDyadDataProtocol,
|
|
15
14
|
RoborockMessage,
|
|
@@ -43,8 +42,7 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
|
|
|
43
42
|
await self.validate_connection()
|
|
44
43
|
response_protocol = RoborockMessageProtocol.RPC_RESPONSE
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
m = MessageParser.build(roborock_message, local_key, prefixed=False)
|
|
45
|
+
m = self._encoder(roborock_message)
|
|
48
46
|
# self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
49
47
|
payload = json.loads(unpad(roborock_message.payload, AES.block_size))
|
|
50
48
|
futures = []
|
|
@@ -90,39 +90,6 @@ class RoborockApiClient:
|
|
|
90
90
|
md5.update(self._device_identifier.encode())
|
|
91
91
|
return base64.b64encode(md5.digest()).decode()
|
|
92
92
|
|
|
93
|
-
def _process_extra_hawk_values(self, values: dict | None) -> str:
|
|
94
|
-
if values is None:
|
|
95
|
-
return ""
|
|
96
|
-
else:
|
|
97
|
-
sorted_keys = sorted(values.keys())
|
|
98
|
-
result = []
|
|
99
|
-
for key in sorted_keys:
|
|
100
|
-
value = values.get(key)
|
|
101
|
-
result.append(f"{key}={value}")
|
|
102
|
-
return hashlib.md5("&".join(result).encode()).hexdigest()
|
|
103
|
-
|
|
104
|
-
def _get_hawk_authentication(
|
|
105
|
-
self, rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None
|
|
106
|
-
) -> str:
|
|
107
|
-
timestamp = math.floor(time.time())
|
|
108
|
-
nonce = secrets.token_urlsafe(6)
|
|
109
|
-
formdata_str = self._process_extra_hawk_values(formdata)
|
|
110
|
-
params_str = self._process_extra_hawk_values(params)
|
|
111
|
-
|
|
112
|
-
prestr = ":".join(
|
|
113
|
-
[
|
|
114
|
-
rriot.u,
|
|
115
|
-
rriot.s,
|
|
116
|
-
nonce,
|
|
117
|
-
str(timestamp),
|
|
118
|
-
hashlib.md5(url.encode()).hexdigest(),
|
|
119
|
-
params_str,
|
|
120
|
-
formdata_str,
|
|
121
|
-
]
|
|
122
|
-
)
|
|
123
|
-
mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()
|
|
124
|
-
return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"'
|
|
125
|
-
|
|
126
93
|
async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
|
|
127
94
|
"""This gets a few critical parameters for adding a device to your account."""
|
|
128
95
|
if (
|
|
@@ -144,7 +111,7 @@ class RoborockApiClient:
|
|
|
144
111
|
"post",
|
|
145
112
|
"/nc/prepare",
|
|
146
113
|
headers={
|
|
147
|
-
"Authorization":
|
|
114
|
+
"Authorization": _get_hawk_authentication(
|
|
148
115
|
user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
|
|
149
116
|
),
|
|
150
117
|
},
|
|
@@ -177,7 +144,7 @@ class RoborockApiClient:
|
|
|
177
144
|
"GET",
|
|
178
145
|
"/user/devices/newadd",
|
|
179
146
|
headers={
|
|
180
|
-
"Authorization":
|
|
147
|
+
"Authorization": _get_hawk_authentication(
|
|
181
148
|
user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
|
|
182
149
|
),
|
|
183
150
|
},
|
|
@@ -337,7 +304,7 @@ class RoborockApiClient:
|
|
|
337
304
|
rriot.r.a,
|
|
338
305
|
self.session,
|
|
339
306
|
{
|
|
340
|
-
"Authorization":
|
|
307
|
+
"Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"),
|
|
341
308
|
},
|
|
342
309
|
)
|
|
343
310
|
home_response = await home_request.request("get", "/user/homes/" + str(home_id))
|
|
@@ -366,7 +333,7 @@ class RoborockApiClient:
|
|
|
366
333
|
rriot.r.a,
|
|
367
334
|
self.session,
|
|
368
335
|
{
|
|
369
|
-
"Authorization":
|
|
336
|
+
"Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
|
|
370
337
|
},
|
|
371
338
|
)
|
|
372
339
|
home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id))
|
|
@@ -393,7 +360,7 @@ class RoborockApiClient:
|
|
|
393
360
|
rriot.r.a,
|
|
394
361
|
self.session,
|
|
395
362
|
{
|
|
396
|
-
"Authorization":
|
|
363
|
+
"Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)),
|
|
397
364
|
},
|
|
398
365
|
)
|
|
399
366
|
home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id))
|
|
@@ -416,7 +383,7 @@ class RoborockApiClient:
|
|
|
416
383
|
rriot.r.a,
|
|
417
384
|
self.session,
|
|
418
385
|
{
|
|
419
|
-
"Authorization":
|
|
386
|
+
"Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
|
|
420
387
|
},
|
|
421
388
|
)
|
|
422
389
|
room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id))
|
|
@@ -441,7 +408,7 @@ class RoborockApiClient:
|
|
|
441
408
|
rriot.r.a,
|
|
442
409
|
self.session,
|
|
443
410
|
{
|
|
444
|
-
"Authorization":
|
|
411
|
+
"Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"),
|
|
445
412
|
},
|
|
446
413
|
)
|
|
447
414
|
scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}")
|
|
@@ -463,7 +430,7 @@ class RoborockApiClient:
|
|
|
463
430
|
rriot.r.a,
|
|
464
431
|
self.session,
|
|
465
432
|
{
|
|
466
|
-
"Authorization":
|
|
433
|
+
"Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"),
|
|
467
434
|
},
|
|
468
435
|
)
|
|
469
436
|
execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute")
|
|
@@ -546,3 +513,36 @@ class PreparedRequest:
|
|
|
546
513
|
finally:
|
|
547
514
|
if close_session:
|
|
548
515
|
await session.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _process_extra_hawk_values(values: dict | None) -> str:
|
|
519
|
+
if values is None:
|
|
520
|
+
return ""
|
|
521
|
+
else:
|
|
522
|
+
sorted_keys = sorted(values.keys())
|
|
523
|
+
result = []
|
|
524
|
+
for key in sorted_keys:
|
|
525
|
+
value = values.get(key)
|
|
526
|
+
result.append(f"{key}={value}")
|
|
527
|
+
return hashlib.md5("&".join(result).encode()).hexdigest()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None) -> str:
|
|
531
|
+
timestamp = math.floor(time.time())
|
|
532
|
+
nonce = secrets.token_urlsafe(6)
|
|
533
|
+
formdata_str = _process_extra_hawk_values(formdata)
|
|
534
|
+
params_str = _process_extra_hawk_values(params)
|
|
535
|
+
|
|
536
|
+
prestr = ":".join(
|
|
537
|
+
[
|
|
538
|
+
rriot.u,
|
|
539
|
+
rriot.s,
|
|
540
|
+
nonce,
|
|
541
|
+
str(timestamp),
|
|
542
|
+
hashlib.md5(url.encode()).hexdigest(),
|
|
543
|
+
params_str,
|
|
544
|
+
formdata_str,
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()
|
|
548
|
+
return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"'
|
|
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.20.0 → python_roborock-2.22.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.20.0 → python_roborock-2.22.0}/roborock/version_a01_apis/roborock_client_a01.py
RENAMED
|
File without changes
|