From 0fb2b3bd6628f28cf24bb448416db7c89b3869d5 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Mon, 12 Jan 2026 15:09:39 +0800 Subject: [PATCH] GATT: Support Multiple Requests --- bumble/att.py | 129 +++++++++++++++++++++++++++++++----------- bumble/gatt_server.py | 88 ++++++++++++++++++++++++++++ tests/gatt_test.py | 100 +++++++++++++++++++++++++++++++- 3 files changed, 284 insertions(+), 33 deletions(-) diff --git a/bumble/att.py b/bumble/att.py index 6e7c989f..60e9b5c5 100644 --- a/bumble/att.py +++ b/bumble/att.py @@ -29,7 +29,7 @@ import functools import inspect import struct -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from typing import ( TYPE_CHECKING, ClassVar, @@ -72,34 +72,36 @@ def is_enhanced_bearer(bearer: Bearer) -> TypeIs[EnhancedBearer]: EATT_PSM = 0x0027 class Opcode(hci.SpecableEnum): - ATT_ERROR_RESPONSE = 0x01 - ATT_EXCHANGE_MTU_REQUEST = 0x02 - ATT_EXCHANGE_MTU_RESPONSE = 0x03 - ATT_FIND_INFORMATION_REQUEST = 0x04 - ATT_FIND_INFORMATION_RESPONSE = 0x05 - ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06 - ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07 - ATT_READ_BY_TYPE_REQUEST = 0x08 - ATT_READ_BY_TYPE_RESPONSE = 0x09 - ATT_READ_REQUEST = 0x0A - ATT_READ_RESPONSE = 0x0B - ATT_READ_BLOB_REQUEST = 0x0C - ATT_READ_BLOB_RESPONSE = 0x0D - ATT_READ_MULTIPLE_REQUEST = 0x0E - ATT_READ_MULTIPLE_RESPONSE = 0x0F - ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10 - ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11 - ATT_WRITE_REQUEST = 0x12 - ATT_WRITE_RESPONSE = 0x13 - ATT_WRITE_COMMAND = 0x52 - ATT_SIGNED_WRITE_COMMAND = 0xD2 - ATT_PREPARE_WRITE_REQUEST = 0x16 - ATT_PREPARE_WRITE_RESPONSE = 0x17 - ATT_EXECUTE_WRITE_REQUEST = 0x18 - ATT_EXECUTE_WRITE_RESPONSE = 0x19 - ATT_HANDLE_VALUE_NOTIFICATION = 0x1B - ATT_HANDLE_VALUE_INDICATION = 0x1D - ATT_HANDLE_VALUE_CONFIRMATION = 0x1E + ATT_ERROR_RESPONSE = 0x01 + ATT_EXCHANGE_MTU_REQUEST = 0x02 + ATT_EXCHANGE_MTU_RESPONSE = 0x03 + ATT_FIND_INFORMATION_REQUEST = 0x04 + ATT_FIND_INFORMATION_RESPONSE = 0x05 + ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06 + ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07 + ATT_READ_BY_TYPE_REQUEST = 0x08 + ATT_READ_BY_TYPE_RESPONSE = 0x09 + ATT_READ_REQUEST = 0x0A + ATT_READ_RESPONSE = 0x0B + ATT_READ_BLOB_REQUEST = 0x0C + ATT_READ_BLOB_RESPONSE = 0x0D + ATT_READ_MULTIPLE_REQUEST = 0x0E + ATT_READ_MULTIPLE_RESPONSE = 0x0F + ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10 + ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11 + ATT_READ_MULTIPLE_VARIABLE_REQUEST = 0x20 + ATT_READ_MULTIPLE_VARIABLE_RESPONSE = 0x21 + ATT_WRITE_REQUEST = 0x12 + ATT_WRITE_RESPONSE = 0x13 + ATT_WRITE_COMMAND = 0x52 + ATT_SIGNED_WRITE_COMMAND = 0xD2 + ATT_PREPARE_WRITE_REQUEST = 0x16 + ATT_PREPARE_WRITE_RESPONSE = 0x17 + ATT_EXECUTE_WRITE_REQUEST = 0x18 + ATT_EXECUTE_WRITE_RESPONSE = 0x19 + ATT_HANDLE_VALUE_NOTIFICATION = 0x1B + ATT_HANDLE_VALUE_INDICATION = 0x1D + ATT_HANDLE_VALUE_CONFIRMATION = 0x1E ATT_REQUESTS = [ Opcode.ATT_EXCHANGE_MTU_REQUEST, @@ -110,9 +112,10 @@ class Opcode(hci.SpecableEnum): Opcode.ATT_READ_BLOB_REQUEST, Opcode.ATT_READ_MULTIPLE_REQUEST, Opcode.ATT_READ_BY_GROUP_TYPE_REQUEST, + Opcode.ATT_READ_MULTIPLE_VARIABLE_REQUEST, Opcode.ATT_WRITE_REQUEST, Opcode.ATT_PREPARE_WRITE_REQUEST, - Opcode.ATT_EXECUTE_WRITE_REQUEST + Opcode.ATT_EXECUTE_WRITE_REQUEST, ] ATT_RESPONSES = [ @@ -125,9 +128,10 @@ class Opcode(hci.SpecableEnum): Opcode.ATT_READ_BLOB_RESPONSE, Opcode.ATT_READ_MULTIPLE_RESPONSE, Opcode.ATT_READ_BY_GROUP_TYPE_RESPONSE, + Opcode.ATT_READ_MULTIPLE_VARIABLE_RESPONSE, Opcode.ATT_WRITE_RESPONSE, Opcode.ATT_PREPARE_WRITE_RESPONSE, - Opcode.ATT_EXECUTE_WRITE_RESPONSE + Opcode.ATT_EXECUTE_WRITE_RESPONSE, ] class ErrorCode(hci.SpecableEnum): @@ -185,6 +189,18 @@ class ErrorCode(hci.SpecableEnum): ATT_DEFAULT_MTU = 23 HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'} +_SET_OF_HANDLES_METADATA = hci.metadata({ + 'parser': lambda data, offset: ( + len(data), + [ + struct.unpack_from(' tuple[int, list[tuple[int, bytes]]]: + length_value_tuple_list: list[tuple[int, bytes]] = [] + while offset < len(data): + length = struct.unpack_from(' max_attribute_size: + # We need to truncate + attribute_value = attribute_value[:max_attribute_size] + + # Check if there is enough space + entry_size = len(attribute_value) + if pdu_space_available < entry_size: + break + + # Add the attribute to the list + values.append(attribute_value) + pdu_space_available -= entry_size + + response = att.ATT_Read_Multiple_Response(set_of_values=b''.join(values)) + self.send_response(bearer, response) + + @utils.AsyncRunner.run_in_task() + async def on_att_read_multiple_variable_request( + self, bearer: att.Bearer, request: att.ATT_Read_Multiple_Variable_Request + ): + ''' + See Bluetooth spec Vol 3, Part F - 3.4.4.11 Read Multiple Variable Request. + ''' + response: att.ATT_PDU + + pdu_space_available = bearer.att_mtu - 1 + length_value_tuple_list: list[tuple[int, bytes]] = [] + + for handle in request.set_of_handles: + if not (attribute := self.get_attribute(handle)): + response = att.ATT_Error_Response( + request_opcode_in_error=request.op_code, + attribute_handle_in_error=handle, + error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR, + ) + self.send_response(bearer, response) + return + # No need to catch permission errors here, since these attributes + # must all be world-readable + attribute_value = await attribute.read_value(bearer) + length = len(attribute_value) + # Check the attribute value size + max_attribute_size = min(bearer.att_mtu - 3, 251) + if len(attribute_value) > max_attribute_size: + # We need to truncate + attribute_value = attribute_value[:max_attribute_size] + + # Check if there is enough space + entry_size = 2 + len(attribute_value) + + # Add the attribute to the list + length_value_tuple_list.append((length, attribute_value)) + pdu_space_available -= entry_size + + if pdu_space_available <= 0: + break + + response = att.ATT_Read_Multiple_Variable_Response( + length_value_tuple_list=length_value_tuple_list + ) + self.send_response(bearer, response) + @utils.AsyncRunner.run_in_task() async def on_att_write_request( self, bearer: att.Bearer, request: att.ATT_Write_Request diff --git a/tests/gatt_test.py b/tests/gatt_test.py index f2f3204f..e167cc1b 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -28,7 +28,7 @@ import pytest from typing_extensions import Self -from bumble import gatt_client, l2cap +from bumble import att, gatt_client, l2cap from bumble.att import ( ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_PDU, @@ -1638,6 +1638,104 @@ async def test_eatt_connection_failure(): await gatt_client.Client.connect_eatt(devices.connections[0]) +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_read_multiple() -> None: + devices = await TwoDevices.create_with_connection() + + characteristic1 = Characteristic( + '0001', Characteristic.Properties.READ, Characteristic.READABLE, b'1234' + ) + + characteristic2 = Characteristic( + '0002', + Characteristic.Properties.READ, + Characteristic.READABLE, + b'5678', + ) + + service = Service('0000', [characteristic1, characteristic2]) + devices[1].add_service(service) + + client = devices.connections[0].gatt_client + server = devices[1].gatt_server + + await client.discover_services() + characteristics = await client.discover_characteristics( + [characteristic1.uuid, characteristic2.uuid], None + ) + response = await client.send_request( + att.ATT_Read_Multiple_Request( + set_of_handles=[c.handle for c in characteristics] + ) + ) + assert isinstance(response, att.ATT_Read_Multiple_Response) + assert response.set_of_values == b'12345678' + + response = await client.send_request( + att.ATT_Read_Multiple_Request( + set_of_handles=[ + next( + handle + for handle in range(0x0001, 0xFFFF) + if not server.get_attribute(handle) + ) + ] + ) + ) + assert isinstance(response, att.ATT_Error_Response) + assert response.error_code == att.ATT_ATTRIBUTE_NOT_FOUND_ERROR + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_read_multiple_variable() -> None: + devices = await TwoDevices.create_with_connection() + + characteristic1 = Characteristic( + '0001', Characteristic.Properties.READ, Characteristic.READABLE, b'1234' + ) + + characteristic2 = Characteristic( + '0002', + Characteristic.Properties.READ, + Characteristic.READABLE, + b'99', + ) + + service = Service('0000', [characteristic1, characteristic2]) + devices[1].add_service(service) + + client = devices.connections[0].gatt_client + server = devices[1].gatt_server + + await client.discover_services() + characteristics = await client.discover_characteristics( + [characteristic1.uuid, characteristic2.uuid], None + ) + response = await client.send_request( + att.ATT_Read_Multiple_Variable_Request( + set_of_handles=[c.handle for c in characteristics] + ) + ) + assert isinstance(response, att.ATT_Read_Multiple_Variable_Response) + assert response.length_value_tuple_list == [(4, b'1234'), (2, b'99')] + + response = await client.send_request( + att.ATT_Read_Multiple_Variable_Request( + set_of_handles=[ + next( + handle + for handle in range(0x0001, 0xFFFF) + if not server.get_attribute(handle) + ) + ] + ) + ) + assert isinstance(response, att.ATT_Error_Response) + assert response.error_code == att.ATT_ATTRIBUTE_NOT_FOUND_ERROR + + # ----------------------------------------------------------------------------- if __name__ == '__main__': logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())