Skip to content

feat: add get_paired_devices() to retrieve OS-level bonded BLE devices#1966

Open
Vodur wants to merge 2 commits intohbldh:developfrom
Vodur:feature/get-paired-devices
Open

feat: add get_paired_devices() to retrieve OS-level bonded BLE devices#1966
Vodur wants to merge 2 commits intohbldh:developfrom
Vodur:feature/get-paired-devices

Conversation

@Vodur
Copy link
Copy Markdown

@Vodur Vodur commented Apr 9, 2026

Summary

Adds BleakScanner.get_paired_devices(), get_paired_devices_by_address(), and get_paired_devices_by_name() to query OS-level paired/bonded BLE devices without scanning.

  • Windows: Uses BluetoothLEDevice.get_device_selector_from_pairing_state() with DeviceInformation.find_all_async(). Returned BLEDevice objects connect directly via BleakClient
    without scanning.
  • macOS: Uses IOBluetoothDevice.pairedDevices() via IOBluetooth framework. For connected devices, resolves CoreBluetooth peripherals using retrieveConnectedPeripheralsWithServices
    so BleakClient can connect without scanning. BleakClient.connect() also now tries retrievePeripherals(withIdentifiers:) for CB UUID addresses before falling back to scan.
  • Other backends get a default NotImplementedError.
  • Each returned BLEDevice includes is_connected in its details dict.

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 implementations
  • bleak/__init__.py — Public facade on BleakScanner
  • bleak/backends/winrt/scanner.py — Windows implementation
  • bleak/backends/winrt/client.py — Handle paired device BLEDevice in __init__
  • bleak/backends/corebluetooth/scanner.py — macOS implementation
  • bleak/backends/corebluetooth/client.pyretrievePeripherals(withIdentifiers:) + paired device handling
  • pyproject.toml — Add pyobjc-framework-IOBluetooth dependency
  • examples/paired_devices.py — Example with --name-filter, --address, --connect

Test plan

  • python examples/paired_devices.py lists all paired devices with connection status
  • python examples/paired_devices.py --name-filter "..." filters by name
  • python examples/paired_devices.py --address "XX:XX:XX:XX:XX:XX" finds by address
  • python examples/paired_devices.py --name-filter "..." --connect connects and lists services
  • Tested on Windows 11 and macOS
  • Passes black, isort, flake8

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 12.58278% with 132 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.85%. Comparing base (c9fa119) to head (3605c47).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
bleak/backends/winrt/adapter.py 0.00% 29 Missing ⚠️
bleak/backends/bluezdbus/adapter.py 0.00% 28 Missing ⚠️
bleak/backends/corebluetooth/adapter.py 0.00% 28 Missing ⚠️
bleak/backends/bluezdbus/manager.py 5.88% 16 Missing ⚠️
bleak/backends/adapter.py 42.30% 15 Missing ⚠️
bleak/backends/corebluetooth/client.py 11.11% 8 Missing ⚠️
bleak/__init__.py 46.15% 7 Missing ⚠️
bleak/backends/winrt/client.py 0.00% 1 Missing ⚠️
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              
Flag Coverage Δ
bluez-integration-py3.10 ?
bluez-integration-py3.11 37.47% <11.92%> (-0.94%) ⬇️
bluez-integration-py3.12 37.47% <11.92%> (-0.94%) ⬇️
bluez-integration-py3.13 37.47% <11.92%> (-0.94%) ⬇️
bluez-integration-py3.14 35.93% <11.92%> (-0.90%) ⬇️
macos-latest-py3.10 19.58% <11.92%> (-0.30%) ⬇️
macos-latest-py3.11 19.58% <11.92%> (-0.30%) ⬇️
macos-latest-py3.12 19.58% <11.92%> (-0.30%) ⬇️
macos-latest-py3.13 19.58% <11.92%> (-0.30%) ⬇️
macos-latest-py3.14 19.40% <11.92%> (-0.30%) ⬇️
ubuntu-latest-py3.10 23.45% <11.92%> (-0.42%) ⬇️
ubuntu-latest-py3.11 23.45% <11.92%> (-0.42%) ⬇️
ubuntu-latest-py3.12 23.45% <11.92%> (-0.42%) ⬇️
ubuntu-latest-py3.13 23.45% <11.92%> (-0.42%) ⬇️
ubuntu-latest-py3.14 21.56% <11.92%> (-0.36%) ⬇️
windows-latest-py3.10 18.21% <11.25%> (-0.25%) ⬇️
windows-latest-py3.11 18.21% <11.25%> (-0.25%) ⬇️
windows-latest-py3.12 18.21% <11.25%> (-0.25%) ⬇️
windows-latest-py3.13 18.21% <11.25%> (-0.25%) ⬇️
windows-latest-py3.14 17.94% <11.25%> (-0.24%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), and get_paired_devices_by_name() on the base scanner and public BleakScanner facade.
  • Implement paired-device retrieval on Windows (WinRT) and macOS (IOBluetooth + CoreBluetooth peripheral resolution).
  • Add macOS dependency (pyobjc-framework-IOBluetooth) and a new examples/paired_devices.py helper 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.

Comment thread bleak/backends/corebluetooth/scanner.py Outdated
Comment on lines +232 to +234
"is_connected": True,
}
devices.append(BLEDevice(cb_uuid, name, details))
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"is_connected": True,
}
devices.append(BLEDevice(cb_uuid, name, details))
"cb_uuid": cb_uuid,
"is_connected": True,
}
devices.append(BLEDevice(mac, name, details))

Copilot uses AI. Check for mistakes.
Comment thread bleak/backends/scanner.py Outdated
Comment on lines +298 to +302
"""Find a single paired device by exact address.

Args:
address: The Bluetooth address to match (case-insensitive).

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”.

Copilot uses AI. Check for mistakes.
Comment thread bleak/__init__.py Outdated
Comment on lines +461 to +465
"""Find a single paired device by exact Bluetooth address.

Args:
address:
The Bluetooth address to match (case-insensitive).
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"""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.

Copilot uses AI. Check for mistakes.
Comment thread bleak/backends/scanner.py Outdated
Comment on lines +306 to +310
devices = await cls.get_paired_devices(**kwargs)
address_upper = address.upper()
for d in devices:
if d.address.upper() == address_upper:
return d
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread examples/paired_devices.py Outdated
parser.add_argument(
"--address",
metavar="<address>",
help="find a paired device by exact Bluetooth address",
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)",

Copilot uses AI. Check for mistakes.
@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 9, 2026

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 retrievePeripheralsWithIdentifiers:. Does the latter remember all devices that have ever been connected even if they were not paired/bonded?

@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 9, 2026

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 retrievePeripheralsWithIdentifiers:. Does the latter remember all devices that have ever been connected even if they were not paired/bonded?

Hey David,
I started this locally to help with my project, and then I thought why not share with everyone.

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:

  • scanForPeripheralsWithServices: — active scan (requires advertising)
  • retrieveConnectedPeripheralsWithServices: — only currently-connected peripherals
  • retrievePeripheralsWithIdentifiers: — lookup by known UUID (not enumeration)

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:

  1. Linux (BlueZ) backend support
  2. Automated tests
  3. Changelog entry

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 9, 2026

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.

@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 10, 2026

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
retrieveConnectedPeripheralsWithServices: handles the connected case natively in CoreBluetooth.

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?

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 10, 2026

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.

@Vodur Vodur force-pushed the feature/get-paired-devices branch from 7b5a07a to 52e6dde Compare April 10, 2026 14:13
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 10, 2026

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.

@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.

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 10, 2026

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.

@Vodur Vodur force-pushed the feature/get-paired-devices branch 2 times, most recently from 14180af to aaacc8f Compare April 10, 2026 15:59
Comment thread tests/test_connected_devices.py Outdated
@pytest.mark.asyncio
async def test_get_connected_devices(sample_devices):
StubScanner._devices = sample_devices
devices = await BleakScanner.get_connected_devices(backend=StubScanner)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

@Vodur Vodur Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.rst
Comment thread CHANGELOG.rst Outdated
Comment thread bleak/backends/corebluetooth/scanner.py Outdated
if service_uuids:
cb_uuids = [CBUUID.UUIDWithString_(u) for u in service_uuids]
else:
cb_uuids = [CBUUID.UUIDWithString_("1800")]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't allow None?

Copy link
Copy Markdown
Author

@Vodur Vodur Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bleak/backends/corebluetooth/scanner.py Outdated
Comment thread bleak/backends/bluezdbus/scanner.py Outdated
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 12, 2026

Tested on macOS — None crashes with NSInternalInconsistencyException: Invalid parameter not satisfying: serviceUUIDs != nil. Empty array returns nothing. Even 0x1800 (Generic Access) returned nothing for a connected MX Master 2S, while 0x180F (Battery Service) found it. So service_uuids is now required on macOS and raises BleakError if not provided.

@Vodur Vodur force-pushed the feature/get-paired-devices branch from aaacc8f to d2fcf73 Compare April 12, 2026 05:56
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 12, 2026

@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 service_uuids was passed as a parameter:
devices = await BleakScanner.get_connected_devices(service_uuids=["180F"])
or
devices = await BleakScanner.get_connected_devices(service_uuids=["180F", "180A", "FFF0"])

@Vodur Vodur force-pushed the feature/get-paired-devices branch from d2fcf73 to d02dea7 Compare April 12, 2026 19:05
@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 12, 2026

So service_uuids is now required on macOS and raises BleakError if not provided.

I think it would be better if it was the same on all platforms (so required on all platforms). And ValueError would probably make more sense. BleakError is generally for runtime errors rather than invalid arguments.

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).

Comment thread bleak/backends/bluezdbus/manager.py
Comment thread bleak/backends/corebluetooth/client.py Outdated
Comment thread bleak/backends/corebluetooth/client.py
Comment thread bleak/__init__.py Outdated
Comment thread bleak/__init__.py
Comment thread bleak/backends/corebluetooth/scanner.py Outdated
Comment thread bleak/backends/winrt/scanner.py Outdated
Comment thread bleak/backends/winrt/scanner.py Outdated
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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not get the address and name from the device_info?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bleak/backends/winrt/client.py Outdated
Comment thread CHANGELOG.rst Outdated
Comment thread typings/Foundation/__init__.pyi Outdated
…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.
@Vodur Vodur force-pushed the feature/get-paired-devices branch from d02dea7 to 80f0511 Compare April 13, 2026 06:39
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 13, 2026

Can we not get the address and name from the device_info?

I kept BluetoothLEDevice.from_id_async() since DeviceInformation does not expose bluetooth_address directly without parsing the device ID string or using fragile property keys. Happy to switch if there is a preferred approach.

The WinRT AQS selector for connection status does not combine with service UUIDs in a single query. Filtering would require calling get_gatt_services_async() per device. Since BluetoothLEDevice already excludes Bluetooth Classic, I left service_uuids as a no-op on Windows (documented in the code). Let me know if you want strict filtering even at the cost of an extra async call per device.

@Vodur Vodur force-pushed the feature/get-paired-devices branch 3 times, most recently from bcf6949 to 44f59c4 Compare April 13, 2026 15:28
Comment thread typings/Foundation/__init__.pyi Outdated
Comment thread bleak/backends/corebluetooth/client.py Outdated
@Vodur Vodur force-pushed the feature/get-paired-devices branch from 44f59c4 to 98b80b5 Compare April 13, 2026 15:35
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 13, 2026

This macos-latest CI is rough haha.

@dlech
Copy link
Copy Markdown
Collaborator

dlech commented Apr 13, 2026

You can run the checks locally to speed things up.

uv tool install pyright
uv run pyright
uv tool install mypy
uv run mypy -p bleak -p tests -p examples

@Vodur Vodur force-pushed the feature/get-paired-devices branch from 98b80b5 to 9cb99f8 Compare April 13, 2026 16:20
@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 13, 2026

So regarding codecov, I'll probably need some assistance @dlech.
If it's because of integration tests, so we can't actually have any since this needs to test hardware, I did the manual tests on the same MX Master 2S which worked on all Windows, MacOS and Linux.
I also had Bluetooth earphones connected for the test, but they are a Bluetooth Classic device so it wasn't even listed, which is great.

@Vodur
Copy link
Copy Markdown
Author

Vodur commented Apr 14, 2026

You can run the checks locally to speed things up.


uv tool install pyright

uv run pyright

uv tool install mypy

uv run mypy -p bleak -p tests -p examples

Thank you! This really helped me out, hopefully for future assistance with Bleak!

@Vodur Vodur force-pushed the feature/get-paired-devices branch from 9cb99f8 to 539b27d Compare April 18, 2026 13:49
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.
@Vodur Vodur force-pushed the feature/get-paired-devices branch from 539b27d to 3605c47 Compare April 18, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants