Skip to content

Commit 494c5b4

Browse files
allenporterCopilot
andauthored
feat: Add ability to listen for ready devices (#664)
* 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. * fix: Update roborock/devices/device.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: add more tests for already connected devices * chore: fix lint errors --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 06d051c commit 494c5b4

File tree

3 files changed

+59
-3
lines changed

3 files changed

+59
-3
lines changed

roborock/devices/device.py

Lines changed: 25 additions & 0 deletions
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
@@ -23,6 +24,7 @@
2324
_LOGGER = logging.getLogger(__name__)
2425

2526
__all__ = [
27+
"DeviceReadyCallback",
2628
"RoborockDevice",
2729
]
2830

@@ -33,6 +35,9 @@
3335
START_ATTEMPT_TIMEOUT = datetime.timedelta(seconds=5)
3436

3537

38+
DeviceReadyCallback = Callable[["RoborockDevice"], None]
39+
40+
3641
class RoborockDevice(ABC, TraitsMixin):
3742
"""A generic channel for establishing a connection with a Roborock device.
3843
@@ -67,6 +72,8 @@ def __init__(
6772
self._channel = channel
6873
self._connect_task: asyncio.Task[None] | None = None
6974
self._unsub: Callable[[], None] | None = None
75+
self._ready_callbacks = CallbackList["RoborockDevice"]()
76+
self._has_connected = False
7077

7178
@property
7279
def duid(self) -> str:
@@ -110,6 +117,22 @@ def is_local_connected(self) -> bool:
110117
"""
111118
return self._channel.is_local_connected
112119

120+
def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
121+
"""Add a callback to be notified when the device is ready.
122+
123+
A device is considered ready when it has successfully connected. It may go
124+
offline later, but this callback will only be called once when the device
125+
first connects.
126+
127+
The callback will be called immediately if the device has already previously
128+
connected.
129+
"""
130+
remove = self._ready_callbacks.add_callback(callback)
131+
if self._has_connected:
132+
callback(self)
133+
134+
return remove
135+
113136
async def start_connect(self) -> None:
114137
"""Start a background task to connect to the device.
115138
@@ -133,6 +156,8 @@ async def connect_loop() -> None:
133156
try:
134157
await self.connect()
135158
start_attempt.set()
159+
self._has_connected = True
160+
self._ready_callbacks(self)
136161
return
137162
except RoborockException as e:
138163
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: 26 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,30 @@ 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+
# Verify that adding a ready callback to an already connected device will
186+
# invoke the callback immediately.
187+
more_ready_device: list[RoborockDevice] = []
188+
device.add_ready_callback(more_ready_device.append)
189+
assert len(more_ready_device) == 1
190+
assert more_ready_device[0].duid == "abc123"
191+
192+
await device_manager.close()
193+
194+
174195
async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None:
175196
"""Test that start_connect retries when connection fails."""
176-
device_manager = await create_device_manager(USER_PARAMS)
197+
ready_devices: list[RoborockDevice] = []
198+
device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append)
177199
devices = await device_manager.get_devices()
178200

179201
# The device should attempt to connect in the background at least once
@@ -184,6 +206,7 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock,
184206
# Device should exist but not be connected
185207
assert len(devices) == 1
186208
assert not devices[0].is_connected
209+
assert not ready_devices
187210

188211
# Verify retry attempts
189212
assert channel_failure.return_value.subscribe.call_count >= 1
@@ -203,6 +226,8 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock,
203226
assert attempts < 10, "Device did not connect after multiple attempts"
204227

205228
assert devices[0].is_connected
229+
assert ready_devices
230+
assert len(ready_devices) == 1
206231

207232
await device_manager.close()
208233
assert mock_unsub.call_count == 1

0 commit comments

Comments
 (0)