Skip to content

Commit

Permalink
BleakScanner: Add async iterator scanning capability
Browse files Browse the repository at this point in the history
Add `devices()` async iterator method to the `BleakScanner` which yields
results of the ongoing scan, together with its class-method counterpart
`scan()`.

Note about cleaning the `devices` queue in BleakScanner.devices():
It is safe to use `devices.get()` instead of `.get_nowait()` and
catching the QueueEmpty, as yield was the only consumer of the queue.
This means that a race condition between `.empty()` returning `False`
but queue being empty when `.get()` is called, consequently hanging
the program, is not possible.
  • Loading branch information
bojanpotocnik committed Jul 13, 2023
1 parent 2abe80d commit caa2484
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 13 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0
Changed
-------
- Improved error messages when failing to get services in WinRT backend.
- Added ``devices()`` async iterator method and ``scan()`` class-method counterpart to ``BleakScanner``.

Fixed
-----
- Fix handling all access denied errors when enumerating characteristics on Windows. Fixes #1291.
- Added support for 32bit UUIDs. Fixes #1314
- Added support for 32bit UUIDs. Fixes #1314.

`0.20.2`_ (2023-04-19)
======================
Expand Down
66 changes: 54 additions & 12 deletions bleak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Type,
Union,
overload,
AsyncIterator,
)
from warnings import warn

Expand Down Expand Up @@ -204,6 +205,52 @@ def set_scanning_filter(self, **kwargs):
)
self._backend.set_scanning_filter(**kwargs)

async def devices(self) -> AsyncIterator[Tuple[BLEDevice, AdvertisementData]]:
"""
Yields devices and their advertising data as they are discovered.
.. note::
Ensure that scanning is started before calling this method.
Returns:
An async iterator that yields tuples (:class:`BLEDevice`, :class:`AdvertisementData`).
"""
if self._backend.detection_callback:
raise BleakError(
"Cannot use async iterator methods when detection_callback is used"
)

devices = asyncio.Queue()

async def detection_callback(bd, ad):
await devices.put((bd, ad))

self._backend.register_detection_callback(detection_callback)
try:
while True:
yield await devices.get()
finally:
self._backend.register_detection_callback(None)
while not devices.empty():
await devices.get()

@classmethod
async def scan(cls, **kwargs) -> AsyncIterator[Tuple[BLEDevice, AdvertisementData]]:
"""
Continuously scans for devices and yields them.
Args:
**kwargs:
Additional arguments to be passed to the :class:`BleakScanner`
constructor.
Returns:
An async iterator that yields tuples (:class:`BLEDevice`, :class:`AdvertisementData`).
"""
async with cls(**kwargs) as scanner:
async for result in scanner.devices():
yield result

@overload
@classmethod
async def discover(
Expand Down Expand Up @@ -360,18 +407,13 @@ async def find_device_by_filter(
the timeout.
"""
found_device_queue: asyncio.Queue[BLEDevice] = asyncio.Queue()

def apply_filter(d: BLEDevice, ad: AdvertisementData):
if filterfunc(d, ad):
found_device_queue.put_nowait(d)

async with cls(detection_callback=apply_filter, **kwargs):
try:
async with async_timeout(timeout):
return await found_device_queue.get()
except asyncio.TimeoutError:
return None
try:
async with async_timeout(timeout):
async for bd, ad in cls.scan(**kwargs):
if filterfunc(bd, ad):
return bd
except asyncio.TimeoutError:
return None


class BleakClient:
Expand Down
5 changes: 5 additions & 0 deletions bleak/backends/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ def __init__(

self.seen_devices = {}

@property
def detection_callback(self) -> Optional[AdvertisementDataCallback]:
"""Get the callback provided to :meth:`__init__` or registered with :meth:`register_detection_callback`"""
return self._callback

def register_detection_callback(
self, callback: Optional[AdvertisementDataCallback]
) -> None:
Expand Down
37 changes: 37 additions & 0 deletions examples/scan_iterator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Scan/Discovery Async Iterator
--------------
Example showing how to scan for BLE devices using async iterator instead of callback function
Created on 2023-07-07 by bojanpotocnik <info@bojanpotocnik.com>
"""
import asyncio

from bleak import BleakScanner


async def main():
# The two methods below (using class method .scan() or instance method .devices()) are equivalent

x = 5
print(f"Scanning for {x} devices...")
async with BleakScanner() as scanner:
async for bd, ad in scanner.devices():
print(f"{x}. {bd!r} with {ad!r}")
x -= 1
if x == 0:
break

x = 6
print(f"\nScanning for a device with name longer than {x} characters...")
async for bd, ad in BleakScanner.scan():
found = len(bd.name or "") > x or len(ad.local_name or "") > x
print(f"Found{' it' if found else ''} {bd!r} with {ad!r}")
if found:
break


if __name__ == "__main__":
asyncio.run(main())

0 comments on commit caa2484

Please sign in to comment.