python-roborock 2.32.0__tar.gz → 2.34.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.32.0 → python_roborock-2.34.0}/PKG-INFO +1 -1
- {python_roborock-2.32.0 → python_roborock-2.34.0}/pyproject.toml +2 -2
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/cli.py +16 -9
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/__init__.py +1 -0
- python_roborock-2.34.0/roborock/devices/b01_channel.py +30 -0
- python_roborock-2.34.0/roborock/devices/cache.py +57 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/device_manager.py +25 -7
- python_roborock-2.34.0/roborock/devices/traits/b01/props.py +31 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/v1_channel.py +20 -7
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/roborock_session.py +8 -3
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocol.py +10 -1
- python_roborock-2.34.0/roborock/protocols/b01_protocol.py +55 -0
- python_roborock-2.34.0/roborock/py.typed +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_message.py +93 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_typing.py +15 -1
- {python_roborock-2.32.0 → python_roborock-2.34.0}/LICENSE +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/README.md +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/__init__.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/api.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/const.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/containers.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/device_features.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/a01_channel.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/channel.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/device.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/local_channel.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/mqtt_channel.py +0 -0
- /python_roborock-2.32.0/roborock/py.typed → /python_roborock-2.34.0/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/dyad.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/status.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/trait.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/zeo.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/v1_rpc_channel.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/local_api.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/util.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
- {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/web_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-roborock"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.34.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"
|
|
@@ -39,7 +39,7 @@ requires = ["poetry-core==1.8.0"]
|
|
|
39
39
|
build-backend = "poetry.core.masonry.api"
|
|
40
40
|
|
|
41
41
|
[tool.poetry.group.dev.dependencies]
|
|
42
|
-
pytest-asyncio = "
|
|
42
|
+
pytest-asyncio = ">=1.1.0"
|
|
43
43
|
pytest = "*"
|
|
44
44
|
pre-commit = ">=3.5,<5.0"
|
|
45
45
|
mypy = "*"
|
|
@@ -12,6 +12,7 @@ from pyshark.packet.packet import Packet # type: ignore
|
|
|
12
12
|
|
|
13
13
|
from roborock import RoborockException
|
|
14
14
|
from roborock.containers import DeviceData, HomeData, HomeDataProduct, LoginData, NetworkInfo, RoborockBase, UserData
|
|
15
|
+
from roborock.devices.cache import Cache, CacheData
|
|
15
16
|
from roborock.devices.device_manager import create_device_manager, create_home_data_api
|
|
16
17
|
from roborock.protocol import MessageParser
|
|
17
18
|
from roborock.util import run_sync
|
|
@@ -39,7 +40,7 @@ class ConnectionCache(RoborockBase):
|
|
|
39
40
|
network_info: dict[str, NetworkInfo] | None = None
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
class RoborockContext:
|
|
43
|
+
class RoborockContext(Cache):
|
|
43
44
|
roborock_file = Path("~/.roborock").expanduser()
|
|
44
45
|
_cache_data: ConnectionCache | None = None
|
|
45
46
|
|
|
@@ -68,6 +69,18 @@ class RoborockContext:
|
|
|
68
69
|
self.validate()
|
|
69
70
|
return self._cache_data
|
|
70
71
|
|
|
72
|
+
async def get(self) -> CacheData:
|
|
73
|
+
"""Get cached value."""
|
|
74
|
+
connection_cache = self.cache_data()
|
|
75
|
+
return CacheData(home_data=connection_cache.home_data, network_info=connection_cache.network_info or {})
|
|
76
|
+
|
|
77
|
+
async def set(self, value: CacheData) -> None:
|
|
78
|
+
"""Set value in the cache."""
|
|
79
|
+
connection_cache = self.cache_data()
|
|
80
|
+
connection_cache.home_data = value.home_data
|
|
81
|
+
connection_cache.network_info = value.network_info
|
|
82
|
+
self.update(connection_cache)
|
|
83
|
+
|
|
71
84
|
|
|
72
85
|
@click.option("-d", "--debug", default=False, count=True)
|
|
73
86
|
@click.version_option(package_name="python-roborock")
|
|
@@ -119,14 +132,8 @@ async def session(ctx, duration: int):
|
|
|
119
132
|
|
|
120
133
|
home_data_api = create_home_data_api(cache_data.email, cache_data.user_data)
|
|
121
134
|
|
|
122
|
-
async def home_data_cache() -> HomeData:
|
|
123
|
-
if cache_data.home_data is None:
|
|
124
|
-
cache_data.home_data = await home_data_api()
|
|
125
|
-
context.update(cache_data)
|
|
126
|
-
return cache_data.home_data
|
|
127
|
-
|
|
128
135
|
# Create device manager
|
|
129
|
-
device_manager = await create_device_manager(cache_data.user_data,
|
|
136
|
+
device_manager = await create_device_manager(cache_data.user_data, home_data_api, context)
|
|
130
137
|
|
|
131
138
|
devices = await device_manager.get_devices()
|
|
132
139
|
click.echo(f"Discovered devices: {', '.join([device.name for device in devices])}")
|
|
@@ -156,7 +163,7 @@ async def _discover(ctx):
|
|
|
156
163
|
if not cache_data:
|
|
157
164
|
raise Exception("You need to login first")
|
|
158
165
|
client = RoborockApiClient(cache_data.email)
|
|
159
|
-
home_data = await client.
|
|
166
|
+
home_data = await client.get_home_data_v3(cache_data.user_data)
|
|
160
167
|
cache_data.home_data = home_data
|
|
161
168
|
context.update(cache_data)
|
|
162
169
|
click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Thin wrapper around the MQTT channel for Roborock B01 devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roborock.protocols.b01_protocol import (
|
|
9
|
+
CommandType,
|
|
10
|
+
ParamsType,
|
|
11
|
+
decode_rpc_response,
|
|
12
|
+
encode_mqtt_payload,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .mqtt_channel import MqttChannel
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def send_decoded_command(
|
|
21
|
+
mqtt_channel: MqttChannel,
|
|
22
|
+
dps: int,
|
|
23
|
+
command: CommandType,
|
|
24
|
+
params: ParamsType,
|
|
25
|
+
) -> dict[int, Any]:
|
|
26
|
+
"""Send a command on the MQTT channel and get a decoded response."""
|
|
27
|
+
_LOGGER.debug("Sending MQTT command: %s", params)
|
|
28
|
+
roborock_message = encode_mqtt_payload(dps, command, params)
|
|
29
|
+
response = await mqtt_channel.send_message(roborock_message)
|
|
30
|
+
return decode_rpc_response(response) # type: ignore[return-value]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""This module provides caching functionality for the Roborock device management system.
|
|
2
|
+
|
|
3
|
+
This module defines a cache interface that you may use to cache device
|
|
4
|
+
information to avoid unnecessary API calls. Callers may implement
|
|
5
|
+
this interface to provide their own caching mechanism.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from roborock.containers import HomeData, NetworkInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CacheData:
|
|
16
|
+
"""Data structure for caching device information."""
|
|
17
|
+
|
|
18
|
+
home_data: HomeData | None = None
|
|
19
|
+
"""Home data containing device and product information."""
|
|
20
|
+
|
|
21
|
+
network_info: dict[str, NetworkInfo] = field(default_factory=dict)
|
|
22
|
+
"""Network information indexed by device DUID."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cache(Protocol):
|
|
26
|
+
"""Protocol for a cache that can store and retrieve values."""
|
|
27
|
+
|
|
28
|
+
async def get(self) -> CacheData:
|
|
29
|
+
"""Get cached value."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def set(self, value: CacheData) -> None:
|
|
33
|
+
"""Set value in the cache."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InMemoryCache(Cache):
|
|
38
|
+
"""In-memory cache implementation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self._data = CacheData()
|
|
42
|
+
|
|
43
|
+
async def get(self) -> CacheData:
|
|
44
|
+
return self._data
|
|
45
|
+
|
|
46
|
+
async def set(self, value: CacheData) -> None:
|
|
47
|
+
self._data = value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NoCache(Cache):
|
|
51
|
+
"""No-op cache implementation."""
|
|
52
|
+
|
|
53
|
+
async def get(self) -> CacheData:
|
|
54
|
+
return CacheData()
|
|
55
|
+
|
|
56
|
+
async def set(self, value: CacheData) -> None:
|
|
57
|
+
pass
|
|
@@ -18,8 +18,10 @@ from roborock.mqtt.session import MqttSession
|
|
|
18
18
|
from roborock.protocol import create_mqtt_params
|
|
19
19
|
from roborock.web_api import RoborockApiClient
|
|
20
20
|
|
|
21
|
+
from .cache import Cache, NoCache
|
|
21
22
|
from .channel import Channel
|
|
22
23
|
from .mqtt_channel import create_mqtt_channel
|
|
24
|
+
from .traits.b01.props import B01PropsApi
|
|
23
25
|
from .traits.dyad import DyadApi
|
|
24
26
|
from .traits.status import StatusTrait
|
|
25
27
|
from .traits.trait import Trait
|
|
@@ -32,8 +34,6 @@ __all__ = [
|
|
|
32
34
|
"create_device_manager",
|
|
33
35
|
"create_home_data_api",
|
|
34
36
|
"DeviceManager",
|
|
35
|
-
"HomeDataApi",
|
|
36
|
-
"DeviceCreator",
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
|
|
@@ -46,6 +46,7 @@ class DeviceVersion(enum.StrEnum):
|
|
|
46
46
|
|
|
47
47
|
V1 = "1.0"
|
|
48
48
|
A01 = "A01"
|
|
49
|
+
B01 = "B01"
|
|
49
50
|
UNKNOWN = "unknown"
|
|
50
51
|
|
|
51
52
|
|
|
@@ -57,19 +58,27 @@ class DeviceManager:
|
|
|
57
58
|
home_data_api: HomeDataApi,
|
|
58
59
|
device_creator: DeviceCreator,
|
|
59
60
|
mqtt_session: MqttSession,
|
|
61
|
+
cache: Cache,
|
|
60
62
|
) -> None:
|
|
61
63
|
"""Initialize the DeviceManager with user data and optional cache storage.
|
|
62
64
|
|
|
63
65
|
This takes ownership of the MQTT session and will close it when the manager is closed.
|
|
64
66
|
"""
|
|
65
67
|
self._home_data_api = home_data_api
|
|
68
|
+
self._cache = cache
|
|
66
69
|
self._device_creator = device_creator
|
|
67
70
|
self._devices: dict[str, RoborockDevice] = {}
|
|
68
71
|
self._mqtt_session = mqtt_session
|
|
69
72
|
|
|
70
73
|
async def discover_devices(self) -> list[RoborockDevice]:
|
|
71
74
|
"""Discover all devices for the logged-in user."""
|
|
72
|
-
|
|
75
|
+
cache_data = await self._cache.get()
|
|
76
|
+
if not cache_data.home_data:
|
|
77
|
+
_LOGGER.debug("No cached home data found, fetching from API")
|
|
78
|
+
cache_data.home_data = await self._home_data_api()
|
|
79
|
+
await self._cache.set(cache_data)
|
|
80
|
+
home_data = cache_data.home_data
|
|
81
|
+
|
|
73
82
|
device_products = home_data.device_products
|
|
74
83
|
_LOGGER.debug("Discovered %d devices %s", len(device_products), home_data)
|
|
75
84
|
|
|
@@ -113,18 +122,24 @@ def create_home_data_api(email: str, user_data: UserData) -> HomeDataApi:
|
|
|
113
122
|
client = RoborockApiClient(email)
|
|
114
123
|
|
|
115
124
|
async def home_data_api() -> HomeData:
|
|
116
|
-
return await client.
|
|
125
|
+
return await client.get_home_data_v3(user_data)
|
|
117
126
|
|
|
118
127
|
return home_data_api
|
|
119
128
|
|
|
120
129
|
|
|
121
|
-
async def create_device_manager(
|
|
130
|
+
async def create_device_manager(
|
|
131
|
+
user_data: UserData,
|
|
132
|
+
home_data_api: HomeDataApi,
|
|
133
|
+
cache: Cache | None = None,
|
|
134
|
+
) -> DeviceManager:
|
|
122
135
|
"""Convenience function to create and initialize a DeviceManager.
|
|
123
136
|
|
|
124
137
|
The Home Data is fetched using the provided home_data_api callable which
|
|
125
138
|
is exposed this way to allow for swapping out other implementations to
|
|
126
139
|
include caching or other optimizations.
|
|
127
140
|
"""
|
|
141
|
+
if cache is None:
|
|
142
|
+
cache = NoCache()
|
|
128
143
|
|
|
129
144
|
mqtt_params = create_mqtt_params(user_data.rriot)
|
|
130
145
|
mqtt_session = await create_mqtt_session(mqtt_params)
|
|
@@ -135,7 +150,7 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
|
|
|
135
150
|
# TODO: Define a registration mechanism/factory for v1 traits
|
|
136
151
|
match device.pv:
|
|
137
152
|
case DeviceVersion.V1:
|
|
138
|
-
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device)
|
|
153
|
+
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
|
|
139
154
|
traits.append(StatusTrait(product, channel.rpc_channel))
|
|
140
155
|
case DeviceVersion.A01:
|
|
141
156
|
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
@@ -146,10 +161,13 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
|
|
|
146
161
|
traits.append(ZeoApi(mqtt_channel))
|
|
147
162
|
case _:
|
|
148
163
|
raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
|
|
164
|
+
case DeviceVersion.B01:
|
|
165
|
+
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
166
|
+
traits.append(B01PropsApi(mqtt_channel))
|
|
149
167
|
case _:
|
|
150
168
|
raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
|
|
151
169
|
return RoborockDevice(device, channel, traits)
|
|
152
170
|
|
|
153
|
-
manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session)
|
|
171
|
+
manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session, cache=cache)
|
|
154
172
|
await manager.discover_devices()
|
|
155
173
|
return manager
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from roborock import RoborockB01Methods
|
|
7
|
+
from roborock.roborock_message import RoborockB01Props
|
|
8
|
+
|
|
9
|
+
from ...b01_channel import send_decoded_command
|
|
10
|
+
from ...mqtt_channel import MqttChannel
|
|
11
|
+
from ..trait import Trait
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"B01PropsApi",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class B01PropsApi(Trait):
|
|
21
|
+
"""API for interacting with B01 devices."""
|
|
22
|
+
|
|
23
|
+
name = "B01_props"
|
|
24
|
+
|
|
25
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
26
|
+
"""Initialize the B01Props API."""
|
|
27
|
+
self._channel = channel
|
|
28
|
+
|
|
29
|
+
async def query_values(self, props: list[RoborockB01Props]) -> dict[int, Any]:
|
|
30
|
+
"""Query the device for the values of the given Dyad protocols."""
|
|
31
|
+
return await send_decoded_command(self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params=props)
|
|
@@ -18,6 +18,7 @@ from roborock.protocols.v1_protocol import (
|
|
|
18
18
|
from roborock.roborock_message import RoborockMessage
|
|
19
19
|
from roborock.roborock_typing import RoborockCommand
|
|
20
20
|
|
|
21
|
+
from .cache import Cache
|
|
21
22
|
from .channel import Channel
|
|
22
23
|
from .local_channel import LocalChannel, LocalSession, create_local_session
|
|
23
24
|
from .mqtt_channel import MqttChannel
|
|
@@ -46,6 +47,7 @@ class V1Channel(Channel):
|
|
|
46
47
|
security_data: SecurityData,
|
|
47
48
|
mqtt_channel: MqttChannel,
|
|
48
49
|
local_session: LocalSession,
|
|
50
|
+
cache: Cache,
|
|
49
51
|
) -> None:
|
|
50
52
|
"""Initialize the V1Channel.
|
|
51
53
|
|
|
@@ -62,7 +64,7 @@ class V1Channel(Channel):
|
|
|
62
64
|
self._mqtt_unsub: Callable[[], None] | None = None
|
|
63
65
|
self._local_unsub: Callable[[], None] | None = None
|
|
64
66
|
self._callback: Callable[[RoborockMessage], None] | None = None
|
|
65
|
-
self.
|
|
67
|
+
self._cache = cache
|
|
66
68
|
|
|
67
69
|
@property
|
|
68
70
|
def is_connected(self) -> bool:
|
|
@@ -131,19 +133,26 @@ class V1Channel(Channel):
|
|
|
131
133
|
|
|
132
134
|
This is a cloud only command used to get the local device's IP address.
|
|
133
135
|
"""
|
|
136
|
+
cache_data = await self._cache.get()
|
|
137
|
+
if cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
|
|
138
|
+
_LOGGER.debug("Using cached network info for device %s", self._device_uid)
|
|
139
|
+
return network_info
|
|
134
140
|
try:
|
|
135
|
-
|
|
141
|
+
network_info = await self._mqtt_rpc_channel.send_command(
|
|
136
142
|
RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
|
|
137
143
|
)
|
|
138
144
|
except RoborockException as e:
|
|
139
145
|
raise RoborockException(f"Network info failed for device {self._device_uid}") from e
|
|
146
|
+
_LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info)
|
|
147
|
+
cache_data.network_info[self._device_uid] = network_info
|
|
148
|
+
await self._cache.set(cache_data)
|
|
149
|
+
return network_info
|
|
140
150
|
|
|
141
151
|
async def _local_connect(self) -> Callable[[], None]:
|
|
142
152
|
"""Set up local connection if possible."""
|
|
143
153
|
_LOGGER.debug("Attempting to connect to local channel for device %s", self._device_uid)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
host = self._networking_info.ip
|
|
154
|
+
networking_info = await self._get_networking_info()
|
|
155
|
+
host = networking_info.ip
|
|
147
156
|
_LOGGER.debug("Connecting to local channel at %s", host)
|
|
148
157
|
self._local_channel = self._local_session(host)
|
|
149
158
|
try:
|
|
@@ -168,10 +177,14 @@ class V1Channel(Channel):
|
|
|
168
177
|
|
|
169
178
|
|
|
170
179
|
def create_v1_channel(
|
|
171
|
-
user_data: UserData,
|
|
180
|
+
user_data: UserData,
|
|
181
|
+
mqtt_params: MqttParams,
|
|
182
|
+
mqtt_session: MqttSession,
|
|
183
|
+
device: HomeDataDevice,
|
|
184
|
+
cache: Cache,
|
|
172
185
|
) -> V1Channel:
|
|
173
186
|
"""Create a V1Channel for the given device."""
|
|
174
187
|
security_data = create_security_data(user_data.rriot)
|
|
175
188
|
mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
|
|
176
189
|
local_session = create_local_session(device.local_key)
|
|
177
|
-
return V1Channel(device.duid, security_data, mqtt_channel, local_session=local_session)
|
|
190
|
+
return V1Channel(device.duid, security_data, mqtt_channel, local_session=local_session, cache=cache)
|
|
@@ -49,6 +49,7 @@ class RoborockMqttSession(MqttSession):
|
|
|
49
49
|
self._params = params
|
|
50
50
|
self._background_task: asyncio.Task[None] | None = None
|
|
51
51
|
self._healthy = False
|
|
52
|
+
self._stop = False
|
|
52
53
|
self._backoff = MIN_BACKOFF_INTERVAL
|
|
53
54
|
self._client: aiomqtt.Client | None = None
|
|
54
55
|
self._client_lock = asyncio.Lock()
|
|
@@ -81,6 +82,7 @@ class RoborockMqttSession(MqttSession):
|
|
|
81
82
|
|
|
82
83
|
async def close(self) -> None:
|
|
83
84
|
"""Cancels the MQTT loop and shutdown the client library."""
|
|
85
|
+
self._stop = True
|
|
84
86
|
if self._background_task:
|
|
85
87
|
self._background_task.cancel()
|
|
86
88
|
try:
|
|
@@ -125,16 +127,19 @@ class RoborockMqttSession(MqttSession):
|
|
|
125
127
|
except Exception as err:
|
|
126
128
|
# This error is thrown when the MQTT loop is cancelled
|
|
127
129
|
# and the generator is not stopped.
|
|
128
|
-
if "generator didn't stop" in str(err):
|
|
130
|
+
if "generator didn't stop" in str(err) or "generator didn't yield" in str(err):
|
|
129
131
|
_LOGGER.debug("MQTT loop was cancelled")
|
|
130
132
|
return
|
|
131
133
|
if start_future:
|
|
132
134
|
_LOGGER.error("Uncaught error starting MQTT session: %s", err)
|
|
133
135
|
start_future.set_exception(err)
|
|
134
136
|
return
|
|
135
|
-
_LOGGER.
|
|
137
|
+
_LOGGER.exception("Uncaught error during MQTT session: %s", err)
|
|
136
138
|
|
|
137
139
|
self._healthy = False
|
|
140
|
+
if self._stop:
|
|
141
|
+
_LOGGER.debug("MQTT session closed, stopping retry loop")
|
|
142
|
+
return
|
|
138
143
|
_LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds())
|
|
139
144
|
await asyncio.sleep(self._backoff.total_seconds())
|
|
140
145
|
self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
|
|
@@ -180,7 +185,7 @@ class RoborockMqttSession(MqttSession):
|
|
|
180
185
|
except asyncio.CancelledError:
|
|
181
186
|
raise
|
|
182
187
|
except Exception as e:
|
|
183
|
-
_LOGGER.
|
|
188
|
+
_LOGGER.exception("Uncaught exception in subscriber callback: %s", e)
|
|
184
189
|
|
|
185
190
|
async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
|
|
186
191
|
"""Subscribe to messages on the specified topic and invoke the callback for new messages.
|
|
@@ -39,6 +39,7 @@ from roborock.roborock_message import RoborockMessage
|
|
|
39
39
|
_LOGGER = logging.getLogger(__name__)
|
|
40
40
|
SALT = b"TXdfu$jyZ#TZHsg4"
|
|
41
41
|
A01_HASH = "726f626f726f636b2d67a6d6da"
|
|
42
|
+
B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E"
|
|
42
43
|
BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
|
|
43
44
|
AP_CONFIG = 1
|
|
44
45
|
SOCK_DISCOVERY = 2
|
|
@@ -213,6 +214,10 @@ class EncryptionAdapter(Construct):
|
|
|
213
214
|
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
|
|
214
215
|
f = decipher.encrypt(obj)
|
|
215
216
|
return f
|
|
217
|
+
elif context.version == b"B01":
|
|
218
|
+
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
|
|
219
|
+
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
|
|
220
|
+
return decipher.encrypt(obj)
|
|
216
221
|
token = self.token_func(context)
|
|
217
222
|
encrypted = Utils.encrypt_ecb(obj, token)
|
|
218
223
|
return encrypted
|
|
@@ -224,6 +229,10 @@ class EncryptionAdapter(Construct):
|
|
|
224
229
|
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
|
|
225
230
|
f = decipher.decrypt(obj)
|
|
226
231
|
return f
|
|
232
|
+
elif context.version == b"B01":
|
|
233
|
+
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
|
|
234
|
+
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
|
|
235
|
+
return decipher.decrypt(obj)
|
|
227
236
|
token = self.token_func(context)
|
|
228
237
|
decrypted = Utils.decrypt_ecb(obj, token)
|
|
229
238
|
return decrypted
|
|
@@ -248,7 +257,7 @@ class PrefixedStruct(Struct):
|
|
|
248
257
|
def _parse(self, stream, context, path):
|
|
249
258
|
subcon1 = Peek(Optional(Bytes(3)))
|
|
250
259
|
peek_version = subcon1.parse_stream(stream, **context)
|
|
251
|
-
if peek_version not in (b"1.0", b"A01"):
|
|
260
|
+
if peek_version not in (b"1.0", b"A01", b"B01"):
|
|
252
261
|
subcon2 = Bytes(4)
|
|
253
262
|
subcon2.parse_stream(stream, **context)
|
|
254
263
|
return super()._parse(stream, context, path)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Roborock B01 Protocol encoding and decoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Util.Padding import pad, unpad
|
|
9
|
+
|
|
10
|
+
from roborock import RoborockB01Methods
|
|
11
|
+
from roborock.exceptions import RoborockException
|
|
12
|
+
from roborock.roborock_message import (
|
|
13
|
+
RoborockMessage,
|
|
14
|
+
RoborockMessageProtocol,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
B01_VERSION = b"B01"
|
|
20
|
+
CommandType = RoborockB01Methods | str
|
|
21
|
+
ParamsType = list | dict | int | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
|
|
25
|
+
"""Encode payload for B01 commands over MQTT."""
|
|
26
|
+
dps_data = {"dps": {dps: {"method": command, "params": params or []}}}
|
|
27
|
+
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
|
|
28
|
+
return RoborockMessage(
|
|
29
|
+
protocol=RoborockMessageProtocol.RPC_REQUEST,
|
|
30
|
+
version=B01_VERSION,
|
|
31
|
+
payload=payload,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
|
|
36
|
+
"""Decode a B01 RPC_RESPONSE message."""
|
|
37
|
+
if not message.payload:
|
|
38
|
+
raise RoborockException("Invalid B01 message format: missing payload")
|
|
39
|
+
try:
|
|
40
|
+
unpadded = unpad(message.payload, AES.block_size)
|
|
41
|
+
except ValueError as err:
|
|
42
|
+
raise RoborockException(f"Unable to unpad B01 payload: {err}")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
payload = json.loads(unpadded.decode())
|
|
46
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
47
|
+
raise RoborockException(f"Invalid B01 message payload: {e} for {message.payload!r}") from e
|
|
48
|
+
|
|
49
|
+
datapoints = payload.get("dps", {})
|
|
50
|
+
if not isinstance(datapoints, dict):
|
|
51
|
+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
|
|
52
|
+
try:
|
|
53
|
+
return {int(key): value for key, value in datapoints.items()}
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise RoborockException(f"Invalid B01 message format: 'dps' key should be an integer for {message.payload!r}")
|
|
File without changes
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import math
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
7
8
|
|
|
8
9
|
from roborock import RoborockEnum
|
|
9
10
|
from roborock.util import get_next_int
|
|
@@ -130,6 +131,98 @@ class RoborockZeoProtocol(RoborockEnum):
|
|
|
130
131
|
RPC_RESp = 10102
|
|
131
132
|
|
|
132
133
|
|
|
134
|
+
class RoborockB01Protocol(RoborockEnum):
|
|
135
|
+
RPC_REQUEST = 101
|
|
136
|
+
RPC_RESPONSE = 102
|
|
137
|
+
ERROR_CODE = 120
|
|
138
|
+
STATE = 121
|
|
139
|
+
BATTERY = 122
|
|
140
|
+
FAN_POWER = 123
|
|
141
|
+
WATER_BOX_MODE = 124
|
|
142
|
+
MAIN_BRUSH_LIFE = 125
|
|
143
|
+
SIDE_BRUSH_LIFE = 126
|
|
144
|
+
FILTER_LIFE = 127
|
|
145
|
+
OFFLINE_STATUS = 135
|
|
146
|
+
CLEAN_TIMES = 136
|
|
147
|
+
CLEANING_PREFERENCE = 137
|
|
148
|
+
CLEAN_TASK_TYPE = 138
|
|
149
|
+
BACK_TYPE = 139
|
|
150
|
+
DOCK_TASK_TYPE = 140
|
|
151
|
+
CLEANING_PROGRESS = 141
|
|
152
|
+
FC_STATE = 142
|
|
153
|
+
START_CLEAN_TASK = 201
|
|
154
|
+
START_BACK_DOCK_TASK = 202
|
|
155
|
+
START_DOCK_TASK = 203
|
|
156
|
+
PAUSE = 204
|
|
157
|
+
RESUME = 205
|
|
158
|
+
STOP = 206
|
|
159
|
+
CEIP = 207
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RoborockB01Props(StrEnum):
|
|
163
|
+
"""Properties requested by the Roborock B01 model."""
|
|
164
|
+
|
|
165
|
+
STATUS = "status"
|
|
166
|
+
FAULT = "fault"
|
|
167
|
+
WIND = "wind"
|
|
168
|
+
WATER = "water"
|
|
169
|
+
MODE = "mode"
|
|
170
|
+
QUANTITY = "quantity"
|
|
171
|
+
ALARM = "alarm"
|
|
172
|
+
VOLUME = "volume"
|
|
173
|
+
HYPA = "hypa"
|
|
174
|
+
MAIN_BRUSH = "main_brush"
|
|
175
|
+
SIDE_BRUSH = "side_brush"
|
|
176
|
+
MOP_LIFE = "mop_life"
|
|
177
|
+
MAIN_SENSOR = "main_sensor"
|
|
178
|
+
NET_STATUS = "net_status"
|
|
179
|
+
REPEAT_STATE = "repeat_state"
|
|
180
|
+
TANK_STATE = "tank_state"
|
|
181
|
+
SWEEP_TYPE = "sweep_type"
|
|
182
|
+
CLEAN_PATH_PREFERENCE = "clean_path_preference"
|
|
183
|
+
CLOTH_STATE = "cloth_state"
|
|
184
|
+
TIME_ZONE = "time_zone"
|
|
185
|
+
TIME_ZONE_INFO = "time_zone_info"
|
|
186
|
+
LANGUAGE = "language"
|
|
187
|
+
CLEANING_TIME = "cleaning_time"
|
|
188
|
+
REAL_CLEAN_TIME = "real_clean_time"
|
|
189
|
+
CLEANING_AREA = "cleaning_area"
|
|
190
|
+
CUSTOM_TYPE = "custom_type"
|
|
191
|
+
SOUND = "sound"
|
|
192
|
+
WORK_MODE = "work_mode"
|
|
193
|
+
STATION_ACT = "station_act"
|
|
194
|
+
CHARGE_STATE = "charge_state"
|
|
195
|
+
CURRENT_MAP_ID = "current_map_id"
|
|
196
|
+
MAP_NUM = "map_num"
|
|
197
|
+
DUST_ACTION = "dust_action"
|
|
198
|
+
QUIET_IS_OPEN = "quiet_is_open"
|
|
199
|
+
QUIET_BEGIN_TIME = "quiet_begin_time"
|
|
200
|
+
QUIET_END_TIME = "quiet_end_time"
|
|
201
|
+
CLEAN_FINISH = "clean_finish"
|
|
202
|
+
VOICE_TYPE = "voice_type"
|
|
203
|
+
VOICE_TYPE_VERSION = "voice_type_version"
|
|
204
|
+
ORDER_TOTAL = "order_total"
|
|
205
|
+
BUILD_MAP = "build_map"
|
|
206
|
+
PRIVACY = "privacy"
|
|
207
|
+
DUST_AUTO_STATE = "dust_auto_state"
|
|
208
|
+
DUST_FREQUENCY = "dust_frequency"
|
|
209
|
+
CHILD_LOCK = "child_lock"
|
|
210
|
+
MULTI_FLOOR = "multi_floor"
|
|
211
|
+
MAP_SAVE = "map_save"
|
|
212
|
+
LIGHT_MODE = "light_mode"
|
|
213
|
+
GREEN_LASER = "green_laser"
|
|
214
|
+
DUST_BAG_USED = "dust_bag_used"
|
|
215
|
+
ORDER_SAVE_MODE = "order_save_mode"
|
|
216
|
+
MANUFACTURER = "manufacturer"
|
|
217
|
+
BACK_TO_WASH = "back_to_wash"
|
|
218
|
+
CHARGE_STATION_TYPE = "charge_station_type"
|
|
219
|
+
PV_CUT_CHARGE = "pv_cut_charge"
|
|
220
|
+
PV_CHARGING = "pv_charging"
|
|
221
|
+
SERIAL_NUMBER = "serial_number"
|
|
222
|
+
RECOMMEND = "recommend"
|
|
223
|
+
ADD_SWEEP_STATUS = "add_sweep_status"
|
|
224
|
+
|
|
225
|
+
|
|
133
226
|
ROBOROCK_DATA_STATUS_PROTOCOL = [
|
|
134
227
|
RoborockDataProtocol.ERROR_CODE,
|
|
135
228
|
RoborockDataProtocol.STATE,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from enum import Enum
|
|
4
|
+
from enum import Enum, StrEnum
|
|
5
5
|
|
|
6
6
|
from .containers import (
|
|
7
7
|
CleanRecord,
|
|
@@ -271,6 +271,20 @@ class RoborockCommand(str, Enum):
|
|
|
271
271
|
APP_GET_ROBOT_SETTING = "app_get_robot_setting"
|
|
272
272
|
|
|
273
273
|
|
|
274
|
+
class RoborockB01Methods(StrEnum):
|
|
275
|
+
"""Methods used by the Roborock B01 model."""
|
|
276
|
+
|
|
277
|
+
GET_PROP = "prop.get"
|
|
278
|
+
GET_MAP_LIST = "service.get_map_list"
|
|
279
|
+
UPLOAD_BY_MAPTYPE = "service.upload_by_maptype"
|
|
280
|
+
SET_PROP = "prop.set"
|
|
281
|
+
GET_PREFERENCE = "service.get_preference"
|
|
282
|
+
GET_RECORD_LIST = "service.get_record_list"
|
|
283
|
+
GET_ORDER = "service.get_order"
|
|
284
|
+
EVENT_ORDER_LIST_POST = "event.order_list.post"
|
|
285
|
+
POST_PROP = "prop.post"
|
|
286
|
+
|
|
287
|
+
|
|
274
288
|
@dataclass
|
|
275
289
|
class DockSummary(RoborockBase):
|
|
276
290
|
dust_collection_mode: DustCollectionMode | None = None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/roborock_client_a01.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|