Skip to content

Commit 2c9323a

Browse files
committed
feat: Add ability to listen for ready devices
Add a callback that will be invoked when devices become ready. This is to allow non-blocking setup with dynamically connected devices. In the future we can periodically or asynchronously invoke discover_devices and automatically find new devices as they become available.
1 parent f13e87a commit 2c9323a

File tree

3 files changed

+53
-4
lines changed

3 files changed

+53
-4
lines changed

roborock/devices/device.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections.abc import Callable, Mapping
1212
from typing import Any, TypeVar, cast
1313

14+
from roborock.callbacks import CallbackList
1415
from roborock.data import HomeDataDevice, HomeDataProduct
1516
from roborock.exceptions import RoborockException
1617
from roborock.roborock_message import RoborockMessage
@@ -22,14 +23,18 @@
2223
_LOGGER = logging.getLogger(__name__)
2324

2425
__all__ = [
26+
"DeviceReadyCallback",
2527
"RoborockDevice",
2628
]
2729

2830
# Exponential backoff parameters
2931
MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10)
3032
MAX_BACKOFF_INTERVAL = datetime.timedelta(minutes=30)
3133
BACKOFF_MULTIPLIER = 1.5
32-
START_ATTEMPT_TIMEOUT = datetime.timedelta(seconds=5)
34+
START_ATTEMPT_TIMEOUT = datetime.timedelta(seconds=10)
35+
36+
37+
DeviceReadyCallback = Callable[["RoborockDevice"], None]
3338

3439

3540
class RoborockDevice(ABC, TraitsMixin):
@@ -65,6 +70,8 @@ def __init__(
6570
self._channel = channel
6671
self._connect_task: asyncio.Task[None] | None = None
6772
self._unsub: Callable[[], None] | None = None
73+
self._ready_callbacks = CallbackList["RoborockDevice"]()
74+
self._has_connected = False
6875

6976
@property
7077
def duid(self) -> str:
@@ -108,6 +115,22 @@ def is_local_connected(self) -> bool:
108115
"""
109116
return self._channel.is_local_connected
110117

118+
def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
119+
"""Add a callback to be notified when the device is ready.
120+
121+
A device is considered ready when it has successfully connected. It may go
122+
offline later, but this callback will only be called once when the device
123+
first connects.
124+
125+
The callback will be called immediately if the device has already previously
126+
connected.
127+
"""
128+
remove = self._ready_callbacks.add_callback(callback)
129+
if self._has_connected:
130+
callback(self)
131+
132+
return remove
133+
111134
async def start_connect(self) -> None:
112135
"""Start a background task to connect to the device.
113136
@@ -131,6 +154,8 @@ async def connect_loop() -> None:
131154
try:
132155
await self.connect()
133156
start_attempt.set()
157+
self._has_connected = True
158+
self._ready_callbacks(self)
134159
return
135160
except RoborockException as e:
136161
start_attempt.set()

roborock/devices/device_manager.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
HomeDataProduct,
1515
UserData,
1616
)
17-
from roborock.devices.device import RoborockDevice
17+
from roborock.devices.device import DeviceReadyCallback, RoborockDevice
1818
from roborock.map.map_parser import MapParserConfig
1919
from roborock.mqtt.roborock_session import create_lazy_mqtt_session
2020
from roborock.mqtt.session import MqttSession
@@ -155,6 +155,7 @@ async def create_device_manager(
155155
cache: Cache | None = None,
156156
map_parser_config: MapParserConfig | None = None,
157157
session: aiohttp.ClientSession | None = None,
158+
ready_callback: DeviceReadyCallback | None = None,
158159
) -> DeviceManager:
159160
"""Convenience function to create and initialize a DeviceManager.
160161
@@ -163,6 +164,7 @@ async def create_device_manager(
163164
cache: Optional cache implementation to use for caching device data.
164165
map_parser_config: Optional configuration for parsing maps.
165166
session: Optional aiohttp ClientSession to use for HTTP requests.
167+
ready_callback: Optional callback to be notified when a device is ready.
166168
167169
Returns:
168170
An initialized DeviceManager with discovered devices.
@@ -211,7 +213,11 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
211213
raise NotImplementedError(f"Device {device.name} has unsupported B01 model: {product.model}")
212214
case _:
213215
raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
214-
return RoborockDevice(device, product, channel, trait)
216+
217+
dev = RoborockDevice(device, product, channel, trait)
218+
if ready_callback:
219+
dev.add_ready_callback(ready_callback)
220+
return dev
215221

216222
manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache)
217223
await manager.discover_devices()

tests/devices/test_device_manager.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from roborock.data import HomeData, UserData
1111
from roborock.devices.cache import InMemoryCache
12+
from roborock.devices.device import RoborockDevice
1213
from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper
1314
from roborock.exceptions import RoborockException
1415

@@ -171,9 +172,23 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData:
171172
await device_manager.close()
172173

173174

175+
async def test_ready_callback(home_data: HomeData) -> None:
176+
"""Test that the ready callback is invoked when a device connects."""
177+
ready_devices: list[RoborockDevice] = []
178+
device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append)
179+
180+
# Callback should be called for the discovered device
181+
assert len(ready_devices) == 1
182+
device = ready_devices[0]
183+
assert device.duid == "abc123"
184+
185+
await device_manager.close()
186+
187+
174188
async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None:
175189
"""Test that start_connect retries when connection fails."""
176-
device_manager = await create_device_manager(USER_PARAMS)
190+
ready_devices: list[RoborockDevice] = []
191+
device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append)
177192
devices = await device_manager.get_devices()
178193

179194
# The device should attempt to connect in the background at least once
@@ -184,6 +199,7 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock,
184199
# Device should exist but not be connected
185200
assert len(devices) == 1
186201
assert not devices[0].is_connected
202+
assert not ready_devices
187203

188204
# Verify retry attempts
189205
assert channel_failure.return_value.subscribe.call_count >= 1
@@ -203,6 +219,8 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock,
203219
assert attempts < 10, "Device did not connect after multiple attempts"
204220

205221
assert devices[0].is_connected
222+
assert ready_devices
223+
assert len(ready_devices) == 1
206224

207225
await device_manager.close()
208226
assert mock_unsub.call_count == 1

0 commit comments

Comments
 (0)