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
-----
* Fixed handling empty notification payloads in BlueZ backend when using "AcquireNotify". Fixes #1982.

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

Expand Down
14 changes: 13 additions & 1 deletion bleak/backends/bluezdbus/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
BLE Client for BlueZ on Linux
"""

import select
import sys
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -887,12 +888,23 @@ def _register_notify_fd_reader(
self, char_path: str, fd: int, callback: NotifyCallback
) -> None:
loop = asyncio.get_running_loop()
poll = select.poll()

def on_data():
try:
data = os.read(fd, 1024)

if not data:
raise RuntimeError("Unexpected EOF on notification file handle")
# Empty data could mean that the socket was closed or it
# could be a valid notification with an empty value. In
# order to tell them apart, we have to run poll again to
# check if the socket is closed.
poll.register(fd, select.POLLHUP)
try:
if poll.poll(0):
raise RuntimeError("AcquireNotify socket disconnected")
finally:
poll.unregister(fd)
except Exception as e:
logger.debug(
"AcquireNotify: Read error on fd %d: %s. Notifications have been stopped.",
Expand Down
67 changes: 64 additions & 3 deletions tests/integration/test_client_characteristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from bumble.transport.common import Transport

from bleak import BleakClient
from bleak.args.bluez import BlueZNotifyArgs
from bleak.backends import BleakBackend, get_default_backend
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.exc import BleakGATTProtocolError
from tests.integration.conftest import (
Expand Down Expand Up @@ -276,9 +278,19 @@ def peripheral_write_callback(connection: Connection, value: bytes):
assert written_value == b"DATA"


@pytest.mark.parametrize(
"bluez",
(
[{"use_start_notify": True}, {"use_start_notify": False}]
if get_default_backend() == BleakBackend.BLUEZ_DBUS
else [{}]
),
)
@pytest.mark.asyncio(loop_scope="module")
async def test_notify_gatt_char(char_test_peripheral: CharTestPeripheral):
"""Writing a GATT characteristic is possible."""
async def test_notify_gatt_char(
char_test_peripheral: CharTestPeripheral, bluez: BlueZNotifyArgs
):
"""Ensure notifications are delivered and received by the client."""

notified_data: asyncio.Queue[bytes] = asyncio.Queue()

Expand All @@ -289,6 +301,7 @@ def notify_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
await char_test_peripheral.bleak_client.start_notify(
NOTIFY_CHAR_UUID,
notify_callback,
bluez=bluez,
)
assert notified_data.empty()

Expand Down Expand Up @@ -320,8 +333,18 @@ def notify_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
await asyncio.wait_for(notified_data.get(), timeout=1)


@pytest.mark.parametrize(
"bluez",
(
[{"use_start_notify": True}, {"use_start_notify": False}]
if get_default_backend() == BleakBackend.BLUEZ_DBUS
else [{}]
),
)
@pytest.mark.asyncio(loop_scope="module")
async def test_indicate_gatt_char(char_test_peripheral: CharTestPeripheral):
async def test_indicate_gatt_char(
char_test_peripheral: CharTestPeripheral, bluez: BlueZNotifyArgs
):
"""Ensure indications are delivered and received by the client."""

virtual_connection = list(
Expand All @@ -337,6 +360,7 @@ def indicate_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
await char_test_peripheral.bleak_client.start_notify(
INDICATE_CHAR_UUID,
indicate_callback,
bluez=bluez,
)
assert indicated_data.empty()

Expand Down Expand Up @@ -371,3 +395,40 @@ def indicate_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
# Verify no indication was received after stop
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(indicated_data.get(), timeout=1)


@pytest.mark.parametrize(
"bluez",
(
[{"use_start_notify": True}, {"use_start_notify": False}]
if get_default_backend() == BleakBackend.BLUEZ_DBUS
else [{}]
),
)
@pytest.mark.asyncio(loop_scope="module")
async def test_notify_gatt_char_empty(
char_test_peripheral: CharTestPeripheral, bluez: BlueZNotifyArgs
):
"""Ensure empty notifications are delivered and received by the client."""

notified_data: asyncio.Queue[bytes] = asyncio.Queue()

def notify_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
assert characteristic.uuid.lower() == NOTIFY_CHAR_UUID
notified_data.put_nowait(bytes(data))

await char_test_peripheral.bleak_client.start_notify(
NOTIFY_CHAR_UUID,
notify_callback,
bluez=bluez,
)

await char_test_peripheral.bumble_peripheral.notify_subscribers( # type: ignore # (missing type hints in bumble)
char_test_peripheral.notify_characteristic,
b"",
)

data = await asyncio.wait_for(notified_data.get(), timeout=1)
assert data == b""

await char_test_peripheral.bleak_client.stop_notify(NOTIFY_CHAR_UUID)
Comment thread
dlech marked this conversation as resolved.
70 changes: 70 additions & 0 deletions tests/integration/test_issue_1982.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import asyncio

import pytest
from bumble.att import Attribute
from bumble.device import Device
from bumble.gatt import Characteristic, Service
from bumble.hci import HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR

from bleak import BleakClient
from bleak.backends import BleakBackend, get_default_backend
from bleak.backends.characteristic import BleakGATTCharacteristic
from tests.integration.conftest import (
configure_and_power_on_bumble_peripheral,
find_ble_device,
)

TEST_SERVICE_UUID = "9d513f40-5c89-42dc-9688-2cfa30f2d9e7"
TEST_CHARACTERISTIC_UUID = "e809cb2f-34e3-42a1-ba92-22db2495cd6a"


@pytest.mark.skipif(
get_default_backend() != BleakBackend.BLUEZ_DBUS,
reason="issue present in BlueZ backend only",
)
async def test_empty_notification_disconnect_disambiguation(
bumble_peripheral: Device,
) -> None:
"""
Ensure disconnecting an active notification stream does not deliver EOF as b"".

This is a weird case that only affects BlueZ when use_start_notify is False.

Regression test for: https://github.com/hbldh/bleak/issues/1982
"""

test_characteristic = Characteristic[bytes](
TEST_CHARACTERISTIC_UUID,
Characteristic.Properties.NOTIFY,
Attribute.Permissions(0),
)

await configure_and_power_on_bumble_peripheral(
bumble_peripheral, services=[Service(TEST_SERVICE_UUID, [test_characteristic])]
)

device = await find_ble_device(bumble_peripheral)

async with BleakClient(device, services=[TEST_SERVICE_UUID]) as client:

notified_data: asyncio.Queue[bytes] = asyncio.Queue()

def notify_callback(characteristic: BleakGATTCharacteristic, data: bytearray):
assert characteristic.uuid.lower() == TEST_CHARACTERISTIC_UUID
notified_data.put_nowait(bytes(data))

await client.start_notify(
TEST_CHARACTERISTIC_UUID,
notify_callback,
bluez={"use_start_notify": False},
)

connection = next(iter(bumble_peripheral.connections.values()))

await bumble_peripheral.disconnect(
connection, HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
)

# A closed AcquireNotify fd/disconnect must not be surfaced as an empty notification.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(notified_data.get(), timeout=1)
Loading