From 01fc3ba0b33d30fb07d3345ea89b1e615fecde01 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 5 May 2026 11:08:19 -0500 Subject: [PATCH 1/2] tests/integration: Test bluez use_start_notify True and False Add parametrization to test start_notify() with both use_start_notify True and False on Linux. These have significantly different code paths. --- .../test_client_characteristics.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_client_characteristics.py b/tests/integration/test_client_characteristics.py index 1aa12f1e5..48eb44ce3 100644 --- a/tests/integration/test_client_characteristics.py +++ b/tests/integration/test_client_characteristics.py @@ -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 ( @@ -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() @@ -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() @@ -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( @@ -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() From bdbbf890afff9176d613c8680660f5bef4955898 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 5 May 2026 13:21:44 -0500 Subject: [PATCH 2/2] backends/bluezdbus/client: allow empty notifications when using AcquireNotify Drop the check for empty data in the AcquireNotify callback. Normally in Python, read() returning an empty bytes object indicates EOF. In this case, it just means that a notification with an empty payload was received, which is perfectly valid. Fixes: https://github.com/hbldh/bleak/issues/1982 --- CHANGELOG.rst | 4 ++ bleak/backends/bluezdbus/client.py | 14 +++- .../test_client_characteristics.py | 37 ++++++++++ tests/integration/test_issue_1982.py | 70 +++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_issue_1982.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6f564012e..0832a70e5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ===================== diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index 5e0af10b8..c4a699d1a 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -2,6 +2,7 @@ BLE Client for BlueZ on Linux """ +import select import sys from typing import TYPE_CHECKING @@ -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.", diff --git a/tests/integration/test_client_characteristics.py b/tests/integration/test_client_characteristics.py index 48eb44ce3..71c4757cc 100644 --- a/tests/integration/test_client_characteristics.py +++ b/tests/integration/test_client_characteristics.py @@ -395,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) diff --git a/tests/integration/test_issue_1982.py b/tests/integration/test_issue_1982.py new file mode 100644 index 000000000..47e0916a0 --- /dev/null +++ b/tests/integration/test_issue_1982.py @@ -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)