feat: add get_paired_devices() to retrieve OS-level bonded BLE devices#1966
feat: add get_paired_devices() to retrieve OS-level bonded BLE devices#1966Vodur wants to merge 2 commits intohbldh:developfrom
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #1966 +/- ##
===========================================
- Coverage 51.24% 49.85% -1.40%
===========================================
Files 39 43 +4
Lines 3934 4080 +146
Branches 486 501 +15
===========================================
+ Hits 2016 2034 +18
- Misses 1791 1919 +128
Partials 127 127
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
There was a problem hiding this comment.
Pull request overview
Adds a new public BleakScanner.get_paired_devices() API (plus address/name helper methods) to fetch OS-level paired/bonded BLE devices without performing an active scan, with backend implementations for Windows (WinRT) and macOS (CoreBluetooth/IOBluetooth), and an example script.
Changes:
- Introduce
get_paired_devices(),get_paired_devices_by_address(), andget_paired_devices_by_name()on the base scanner and publicBleakScannerfacade. - Implement paired-device retrieval on Windows (WinRT) and macOS (IOBluetooth + CoreBluetooth peripheral resolution).
- Add macOS dependency (
pyobjc-framework-IOBluetooth) and a newexamples/paired_devices.pyhelper script.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pyproject.toml | Adds pyobjc-framework-IOBluetooth dependency for macOS paired-device enumeration. |
| examples/paired_devices.py | New example CLI to list/filter/connect to paired devices. |
| bleak/backends/scanner.py | Base class API + default filtering helpers for paired devices. |
| bleak/init.py | Public BleakScanner classmethods exposing paired-device APIs. |
| bleak/backends/winrt/scanner.py | WinRT implementation of get_paired_devices() using OS pairing state query. |
| bleak/backends/winrt/client.py | Allows WinRT client initialization from paired-device BLEDevice.details dict. |
| bleak/backends/corebluetooth/scanner.py | macOS implementation using IOBluetooth + CoreBluetooth peripheral lookup for connected devices. |
| bleak/backends/corebluetooth/client.py | Attempts CoreBluetooth retrievePeripherals(withIdentifiers:) for UUID-address connects; improves handling of paired-device BLEDevice.details. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "is_connected": True, | ||
| } | ||
| devices.append(BLEDevice(cb_uuid, name, details)) |
There was a problem hiding this comment.
On macOS, this branch uses the CoreBluetooth UUID (cb_uuid) as the BLEDevice.address for connected paired devices, while the else branch uses the MAC address string. This means get_paired_devices_by_address() (string equality match) won’t find a connected device by its Bluetooth/MAC address and callers may see a mixed address format within the same result set. Consider keeping BLEDevice.address consistent (e.g., always MAC, storing cb_uuid/peripheral in details, or always CB UUID and storing MAC in details) and documenting/normalizing the lookup behavior accordingly.
| "is_connected": True, | |
| } | |
| devices.append(BLEDevice(cb_uuid, name, details)) | |
| "cb_uuid": cb_uuid, | |
| "is_connected": True, | |
| } | |
| devices.append(BLEDevice(mac, name, details)) |
| """Find a single paired device by exact address. | ||
|
|
||
| Args: | ||
| address: The Bluetooth address to match (case-insensitive). | ||
|
|
There was a problem hiding this comment.
The docstring and parameter description say “Bluetooth address”, but on macOS Bleak commonly uses CoreBluetooth UUID strings as the device identifier/address. For consistency with find_device_by_address() docs, consider clarifying that this accepts a Bluetooth address or (on macOS) a UUID address, or renaming wording to “device identifier/address”.
| """Find a single paired device by exact Bluetooth address. | ||
|
|
||
| Args: | ||
| address: | ||
| The Bluetooth address to match (case-insensitive). |
There was a problem hiding this comment.
This docstring says “exact Bluetooth address”, but on macOS Bleak addresses are typically CoreBluetooth UUID strings (and get_paired_devices() may return those). Consider clarifying the accepted identifier format here (Bluetooth address vs macOS UUID) to avoid confusing API consumers.
| """Find a single paired device by exact Bluetooth address. | |
| Args: | |
| address: | |
| The Bluetooth address to match (case-insensitive). | |
| """Find a single paired device by its exact platform-specific identifier. | |
| Args: | |
| address: | |
| The device identifier to match (case-insensitive). This is | |
| typically a Bluetooth address on most platforms, but on macOS | |
| it may be a CoreBluetooth UUID string. |
| devices = await cls.get_paired_devices(**kwargs) | ||
| address_upper = address.upper() | ||
| for d in devices: | ||
| if d.address.upper() == address_upper: | ||
| return d |
There was a problem hiding this comment.
New filtering behavior is introduced here (case-insensitive exact match by address). Since the repo already has pytest/pytest-asyncio coverage, consider adding unit tests for get_paired_devices_by_address() / get_paired_devices_by_name() using a stub backend that returns a deterministic list, so the filter logic and casing behavior are covered without requiring OS Bluetooth state.
| parser.add_argument( | ||
| "--address", | ||
| metavar="<address>", | ||
| help="find a paired device by exact Bluetooth address", |
There was a problem hiding this comment.
The --address help text says “exact Bluetooth address”, but on macOS Bleak often identifies devices by CoreBluetooth UUIDs (and get_paired_devices() may return UUID strings for connected devices). Consider adjusting the CLI help/usage text to clarify what format to pass on macOS to avoid confusion when the filter doesn’t match.
| help="find a paired device by exact Bluetooth address", | |
| help="find a paired device by exact identifier (Bluetooth address on most platforms; CoreBluetooth UUID on macOS)", |
|
Thanks for getting this started. We'll also want automated tests for this and a changelog entry. And ideally Linux support. I've just had a quick look for now. I was wondering why we should use IOBluetooth instead of |
Hey David, Regarding the question about IOBluetooth vs retrievePeripheralsWithIdentifiers:. The latter requires you to already know the CoreBluetooth UUID of the device — it's a lookup by ID, not an enumeration API. CoreBluetooth has no way to list all paired/bonded devices. The only APIs it offers are:
IOBluetooth is the only macOS framework that can enumerate all paired Bluetooth devices. We then use retrieveConnectedPeripheralsWithServices + the undocumented retrieveAddressForPeripheral: to resolve CoreBluetooth UUIDs for connected devices, so the returned BLEDevice objects work directly with BleakClient. I'll work on adding:
|
|
It could also be easier to work on this if we split it up a bit. For example, we could just start with enumerating and "connecting" to already connected devices. Then handle already paired devices separately. |
|
That makes sense — I'll split this up. Phase 1: Enumerate already-connected BLE devices and allow connecting to them without scanning. This avoids the IOBluetooth dependency on macOS since Phase 2: Extend to paired-but-disconnected devices, which is where IOBluetooth becomes necessary on macOS (since CoreBluetooth has no enumeration API for paired devices). I'll rework this PR to just cover phase 1. Should I close this one and open a fresh PR, or force-push the reworked version here? |
I this case I'm OK with either way. |
7b5a07a to
52e6dde
Compare
@dlech Just one thing to to be straight forward, tested on Windows and can test on macOS on Sunday, would appreciate if someone could verify the Linux implementation. |
If there are tests, then CI should be checking it already. 😄 But sure, I can help with Linux if needed. |
14180af to
aaacc8f
Compare
| @pytest.mark.asyncio | ||
| async def test_get_connected_devices(sample_devices): | ||
| StubScanner._devices = sample_devices | ||
| devices = await BleakScanner.get_connected_devices(backend=StubScanner) |
There was a problem hiding this comment.
If we are using a stub scanner, then we aren't testing the actual backend implementation.
This should be an integration test that uses actual hardware and backend.
There was a problem hiding this comment.
True, you're right.
Integration testing this is tricky since it queries OS-level connection state, which Bumble doesn't simulate. Happy to hear suggestions - should this be a manual test for now, or is there a way to inject connected device state in the test infrastructure?
I can test manually on Windows, MacOS and Linux if this will suffice.
| if service_uuids: | ||
| cb_uuids = [CBUUID.UUIDWithString_(u) for u in service_uuids] | ||
| else: | ||
| cb_uuids = [CBUUID.UUIDWithString_("1800")] |
There was a problem hiding this comment.
I'm not sure if nil is accepted — I defaulted to Generic Access (0x1800) to be safe. If nil returns all connected peripherals, I'll remove the default.
Can test this on Sunday.
|
Tested on macOS — |
aaacc8f to
d2fcf73
Compare
|
@dlech I tested on all 3 platforms, did a manual test, all had the same mouse connected, the MX Master 2S, and it repeated finding it on the OS query level and connecting to it and getting services and characteristics, only on macOS |
d2fcf73 to
d02dea7
Compare
I think it would be better if it was the same on all platforms (so required on all platforms). And Making the default "Generic Attribute Profile" (hex 1801) would be a good idea too as this would filter out Bluetooth classic devices (particularly on Linux). |
| devices: list[BLEDevice] = [] | ||
| for i in range(device_info_collection.size): | ||
| device_info = device_info_collection.get_at(i) | ||
| ble_device = await BluetoothLEDevice.from_id_async(device_info.id) |
There was a problem hiding this comment.
Can we not get the address and name from the device_info?
There was a problem hiding this comment.
This code moved to bleak/backends/winrt/adapter.py. Kept from_id_async since DeviceInformation doesn't expose bluetooth_address directly without fragile property key lookups. Happy to change if you have a preferred approach.
…nnected BLE devices Add a new BleakAdapter class for OS-level adapter operations that don't require scanning. Currently provides get_connected_devices() to retrieve BLE devices that are already connected to the system. The returned BLEDevice objects can be passed directly to BleakClient to use the existing OS-level connection without scanning. Platform implementations: - Windows: Uses BluetoothLEDevice.get_device_selector_from_connection_status() with DeviceInformation.find_all_async() to enumerate connected devices. service_uuids is currently ignored since BluetoothLEDevice already excludes Bluetooth Classic devices. - macOS: Uses CBCentralManager.retrieveConnectedPeripheralsWithServices() to get connected peripherals filtered by service UUID. - Linux: Adds BlueZManager.get_connected_devices() to query D-Bus for devices with Connected=True on the default adapter, filtered by service UUID. service_uuids defaults to Generic Attribute Profile (0x1801), which is mandatory on all BLE devices and excludes Bluetooth Classic devices on Linux. Other backends raise NotImplementedError.
d02dea7 to
80f0511
Compare
I kept The WinRT AQS selector for connection status does not combine with service UUIDs in a single query. Filtering would require calling |
bcf6949 to
44f59c4
Compare
44f59c4 to
98b80b5
Compare
|
This macos-latest CI is rough haha. |
|
You can run the checks locally to speed things up. |
98b80b5 to
9cb99f8
Compare
|
So regarding codecov, I'll probably need some assistance @dlech. |
Thank you! This really helped me out, hopefully for future assistance with Bleak! |
9cb99f8 to
539b27d
Compare
BleakClientCoreBluetooth.connect() now attempts to retrieve the peripheral from the system registry using retrievePeripherals( withIdentifiers:) before falling back to an active scan. This allows connecting to bonded devices without scanning.
539b27d to
3605c47
Compare
Summary
Adds
BleakScanner.get_paired_devices(),get_paired_devices_by_address(), andget_paired_devices_by_name()to query OS-level paired/bonded BLE devices without scanning.BluetoothLEDevice.get_device_selector_from_pairing_state()withDeviceInformation.find_all_async(). ReturnedBLEDeviceobjects connect directly viaBleakClientwithout scanning.
IOBluetoothDevice.pairedDevices()via IOBluetooth framework. For connected devices, resolves CoreBluetooth peripherals usingretrieveConnectedPeripheralsWithServicesso
BleakClientcan connect without scanning.BleakClient.connect()also now triesretrievePeripherals(withIdentifiers:)for CB UUID addresses before falling back to scan.NotImplementedError.BLEDeviceincludesis_connectedin itsdetailsdict.Motivation
There is currently no way to retrieve already-paired BLE devices from the OS without scanning. Many applications need to reconnect to known devices or display pairing status.
Changes
bleak/backends/scanner.py— Base methods with default filter implementationsbleak/__init__.py— Public facade onBleakScannerbleak/backends/winrt/scanner.py— Windows implementationbleak/backends/winrt/client.py— Handle paired deviceBLEDevicein__init__bleak/backends/corebluetooth/scanner.py— macOS implementationbleak/backends/corebluetooth/client.py—retrievePeripherals(withIdentifiers:)+ paired device handlingpyproject.toml— Addpyobjc-framework-IOBluetoothdependencyexamples/paired_devices.py— Example with--name-filter,--address,--connectTest plan
python examples/paired_devices.pylists all paired devices with connection statuspython examples/paired_devices.py --name-filter "..."filters by namepython examples/paired_devices.py --address "XX:XX:XX:XX:XX:XX"finds by addresspython examples/paired_devices.py --name-filter "..." --connectconnects and lists servicesblack,isort,flake8