Skip to content

Commit 047e18b

Browse files
committed
backends/scanner: add handle_early_stop hooks
All supported platforms provide some sort of hook that we can use to get a callback when scanning stops. On Mac and Linux, there is only a simple boolean value indicating if scanning is currently in progress. Windows and Android provide additional error information, but this is ignored for now since it isn't available cross-platform. What we actually do with this hook is not yet implemented.
1 parent b39beec commit 047e18b

File tree

8 files changed

+92
-14
lines changed

8 files changed

+92
-14
lines changed

bleak/backends/bluezdbus/advertisement_monitor.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
import logging
10-
from typing import Iterable, NamedTuple, Tuple, Union, no_type_check
10+
from typing import Callable, Iterable, NamedTuple, Tuple, Union, no_type_check
1111

1212
from dbus_fast.service import ServiceInterface, dbus_property, method, PropertyAccess
1313

@@ -34,6 +34,9 @@ class OrPattern(NamedTuple):
3434
OrPatternLike = Union[OrPattern, Tuple[int, AdvertisementDataType, bytes]]
3535

3636

37+
ReleasedCallback = Callable[[], None]
38+
39+
3740
class AdvertisementMonitor(ServiceInterface):
3841
"""
3942
Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface.
@@ -49,21 +52,24 @@ class AdvertisementMonitor(ServiceInterface):
4952
"""
5053

5154
def __init__(
52-
self,
53-
or_patterns: Iterable[OrPatternLike],
55+
self, or_patterns: Iterable[OrPatternLike], released_callback: ReleasedCallback
5456
):
5557
"""
5658
Args:
5759
or_patterns:
5860
List of or patterns that will be returned by the ``Patterns`` property.
61+
released_callback:
62+
A callback that is called when the D-bus "Release" method is called.
5963
"""
6064
super().__init__(defs.ADVERTISEMENT_MONITOR_INTERFACE)
6165
# dbus_fast marshaling requires list instead of tuple
6266
self._or_patterns = [list(p) for p in or_patterns]
67+
self._released_callback = released_callback
6368

6469
@method()
6570
def Release(self):
6671
logger.debug("Release")
72+
self._released_callback()
6773

6874
@method()
6975
def Activate(self):

bleak/backends/bluezdbus/manager.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ class DeviceRemovedCallbackAndState(NamedTuple):
9191
"""
9292

9393

94+
DiscoveryStoppedCallback = Callable[[], None]
95+
96+
97+
class DiscoveryStoppedCallbackAndState(NamedTuple):
98+
callback: DiscoveryStoppedCallback
99+
adapter_path: str
100+
101+
94102
DeviceConnectedChangedCallback = Callable[[bool], None]
95103
"""
96104
A callback that is called when a device's "Connected" property changes.
@@ -167,6 +175,7 @@ def __init__(self):
167175

168176
self._advertisement_callbacks: List[CallbackAndState] = []
169177
self._device_removed_callbacks: List[DeviceRemovedCallbackAndState] = []
178+
self._discovery_stopped_callbacks: List[DiscoveryStoppedCallbackAndState] = []
170179
self._device_watchers: Set[DeviceWatcher] = set()
171180
self._condition_callbacks: Set[Callable] = set()
172181
self._services_cache: Dict[str, BleakGATTServiceCollection] = {}
@@ -312,6 +321,7 @@ async def active_scan(
312321
filters: Dict[str, Variant],
313322
advertisement_callback: AdvertisementCallback,
314323
device_removed_callback: DeviceRemovedCallback,
324+
discovery_stopped_callback: DiscoveryStoppedCallback,
315325
) -> Callable[[], Coroutine]:
316326
"""
317327
Configures the advertisement data filters and starts scanning.
@@ -323,6 +333,9 @@ async def active_scan(
323333
A callable that will be called when new advertisement data is received.
324334
device_removed_callback:
325335
A callable that will be called when a device is removed from BlueZ.
336+
discovery_stopped_callback:
337+
A callable that will be called if discovery is stopped early
338+
(before stop was requested by calling the return value).
326339
327340
Returns:
328341
An async function that is used to stop scanning and remove the filters.
@@ -342,6 +355,13 @@ async def active_scan(
342355
)
343356
self._device_removed_callbacks.append(device_removed_callback_and_state)
344357

358+
discovery_stopped_callback_and_state = DiscoveryStoppedCallbackAndState(
359+
discovery_stopped_callback, adapter_path
360+
)
361+
self._discovery_stopped_callbacks.append(
362+
discovery_stopped_callback_and_state
363+
)
364+
345365
try:
346366
# Apply the filters
347367
reply = await self._bus.call(
@@ -375,6 +395,9 @@ async def stop() -> None:
375395
self._device_removed_callbacks.remove(
376396
device_removed_callback_and_state
377397
)
398+
self._discovery_stopped_callbacks.remove(
399+
discovery_stopped_callback_and_state
400+
)
378401

379402
async with self._bus_lock:
380403
reply = await self._bus.call(
@@ -413,6 +436,7 @@ async def passive_scan(
413436
filters: List[OrPatternLike],
414437
advertisement_callback: AdvertisementCallback,
415438
device_removed_callback: DeviceRemovedCallback,
439+
discovery_stopped_callback: DiscoveryStoppedCallback,
416440
) -> Callable[[], Coroutine]:
417441
"""
418442
Configures the advertisement data filters and starts scanning.
@@ -444,7 +468,7 @@ async def passive_scan(
444468
self._device_removed_callbacks.append(device_removed_callback_and_state)
445469

446470
try:
447-
monitor = AdvertisementMonitor(filters)
471+
monitor = AdvertisementMonitor(filters, discovery_stopped_callback)
448472

449473
# this should be a unique path to allow multiple python interpreters
450474
# running bleak and multiple scanners within a single interpreter
@@ -828,7 +852,14 @@ def _parse_msg(self, message: Message):
828852
# then call any callbacks so they will be called with the
829853
# updated state
830854

831-
if interface == defs.DEVICE_INTERFACE:
855+
if interface == defs.ADAPTER_INTERFACE:
856+
if "Discovering" in changed and not self_interface["Discovering"]:
857+
for (
858+
discovery_stopped_callback,
859+
_,
860+
) in self._discovery_stopped_callbacks:
861+
discovery_stopped_callback()
862+
elif interface == defs.DEVICE_INTERFACE:
832863
# handle advertisement watchers
833864

834865
self._run_advertisement_callbacks(

bleak/backends/bluezdbus/scanner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,15 @@ async def start(self):
186186
self._or_patterns,
187187
self._handle_advertising_data,
188188
self._handle_device_removed,
189+
self.handle_early_stop,
189190
)
190191
else:
191192
self._stop = await manager.active_scan(
192193
adapter_path,
193194
self._filters,
194195
self._handle_advertising_data,
195196
self._handle_device_removed,
197+
self.handle_early_stop,
196198
)
197199

198200
async def stop(self):

bleak/backends/corebluetooth/CentralManagerDelegate.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import logging
1111
import sys
1212
import threading
13-
from typing import Any, Callable, Dict, Optional
13+
from typing import Any, Callable, Dict, Iterable, Optional
1414

1515
if sys.version_info < (3, 11):
1616
from async_timeout import timeout as async_timeout
@@ -49,6 +49,7 @@
4949

5050

5151
DisconnectCallback = Callable[[], None]
52+
ScanningStoppedCallback = Callable[[], None]
5253

5354

5455
class CentralManagerDelegate(NSObject):
@@ -93,6 +94,8 @@ def init(self) -> Optional["CentralManagerDelegate"]:
9394
if self.central_manager.state() != CBManagerStatePoweredOn:
9495
raise BleakError("Bluetooth device is turned off")
9596

97+
self._scanning_stopped_callback: Optional[ScanningStoppedCallback]
98+
9699
# isScanning property was added in 10.13
97100
if objc.macos_available(10, 13):
98101
self.central_manager.addObserver_forKeyPath_options_context_(
@@ -110,7 +113,11 @@ def __del__(self):
110113
# User defined functions
111114

112115
@objc.python_method
113-
async def start_scan(self, service_uuids) -> None:
116+
async def start_scan(
117+
self,
118+
service_uuids: Iterable[str],
119+
scanning_stopped_callback: ScanningStoppedCallback,
120+
) -> None:
114121
service_uuids = (
115122
NSArray.alloc().initWithArray_(
116123
list(map(CBUUID.UUIDWithString_, service_uuids))
@@ -133,8 +140,11 @@ async def start_scan(self, service_uuids) -> None:
133140
else:
134141
await asyncio.sleep(0.1)
135142

143+
self._scanning_stopped_callback = scanning_stopped_callback
144+
136145
@objc.python_method
137146
async def stop_scan(self) -> None:
147+
self._scanning_stopped_callback = None
138148
self.central_manager.stopScan()
139149

140150
# The `isScanning` property was added in macOS 10.13, so before that
@@ -199,11 +209,13 @@ def _changed_is_scanning(self, is_scanning: bool) -> None:
199209
else:
200210
if self._did_stop_scanning_event:
201211
self._did_stop_scanning_event.set()
212+
if self._scanning_stopped_callback:
213+
self._scanning_stopped_callback()
202214

203215
def observeValueForKeyPath_ofObject_change_context_(
204216
self, keyPath: NSString, object: Any, change: NSDictionary, context: int
205217
) -> None:
206-
logger.debug("'%s' changed", keyPath)
218+
logger.debug("'%s' changed: %r", keyPath, change)
207219

208220
if keyPath != "isScanning":
209221
return

bleak/backends/corebluetooth/scanner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None:
152152
self._callback(device, advertisement_data)
153153

154154
self._manager.callbacks[id(self)] = callback
155-
await self._manager.start_scan(self._service_uuids)
155+
await self._manager.start_scan(self._service_uuids, self.handle_early_stop)
156156

157157
async def stop(self):
158158
await self._manager.stop_scan()

bleak/backends/p4android/scanner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ def handle_permissions(permissions, grantResults):
140140
)
141141
self.__javascanner.flushPendingScanResults(self.__callback.java)
142142

143+
# REVISIT: we shouldn't wait and check for error here, instead we should
144+
# just allow the stopped early callback to handle this and let the user
145+
# decide what to do.
143146
try:
144147
async with async_timeout(0.2):
145148
await scanfuture
@@ -292,6 +295,7 @@ def result_state(self, status_str, name, *data):
292295

293296
@java_method("(I)V")
294297
def onScanFailed(self, errorCode):
298+
self._loop.call_soon_threadsafe(self._scanner.handle_early_stop)
295299
self.result_state(defs.ScanFailed(errorCode).name, "onScan")
296300

297301
@java_method("(Landroid/bluetooth/le/ScanResult;)V")

bleak/backends/scanner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ def create_or_update_device(
212212

213213
return device
214214

215+
def handle_early_stop(self) -> None:
216+
...
217+
215218
@abc.abstractmethod
216219
async def start(self):
217220
"""Start scanning for devices"""

bleak/backends/winrt/scanner.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import asyncio
2+
import functools
23
import logging
34
import sys
45
from typing import Dict, List, NamedTuple, Optional
56
from uuid import UUID
67

78
from bleak_winrt.windows.devices.bluetooth.advertisement import (
8-
BluetoothLEScanningMode,
9-
BluetoothLEAdvertisementWatcher,
109
BluetoothLEAdvertisementReceivedEventArgs,
1110
BluetoothLEAdvertisementType,
11+
BluetoothLEAdvertisementWatcher,
12+
BluetoothLEAdvertisementWatcherStatus,
13+
BluetoothLEAdvertisementWatcherStoppedEventArgs,
14+
BluetoothLEScanningMode,
1215
)
1316

1417
if sys.version_info[:2] < (3, 8):
@@ -222,12 +225,29 @@ def _received_handler(
222225

223226
self._callback(device, advertisement_data)
224227

225-
def _stopped_handler(self, sender, e):
228+
def _handle_stopped_threadsafe(
229+
self,
230+
loop: asyncio.AbstractEventLoop,
231+
sender: BluetoothLEAdvertisementWatcher,
232+
e: BluetoothLEAdvertisementWatcherStoppedEventArgs,
233+
) -> None:
234+
logger.debug("watcher status: %s, error: %s", sender.status.name, e.error.name)
235+
236+
loop.call_soon_threadsafe(
237+
self._handle_stopped,
238+
sender.status == BluetoothLEAdvertisementWatcherStatus.ABORTED,
239+
)
240+
241+
def _handle_stopped(self, from_error: bool) -> None:
226242
logger.debug(
227243
"{0} devices found. Watcher status: {1}.".format(
228-
len(self.seen_devices), self.watcher.status
244+
len(self.seen_devices), self.watcher.status.name
229245
)
230246
)
247+
248+
if from_error:
249+
self.handle_early_stop()
250+
231251
self._stopped_event.set()
232252

233253
async def start(self):
@@ -245,7 +265,7 @@ async def start(self):
245265
lambda s, e: event_loop.call_soon_threadsafe(self._received_handler, s, e)
246266
)
247267
self._stopped_token = self.watcher.add_stopped(
248-
lambda s, e: event_loop.call_soon_threadsafe(self._stopped_handler, s, e)
268+
functools.partial(self._handle_stopped_threadsafe, event_loop)
249269
)
250270

251271
if self._signal_strength_filter is not None:

0 commit comments

Comments
 (0)