Skip to content

Commit a9e3235

Browse files
authored
Merge branch 'main' into trait-updates
2 parents da0336e + e561838 commit a9e3235

File tree

16 files changed

+483
-88
lines changed

16 files changed

+483
-88
lines changed

CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,62 @@
22

33
<!-- version list -->
44

5+
## v3.8.0 (2025-11-15)
6+
7+
### Bug Fixes
8+
9+
- Update roborock/devices/device.py
10+
([#588](https://github.com/Python-roborock/python-roborock/pull/588),
11+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
12+
13+
### Chores
14+
15+
- Fix lint ([#589](https://github.com/Python-roborock/python-roborock/pull/589),
16+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
17+
18+
- Fix lint errors ([#588](https://github.com/Python-roborock/python-roborock/pull/588),
19+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
20+
21+
- Update comments to clarify close call
22+
([#588](https://github.com/Python-roborock/python-roborock/pull/588),
23+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
24+
25+
- Update documentation to point to the newer device APIs
26+
([#589](https://github.com/Python-roborock/python-roborock/pull/589),
27+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
28+
29+
- Update pydoc and formatting ([#588](https://github.com/Python-roborock/python-roborock/pull/588),
30+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
31+
32+
- Update README.md ([#589](https://github.com/Python-roborock/python-roborock/pull/589),
33+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
34+
35+
- Update roborock/devices/device.py
36+
([#588](https://github.com/Python-roborock/python-roborock/pull/588),
37+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
38+
39+
- Update roborock/devices/traits/b01/__init__.py
40+
([#589](https://github.com/Python-roborock/python-roborock/pull/589),
41+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
42+
43+
- Update roborock/devices/traits/v1/__init__.py
44+
([#589](https://github.com/Python-roborock/python-roborock/pull/589),
45+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
46+
47+
- Update typing ([#588](https://github.com/Python-roborock/python-roborock/pull/588),
48+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
49+
50+
### Features
51+
52+
- Add examples that show how to use the cache and implement a file cache
53+
([#589](https://github.com/Python-roborock/python-roborock/pull/589),
54+
[`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00))
55+
56+
- Connect to devices asynchronously
57+
([#588](https://github.com/Python-roborock/python-roborock/pull/588),
58+
[`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d))
59+
60+
561
## v3.7.4 (2025-11-15)
662

763
### Bug Fixes

README.md

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,14 @@ Install this via pip (or your favourite package manager):
2020

2121
You can see all of the commands supported [here](https://python-roborock.readthedocs.io/en/latest/api_commands.html)
2222

23-
## Sending Commands
23+
## Example Usage
2424

25-
Here is an example that requires no manual intervention and can be done all automatically. You can skip some steps by
26-
caching values or looking at them and grabbing them manually.
2725
```python
2826
import asyncio
2927

30-
from roborock import HomeDataProduct, DeviceData, RoborockCommand
31-
from roborock.version_1_apis import RoborockMqttClientV1, RoborockLocalClientV1
3228
from roborock.web_api import RoborockApiClient
29+
from roborock.devices.device_manager import create_device_manager, UserParams
30+
3331

3432
async def main():
3533
web_api = RoborockApiClient(username="youremailhere")
@@ -40,30 +38,31 @@ async def main():
4038
code = input("What is the code?")
4139
user_data = await web_api.code_login(code)
4240

43-
# Get home data
44-
home_data = await web_api.get_home_data_v2(user_data)
45-
46-
# Get the device you want
47-
device = home_data.devices[0]
48-
49-
# Get product ids:
50-
product_info: dict[str, HomeDataProduct] = {
51-
product.id: product for product in home_data.products
52-
}
53-
# Create the Mqtt(aka cloud required) Client
54-
device_data = DeviceData(device, product_info[device.product_id].model)
55-
mqtt_client = RoborockMqttClientV1(user_data, device_data)
56-
networking = await mqtt_client.get_networking()
57-
local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip)
58-
local_client = RoborockLocalClientV1(local_device_data)
59-
# You can use the send_command to send any command to the device
60-
status = await local_client.send_command(RoborockCommand.GET_STATUS)
61-
# Or use existing functions that will give you data classes
62-
status = await local_client.get_status()
41+
# Create a device manager that can discover devices.
42+
user_params = UserParams(
43+
username="youremailhere",
44+
user_data=user_data,
45+
)
46+
device_manager = await create_device_manager(user_params)
47+
devices = await device_manager.get_devices()
48+
49+
# Get all vacuum devices that support the v1 PropertiesApi
50+
for device in devices:
51+
if not device.v1_properties:
52+
continue
53+
54+
# Refresh the current device status
55+
status_trait = device.v1_properties.status
56+
await status_trait.refresh()
57+
print(status_trait)
6358

6459
asyncio.run(main())
6560
```
6661

62+
See [examples/example.py](examples/example.py) for a more full featured example
63+
that has performance improvements to cache cloud information to prefer
64+
connections over the local network.
65+
6766
## Supported devices
6867

6968
You can find what devices are supported

examples/example.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Example script demonstrating how to connect to Roborock devices and print their status."""
2+
3+
import asyncio
4+
import dataclasses
5+
import json
6+
import pathlib
7+
from typing import Any
8+
9+
from roborock.devices.device_manager import UserParams, create_device_manager
10+
from roborock.devices.file_cache import FileCache, load_value, store_value
11+
from roborock.web_api import RoborockApiClient
12+
13+
# We typically store the login credentials/information separately from other cached data.
14+
USER_PARAMS_PATH = pathlib.Path.home() / ".cache" / "roborock-user-params.pkl"
15+
16+
# Device connection information is cached to speed up future connections.
17+
CACHE_PATH = pathlib.Path.home() / ".cache" / "roborock-cache-data.pkl"
18+
19+
20+
async def login_flow() -> UserParams:
21+
"""Perform the login flow to obtain UserData from the web API."""
22+
username = input("Email: ")
23+
web_api = RoborockApiClient(username=username)
24+
print("Requesting login code sent to email...")
25+
await web_api.request_code()
26+
code = input("Code: ")
27+
user_data = await web_api.code_login(code)
28+
# We store the base_url to avoid future discovery calls.
29+
base_url = await web_api.base_url
30+
return UserParams(
31+
username=username,
32+
user_data=user_data,
33+
base_url=base_url,
34+
)
35+
36+
37+
async def get_or_create_session() -> UserParams:
38+
"""Initialize the session by logging in if necessary."""
39+
user_params = await load_value(USER_PARAMS_PATH)
40+
if user_params is None:
41+
print("No cached login data found, please login.")
42+
user_params = await login_flow()
43+
print("Login successful, caching login data...")
44+
await store_value(USER_PARAMS_PATH, user_params)
45+
print(f"Cached login data to {USER_PARAMS_PATH}.")
46+
return user_params
47+
48+
49+
def remove_none_values(data: dict[str, Any]) -> dict[str, Any]:
50+
return {k: v for k, v in data.items() if v is not None}
51+
52+
53+
async def main():
54+
user_params = await get_or_create_session()
55+
cache = FileCache(CACHE_PATH)
56+
57+
# Create a device manager that can discover devices.
58+
device_manager = await create_device_manager(user_params, cache=cache)
59+
devices = await device_manager.get_devices()
60+
61+
# Get all vacuum devices that support the v1 PropertiesApi
62+
device_results = []
63+
for device in devices:
64+
if not device.v1_properties:
65+
continue
66+
67+
# Refresh the current device status
68+
status_trait = device.v1_properties.status
69+
await status_trait.refresh()
70+
71+
# Print the device status as JSON
72+
device_results.append(
73+
{
74+
"device": device.name,
75+
"status": remove_none_values(dataclasses.asdict(status_trait)),
76+
}
77+
)
78+
79+
print(json.dumps(device_results, indent=2))
80+
81+
await cache.flush()
82+
83+
84+
if __name__ == "__main__":
85+
asyncio.run(main())

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-roborock"
3-
version = "3.7.4"
3+
version = "3.8.0"
44
description = "A package to control Roborock vacuums."
55
authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
66
requires-python = ">=3.11, <4"
@@ -44,7 +44,7 @@ dev = [
4444
"pytest",
4545
"pre-commit>=3.5,<5.0",
4646
"mypy",
47-
"ruff==0.14.4",
47+
"ruff==0.14.5",
4848
"codespell",
4949
"pyshark>=0.6,<0.7",
5050
"aioresponses>=0.7.7,<0.8",

roborock/__init__.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
cloud_api,
1212
const,
1313
data,
14+
devices,
1415
exceptions,
1516
roborock_typing,
1617
version_1_apis,
@@ -19,13 +20,11 @@
1920
)
2021

2122
__all__ = [
23+
"devices",
24+
"data",
25+
"map",
2226
"web_api",
23-
"version_1_apis",
24-
"version_a01_apis",
25-
"const",
26-
"cloud_api",
2727
"roborock_typing",
2828
"exceptions",
29-
"data",
30-
# Add new APIs here in the future when they are public e.g. devices/
29+
"const",
3130
]

roborock/devices/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# Roborock Device Discovery
1+
# Roborock Devices & Discovery
22

3-
This page documents the full lifecycle of device discovery across Cloud and Network.
3+
The devices module provides functionality to discover Roborock devices on the
4+
network. This section documents the full lifecycle of device discovery across
5+
Cloud and Network.
46

57
## Init account setup
68

@@ -61,7 +63,7 @@ that a newer version of the API should be used.
6163

6264
## Design
6365

64-
### Current API Issues
66+
### Prior API Issues
6567

6668
- Complex Inheritance Hierarchy: Multiple inheritance with classes like RoborockMqttClientV1 inheriting from both RoborockMqttClient and RoborockClientV1
6769

roborock/devices/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
"""The devices module provides functionality to discover Roborock devices on the network."""
1+
"""
2+
.. include:: ./README.md
3+
"""
24

35
__all__ = [
46
"device",
57
"device_manager",
68
"cache",
9+
"file_cache",
10+
"traits",
711
]

roborock/devices/device.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
until the API is stable.
55
"""
66

7+
import asyncio
8+
import datetime
79
import logging
810
from abc import ABC
911
from collections.abc import Callable, Mapping
1012
from typing import Any, TypeVar, cast
1113

1214
from roborock.data import HomeDataDevice, HomeDataProduct
15+
from roborock.exceptions import RoborockException
1316
from roborock.roborock_message import RoborockMessage
1417

1518
from .channel import Channel
@@ -22,6 +25,11 @@
2225
"RoborockDevice",
2326
]
2427

28+
# Exponential backoff parameters
29+
MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10)
30+
MAX_BACKOFF_INTERVAL = datetime.timedelta(minutes=30)
31+
BACKOFF_MULTIPLIER = 1.5
32+
2533

2634
class RoborockDevice(ABC, TraitsMixin):
2735
"""A generic channel for establishing a connection with a Roborock device.
@@ -54,6 +62,7 @@ def __init__(
5462
self._device_info = device_info
5563
self._product = product
5664
self._channel = channel
65+
self._connect_task: asyncio.Task[None] | None = None
5766
self._unsub: Callable[[], None] | None = None
5867

5968
@property
@@ -98,6 +107,38 @@ def is_local_connected(self) -> bool:
98107
"""
99108
return self._channel.is_local_connected
100109

110+
def start_connect(self) -> None:
111+
"""Start a background task to connect to the device.
112+
113+
This will attempt to connect to the device using the appropriate protocol
114+
channel. If the connection fails, it will retry with exponential backoff.
115+
116+
Once connected, the device will remain connected until `close()` is
117+
called. The device will automatically attempt to reconnect if the connection
118+
is lost.
119+
"""
120+
121+
async def connect_loop() -> None:
122+
backoff = MIN_BACKOFF_INTERVAL
123+
try:
124+
while True:
125+
try:
126+
await self.connect()
127+
return
128+
except RoborockException as e:
129+
_LOGGER.info("Failed to connect to device %s: %s", self.name, e)
130+
_LOGGER.info(
131+
"Retrying connection to device %s in %s seconds", self.name, backoff.total_seconds()
132+
)
133+
await asyncio.sleep(backoff.total_seconds())
134+
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
135+
except asyncio.CancelledError:
136+
_LOGGER.info("connect_loop for device %s was cancelled", self.name)
137+
# Clean exit on cancellation
138+
return
139+
140+
self._connect_task = asyncio.create_task(connect_loop())
141+
101142
async def connect(self) -> None:
102143
"""Connect to the device using the appropriate protocol channel."""
103144
if self._unsub:
@@ -107,6 +148,12 @@ async def connect(self) -> None:
107148

108149
async def close(self) -> None:
109150
"""Close all connections to the device."""
151+
if self._connect_task:
152+
self._connect_task.cancel()
153+
try:
154+
await self._connect_task
155+
except asyncio.CancelledError:
156+
pass
110157
if self._unsub:
111158
self._unsub()
112159
self._unsub = None

roborock/devices/device_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async def discover_devices(self) -> list[RoborockDevice]:
8686
if duid in self._devices:
8787
continue
8888
new_device = self._device_creator(home_data, device, product)
89-
await new_device.connect()
89+
new_device.start_connect()
9090
new_devices[duid] = new_device
9191

9292
self._devices.update(new_devices)

0 commit comments

Comments
 (0)