Skip to content

Commit 1c61e7f

Browse files
committed
feat: Connect to devices asynchronously
1 parent 324c816 commit 1c61e7f

File tree

3 files changed

+98
-1
lines changed

3 files changed

+98
-1
lines changed

roborock/devices/device.py

Lines changed: 36 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,31 @@ 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+
while True:
124+
try:
125+
await self.connect()
126+
return
127+
except RoborockException as e:
128+
_LOGGER.info("Failed to connect to device %s: %s", self.name, e)
129+
_LOGGER.info("Retrying connection to device %s in %s seconds", self.name, backoff.total_seconds())
130+
await asyncio.sleep(backoff.total_seconds())
131+
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
132+
133+
self._connect_task = asyncio.create_task(connect_loop())
134+
101135
async def connect(self) -> None:
102136
"""Connect to the device using the appropriate protocol channel."""
103137
if self._unsub:
@@ -107,6 +141,8 @@ async def connect(self) -> None:
107141

108142
async def close(self) -> None:
109143
"""Close all connections to the device."""
144+
if self._connect_task:
145+
self._connect_task.cancel()
110146
if self._unsub:
111147
self._unsub()
112148
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)

tests/devices/test_device_manager.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for the DeviceManager class."""
22

3+
import datetime
4+
import asyncio
35
from collections.abc import Generator, Iterator
46
from unittest.mock import AsyncMock, Mock, patch
57

@@ -34,6 +36,25 @@ def channel_fixture() -> Generator[Mock, None, None]:
3436
yield mock_channel
3537

3638

39+
@pytest.fixture(autouse=True)
40+
def mock_sleep() -> Generator[Mock, None, None]:
41+
"""Mock asyncio.sleep in device module to speed up tests."""
42+
sleep_time = datetime.timedelta(seconds=0.001)
43+
with patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time):
44+
yield
45+
46+
47+
@pytest.fixture(name="channel_failure")
48+
def channel_failure_fixture() -> Generator[Mock, None, None]:
49+
"""Fixture that makes channel subscribe fail."""
50+
with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel:
51+
mock_channel.return_value.subscribe = AsyncMock(
52+
side_effect=RoborockException("Connection failed")
53+
)
54+
mock_channel.return_value.is_connected = False
55+
yield mock_channel
56+
57+
3758
@pytest.fixture(name="home_data_no_devices")
3859
def home_data_no_devices_fixture() -> Iterator[HomeData]:
3960
"""Mock home data API that returns no devices."""
@@ -127,3 +148,43 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData:
127148
assert len(devices2) == 1
128149

129150
await device_manager.close()
151+
152+
153+
async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None:
154+
"""Test that start_connect retries when connection fails."""
155+
device_manager = await create_device_manager(USER_PARAMS)
156+
devices = await device_manager.get_devices()
157+
158+
# Wait for the device to attempt to connect
159+
attempts = 0
160+
subscribe_mock = channel_failure.return_value.subscribe
161+
while subscribe_mock.call_count < 1:
162+
await asyncio.sleep(0.01)
163+
attempts += 1
164+
assert attempts < 10, "Device did not connect after multiple attempts"
165+
166+
# Device should exist but not be connected
167+
assert len(devices) == 1
168+
assert not devices[0].is_connected
169+
170+
# Verify retry attempts
171+
assert channel_failure.return_value.subscribe.call_count >= 1
172+
173+
# Reset the mock channel so that it succeeds on the next attempt
174+
mock_unsub = Mock()
175+
subscribe_mock = AsyncMock()
176+
subscribe_mock.return_value = mock_unsub
177+
channel_failure.return_value.subscribe = subscribe_mock
178+
channel_failure.return_value.is_connected = True
179+
180+
# Wait for the device to attempt to connect again
181+
attempts = 0
182+
while subscribe_mock.call_count < 1:
183+
await asyncio.sleep(0.01)
184+
attempts += 1
185+
assert attempts < 10, "Device did not connect after multiple attempts"
186+
187+
assert devices[0].is_connected
188+
189+
await device_manager.close()
190+
assert mock_unsub.call_count == 1

0 commit comments

Comments
 (0)