Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Added
-----
* Added ``BleakAdapter`` class with ``get_connected_devices()`` to retrieve BLE devices that are already connected to the system without scanning.

Fixed
-----
* ``BleakClientBlueZDBus.disconnect()`` no longer disconnects from the device when the :class:`BLEDevice` was retrieved from :meth:`BleakAdapter.get_connected_devices`.

`3.0.2`_ (2026-05-02)
=====================

Expand Down
10 changes: 9 additions & 1 deletion bleak/backends/bluezdbus/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ async def get_connected_devices(
if props["Alias"] == props["Address"].replace(":", "-")
else props["Alias"]
)
devices.append(BLEDevice(address, name, {"path": path, "props": props}))
# "from_connected_devices" marker: BleakClientBlueZDBus uses it
# to skip the BlueZ Disconnect call when this client is closed.
devices.append(
BLEDevice(
address,
name,
{"path": path, "props": props, "from_connected_devices": True},
)
)

return devices
49 changes: 37 additions & 12 deletions bleak/backends/bluezdbus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,14 @@ def __init__(
if isinstance(address_or_ble_device, BLEDevice):
self._device_path = address_or_ble_device.details["path"]
self._device_info = address_or_ble_device.details.get("props")
# Set by BleakAdapter.get_connected_devices(); see connect().
self._opted_into_existing_connection = bool(
address_or_ble_device.details.get("from_connected_devices", False)
)
else:
self._device_path = None
self._device_info = None
self._opted_into_existing_connection = False

self._requested_services = services

Expand All @@ -90,6 +95,9 @@ def __init__(
self._remove_device_watcher: Optional[Callable[[], None]] = None
# private backing for is_connected property
self._is_connected = False
# True if this BleakClient initiated the BlueZ connection. Set in
# connect(); gates the BlueZ Disconnect call in disconnect().
self._owns_connection = False
# indicates disconnect request in progress when not None
self._disconnecting_event: Optional[asyncio.Event] = None
# used to ensure device gets disconnected if event loop crashes
Expand Down Expand Up @@ -210,7 +218,7 @@ async def disconnect_device() -> None:
# if connection was successful but _get_services() raises (e.g.
# because task was cancelled), then we still need to disconnect
# before passing on the exception.
if self._bus:
if self._bus and self._owns_connection:
# If disconnected callback already fired, this will be a no-op
# since self._bus will be None and the _cleanup_all call will
# have already disconnected.
Expand Down Expand Up @@ -250,8 +258,13 @@ async def disconnect_device() -> None:
'skipping calling "Connect" since %s is already connected',
self._device_path,
)
# Only treat the connection as not-ours if the user
# explicitly opted in. Otherwise it is an orphan from
# a previous run and we should clean it up on close.
self._owns_connection = not self._opted_into_existing_connection
else:
logger.debug("Connecting to BlueZ path %s", self._device_path)
self._owns_connection = True

# Calling pair will fail if we are already paired, so
# in that case we just call Connect.
Expand Down Expand Up @@ -405,19 +418,31 @@ async def disconnect(self) -> None:
self._disconnecting_event = asyncio.Event()
try:
if self.is_connected:
# Try to disconnect the actual device/peripheral
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
path=self._device_path,
interface=defs.DEVICE_INTERFACE,
member="Disconnect",
if self._owns_connection:
# Try to disconnect the actual device/peripheral
reply = await self._bus.call(
Message(
destination=defs.BLUEZ_SERVICE,
path=self._device_path,
interface=defs.DEVICE_INTERFACE,
member="Disconnect",
)
)
)
assert_reply(reply)
assert_reply(reply)

async with async_timeout(10):
await self._disconnecting_event.wait()
async with async_timeout(10):
await self._disconnecting_event.wait()
else:
# Connection was opted into; leave it up. Mirror the
# cleanup that on_connected_changed would do, except
# for _disconnected_callback (the device is not
# actually disconnecting from BlueZ's perspective).
self._is_connected = False
if self._disconnect_monitor_event:
self._disconnect_monitor_event.set()
self._disconnect_monitor_event = None
self._cleanup_all()
self._disconnecting_event.set()
finally:
self._disconnecting_event = None

Expand Down
19 changes: 19 additions & 0 deletions tests/integration/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,22 @@ async def test_get_connected_devices_filters_by_service_uuid(

not_connected = await adapter.get_connected_devices([OTHER_SERVICE_UUID])
assert not_connected == []


@pytest.mark.asyncio(loop_scope="module")
async def test_inner_client_does_not_disconnect_outer(
connected_peripheral: ConnectedPeripheral,
) -> None:
"""Inner BleakClient using a device from get_connected_devices() must not
disconnect the outer client. See https://github.com/bluez/bluez/issues/89.
"""
adapter = await BleakAdapter.get()
connected_devices = await adapter.get_connected_devices([TEST_SERVICE_UUID])
inner_target = next(
d for d in connected_devices if d.address == connected_peripheral.device.address
)

async with BleakClient(inner_target) as inner_client:
assert inner_client.is_connected

assert connected_peripheral.client.is_connected
Loading