From e3373166cfaa56a3b3484dcc5c06121019efc07d Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 15 Jul 2026 16:02:30 +0200 Subject: [PATCH 1/4] gatt: Add server-side ATT long-write (Prepare/Execute Write) support --- bumble/gatt_server.py | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index 31127fb06..89fd1eee5 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -104,6 +104,8 @@ def __init__(self, device: Device) -> None: ) # Map of subscriber states by connection handle and attribute handle self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1)) self.pending_confirmations = defaultdict(lambda: None) + # Queued prepared writes, per bearer + self.prepared_writes: dict[att.Bearer, list[tuple[int, int, bytes]]] = {} def __str__(self) -> str: return "\n".join(map(str, self.attributes)) @@ -559,6 +561,7 @@ def on_disconnection(self, bearer: att.Bearer) -> None: self.subscribers.pop(bearer, None) self.indication_semaphores.pop(bearer, None) self.pending_confirmations.pop(bearer, None) + self.prepared_writes.pop(bearer, None) def on_gatt_pdu(self, bearer: att.Bearer, att_pdu: att.ATT_PDU) -> None: logger.debug(f'GATT Request to server: {_bearer_id(bearer)} {att_pdu}') @@ -1153,6 +1156,88 @@ async def on_att_write_command( except Exception: logger.exception('!!! ignoring exception') + def on_att_prepare_write_request( + self, bearer: att.Bearer, request: att.ATT_Prepare_Write_Request + ): + ''' + See Bluetooth spec Vol 3, Part F - 3.4.6.1 Prepare Write Request + ''' + + # Check that the attribute exists + if self.get_attribute(request.attribute_handle) is None: + self.send_response( + bearer, + att.ATT_Error_Response( + request_opcode_in_error=request.op_code, + attribute_handle_in_error=request.attribute_handle, + error_code=att.ATT_INVALID_HANDLE_ERROR, + ), + ) + return + + # Queue the partial value, to be committed on Execute Write Request + self.prepared_writes.setdefault(bearer, []).append( + ( + request.attribute_handle, + request.value_offset, + request.part_attribute_value, + ) + ) + + # Acknowledge by echoing back the received part + self.send_response( + bearer, + att.ATT_Prepare_Write_Response( + attribute_handle=request.attribute_handle, + value_offset=request.value_offset, + part_attribute_value=request.part_attribute_value, + ), + ) + + @utils.AsyncRunner.run_in_task() + async def on_att_execute_write_request( + self, bearer: att.Bearer, request: att.ATT_Execute_Write_Request + ): + ''' + See Bluetooth spec Vol 3, Part F - 3.4.6.3 Execute Write Request + ''' + + queue = self.prepared_writes.pop(bearer, []) + + # flags == 0x00 means cancel all prepared writes + if request.flags == 0: + self.send_response(bearer, att.ATT_Execute_Write_Response()) + return + + try: + # Reassemble the queued parts; reject a gap with INVALID_OFFSET + values: dict[int, bytearray] = {} + for handle, offset, part in queue: + buffer = values.setdefault(handle, bytearray()) + if offset > len(buffer): + raise att.ATT_Error( + error_code=att.ATT_INVALID_OFFSET_ERROR, att_handle=handle + ) + buffer[offset : offset + len(part)] = part + + # Commit the reassembled values through the normal write path + for handle, value in values.items(): + attribute = self.get_attribute(handle) + assert attribute is not None + await attribute.write_value(bearer, bytes(value)) + except att.ATT_Error as error: + self.send_response( + bearer, + att.ATT_Error_Response( + request_opcode_in_error=request.op_code, + attribute_handle_in_error=error.att_handle, + error_code=error.error_code, + ), + ) + return + + self.send_response(bearer, att.ATT_Execute_Write_Response()) + def on_att_handle_value_confirmation( self, bearer: att.Bearer, From 9186a56440c69706056442f0cbc0ffa8a93802f2 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 15 Jul 2026 16:02:57 +0200 Subject: [PATCH 2/4] gatt: Add client-side ATT long-write (Prepare/Execute Write) support --- bumble/gatt_client.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 23485df8c..c9d620c08 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -1096,8 +1096,8 @@ async def write_value( with_response: bool = False, ) -> None: ''' - See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic - Value + See Vol 3, Part G - 4.9.1 Write Without Response, 4.9.3 Write Characteristic + Value & 4.9.4 Write Long Characteristic Values `attribute` can be an Attribute object, or a handle value ''' @@ -1105,6 +1105,12 @@ async def write_value( # Send a request or command to write attribute_handle = attribute if isinstance(attribute, int) else attribute.handle if with_response: + # Use the Write Long procedure when the value does not fit in a single + # Write Request + if len(value) > self.mtu - 3: + await self.write_long_value(attribute_handle, value) + return + response = await self.send_request( att.ATT_Write_Request( attribute_handle=attribute_handle, attribute_value=value @@ -1119,6 +1125,38 @@ async def write_value( ) ) + async def write_long_value(self, attribute_handle: int, value: bytes) -> None: + ''' + See Vol 3, Part G - 4.9.4 Write Long Characteristic Values + + Writes a value that does not fit in a single Write Request by queuing it + with Prepare Write Requests and committing it with an Execute Write Request. + ''' + + # Each Prepare Write Request carries opcode(1) + handle(2) + offset(2), so + # the part value is limited to ATT_MTU - 5 bytes. + max_part_size = self.mtu - 5 + offset = 0 + while offset < len(value): + part = value[offset : offset + max_part_size] + response = await self.send_request( + att.ATT_Prepare_Write_Request( + attribute_handle=attribute_handle, + value_offset=offset, + part_attribute_value=part, + ) + ) + if response.op_code == att.Opcode.ATT_ERROR_RESPONSE: + # Ask the server to drop whatever it has already queued + await self.send_request(att.ATT_Execute_Write_Request(flags=0x00)) + raise att.ATT_Error(error_code=response.error_code, message=response) + offset += len(part) + + # Commit the queued values (flags=0x01 = write all pending prepared values) + response = await self.send_request(att.ATT_Execute_Write_Request(flags=0x01)) + if response.op_code == att.Opcode.ATT_ERROR_RESPONSE: + raise att.ATT_Error(error_code=response.error_code, message=response) + def on_disconnection(self, *args) -> None: del args # unused. if self.pending_response and not self.pending_response.done(): From c951930315dbb93417c5e62a3141727076f6fee0 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 15 Jul 2026 16:03:32 +0200 Subject: [PATCH 3/4] gatt: Add tests for ATT long writes --- tests/gatt_test.py | 130 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/gatt_test.py b/tests/gatt_test.py index e167cc1bd..83ca21e47 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -1736,6 +1736,136 @@ async def test_read_multiple_variable() -> None: assert response.error_code == att.ATT_ATTRIBUTE_NOT_FOUND_ERROR +# ----------------------------------------------------------------------------- +async def _connect_with_writeable_characteristic(): + devices = await TwoDevices.create_with_connection() + [_, server] = devices + + characteristic = Characteristic( + 'FDB159DB-036C-49E3-B3DB-6325AC750806', + Characteristic.Properties.READ | Characteristic.Properties.WRITE, + Characteristic.READABLE | Characteristic.WRITEABLE, + ) + server.add_service( + Service('3A657F47-D34F-46B3-B1EC-698E29B6B829', [characteristic]) + ) + + peer = Peer(devices.connections[0]) + await peer.discover_services() + await peer.discover_characteristics() + [proxy] = peer.get_characteristics_by_uuid(characteristic.uuid) + return peer, proxy, characteristic + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value(): + peer, proxy, characteristic = await _connect_with_writeable_characteristic() + + # A value that does not fit in a single Write Request (ATT_MTU - 3) + long_value = bytes((i * 7) % 256 for i in range(149)) + assert len(long_value) > peer.gatt_client.mtu - 3 + + # Record the request opcodes the client emits + sent_opcodes = [] + inner_send_request = peer.gatt_client.send_request + + async def recording_send_request(request): + sent_opcodes.append(request.op_code) + return await inner_send_request(request) + + peer.gatt_client.send_request = recording_send_request + + await proxy.write_value(long_value, with_response=True) + await async_barrier() + + # The client used the Write Long procedure, not a single Write Request + assert att.Opcode.ATT_PREPARE_WRITE_REQUEST in sent_opcodes + assert att.Opcode.ATT_EXECUTE_WRITE_REQUEST in sent_opcodes + assert att.Opcode.ATT_WRITE_REQUEST not in sent_opcodes + + # The server reassembled all the queued parts into the complete value + assert characteristic.value == long_value + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value_manual(): + peer, _, characteristic = await _connect_with_writeable_characteristic() + client = peer.gatt_client + + long_value = bytes((i * 3) % 256 for i in range(60)) + + # Queue the value in three parts at increasing offsets + for offset in range(0, len(long_value), 20): + part = long_value[offset : offset + 20] + response = await client.send_request( + att.ATT_Prepare_Write_Request( + attribute_handle=characteristic.handle, + value_offset=offset, + part_attribute_value=part, + ) + ) + assert isinstance(response, att.ATT_Prepare_Write_Response) + assert response.value_offset == offset + assert response.part_attribute_value == part + + # Commit the queued values (flags=0x01) + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x01)) + assert isinstance(response, att.ATT_Execute_Write_Response) + await async_barrier() + assert characteristic.value == long_value + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value_cancel(): + peer, _, characteristic = await _connect_with_writeable_characteristic() + client = peer.gatt_client + + # Queue a value, then cancel instead of committing (flags=0x00) + response = await client.send_request( + att.ATT_Prepare_Write_Request( + attribute_handle=characteristic.handle, + value_offset=0, + part_attribute_value=bytes([1, 2, 3, 4]), + ) + ) + assert isinstance(response, att.ATT_Prepare_Write_Response) + + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x00)) + assert isinstance(response, att.ATT_Execute_Write_Response) + await async_barrier() + + # The cancelled value was not written (the attribute keeps its initial value) + assert characteristic.value is None + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_write_long_value_gap_rejected(): + peer, _, characteristic = await _connect_with_writeable_characteristic() + client = peer.gatt_client + + # Queue parts leaving a gap between them (offset 0 then offset 10) + for offset in (0, 10): + response = await client.send_request( + att.ATT_Prepare_Write_Request( + attribute_handle=characteristic.handle, + value_offset=offset, + part_attribute_value=bytes([1, 2, 3, 4]), + ) + ) + assert isinstance(response, att.ATT_Prepare_Write_Response) + + # Committing a non-contiguous queue is rejected with INVALID_OFFSET + response = await client.send_request(att.ATT_Execute_Write_Request(flags=0x01)) + assert isinstance(response, att.ATT_Error_Response) + assert response.error_code == att.ATT_INVALID_OFFSET_ERROR + await async_barrier() + assert characteristic.value is None + + # ----------------------------------------------------------------------------- if __name__ == '__main__': logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) From c247f52dfdd42518485e2ea60135a488894fbd50 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 16 Jul 2026 19:36:14 +0200 Subject: [PATCH 4/4] Simplify write_long_value loop to use range --- bumble/gatt_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index c9d620c08..dcd009e65 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -1136,8 +1136,7 @@ async def write_long_value(self, attribute_handle: int, value: bytes) -> None: # Each Prepare Write Request carries opcode(1) + handle(2) + offset(2), so # the part value is limited to ATT_MTU - 5 bytes. max_part_size = self.mtu - 5 - offset = 0 - while offset < len(value): + for offset in range(0, len(value), max_part_size): part = value[offset : offset + max_part_size] response = await self.send_request( att.ATT_Prepare_Write_Request( @@ -1150,7 +1149,6 @@ async def write_long_value(self, attribute_handle: int, value: bytes) -> None: # Ask the server to drop whatever it has already queued await self.send_request(att.ATT_Execute_Write_Request(flags=0x00)) raise att.ATT_Error(error_code=response.error_code, message=response) - offset += len(part) # Commit the queued values (flags=0x01 = write all pending prepared values) response = await self.send_request(att.ATT_Execute_Write_Request(flags=0x01))