From 874da914525132b73c57847cafe8947122a8fcd4 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 10 Jul 2026 15:20:41 +0200 Subject: [PATCH 1/4] add support for channel manager delegates --- bumble/device.py | 6 ++- bumble/l2cap.py | 123 ++++++++++++++++++++++++++++++-------------- tests/l2cap_test.py | 32 ++++++------ 3 files changed, 105 insertions(+), 56 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 92f00309d..3cbb5e2f2 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -2366,6 +2366,7 @@ class Device(utils.CompositeEventEmitter): inquiry_response: bytes | None = None address_resolver: smp.AddressResolver | None = None connect_own_address_type: hci.OwnAddressType | None = None + l2cap_channel_manager: l2cap.ChannelManager EVENT_ADVERTISEMENT = "advertisement" EVENT_PERIODIC_ADVERTISING_SYNC_TRANSFER = "periodic_advertising_sync_transfer" @@ -4407,7 +4408,10 @@ async def update_connection_parameters( supervision_timeout, ) ) - if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT: + if ( + l2cap_result + != l2cap.L2CAP_Connection_Parameter_Update_Response.Result.ACCEPTED + ): raise ConnectionParameterUpdateError(l2cap_result) return diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 76e672c25..4fbc4b777 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -88,7 +88,7 @@ L2CAP_LE_PSM_DYNAMIC_RANGE_END = 0x00FF class CommandCode(hci.SpecableEnum): - L2CAP_COMMAND_REJECT = 0x01 + L2CAP_COMMAND_REJECT_RESPONSE = 0x01 L2CAP_CONNECTION_REQUEST = 0x02 L2CAP_CONNECTION_RESPONSE = 0x03 L2CAP_CONFIGURE_REQUEST = 0x04 @@ -115,13 +115,6 @@ class CommandCode(hci.SpecableEnum): L2CAP_CREDIT_BASED_RECONFIGURE_REQUEST = 0x19 L2CAP_CREDIT_BASED_RECONFIGURE_RESPONSE = 0x1A -L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT = 0x0000 -L2CAP_CONNECTION_PARAMETERS_REJECTED_RESULT = 0x0001 - -L2CAP_COMMAND_NOT_UNDERSTOOD_REASON = 0x0000 -L2CAP_SIGNALING_MTU_EXCEEDED_REASON = 0x0001 -L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002 - L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535 L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23 L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU = 65535 @@ -463,9 +456,9 @@ def __str__(self) -> str: # ----------------------------------------------------------------------------- @L2CAP_Control_Frame.subclass @dataclasses.dataclass -class L2CAP_Command_Reject(L2CAP_Control_Frame): +class L2CAP_Command_Reject_Response(L2CAP_Control_Frame): ''' - See Bluetooth spec @ Vol 3, Part A - 4.1 COMMAND REJECT + See Bluetooth spec @ Vol 3, Part A - 4.1 COMMAND REJECT RESPONSE ''' class Reason(hci.SpecableEnum): @@ -706,7 +699,11 @@ class L2CAP_Connection_Parameter_Update_Response(L2CAP_Control_Frame): See Bluetooth spec @ Vol 3, Part A - 4.21 CONNECTION PARAMETER UPDATE RESPONSE ''' - result: int = dataclasses.field(metadata=hci.metadata(2)) + class Result(hci.SpecableEnum): + ACCEPTED = 0x0000 + REJECTED = 0x0001 + + result: Result = dataclasses.field(metadata=Result.type_metadata(2)) # ----------------------------------------------------------------------------- @@ -2038,6 +2035,27 @@ def close(self) -> None: del self.manager.le_coc_servers[self.psm] +# ----------------------------------------------------------------------------- +class ChannelManagerDelegate: + """ + Delegate for handling channel manager decisions, + such as accepting connection parameters. + """ + + def accept_connection_parameters( + self, interval_min: int, interval_max: int, latency: int, timeout: int + ) -> bool: + """ + Decide whether to accept the given connection parameters. + + Return True to accept, False to reject. + + By default, accept all connection parameters. + Override this method to implement custom logic. + """ + return True + + # ----------------------------------------------------------------------------- class ChannelManager: identifiers: dict[int, int] @@ -2058,12 +2076,16 @@ class ChannelManager: ], ] _host: Host | None - connection_parameters_update_response: asyncio.Future[int] | None + connection_parameters_update_response: ( + asyncio.Future[L2CAP_Connection_Parameter_Update_Response.Result] | None + ) + delegate: ChannelManagerDelegate def __init__( self, extended_features: Iterable[int] = (), connectionless_mtu: int = L2CAP_DEFAULT_CONNECTIONLESS_MTU, + delegate: ChannelManagerDelegate | None = None, ) -> None: self._host = None self.identifiers = {} # Incrementing identifier values by connection @@ -2084,6 +2106,7 @@ def __init__( self.extended_features = set(extended_features) self.connectionless_mtu = connectionless_mtu self.connection_parameters_update_response = None + self.delegate = delegate or ChannelManagerDelegate() @property def host(self) -> Host: @@ -2315,9 +2338,9 @@ def on_control_frame( self.send_control_frame( connection, cid, - L2CAP_Command_Reject( + L2CAP_Command_Reject_Response( identifier=control_frame.identifier, - reason=L2CAP_COMMAND_NOT_UNDERSTOOD_REASON, + reason=L2CAP_Command_Reject_Response.Reason.COMMAND_NOT_UNDERSTOOD, data=b'', ), ) @@ -2327,15 +2350,15 @@ def on_control_frame( self.send_control_frame( connection, cid, - L2CAP_Command_Reject( + L2CAP_Command_Reject_Response( identifier=control_frame.identifier, - reason=L2CAP_COMMAND_NOT_UNDERSTOOD_REASON, + reason=L2CAP_Command_Reject_Response.Reason.COMMAND_NOT_UNDERSTOOD, data=b'', ), ) - def on_l2cap_command_reject( - self, _connection: Connection, _cid: int, packet: L2CAP_Command_Reject + def on_l2cap_command_reject_response( + self, _connection: Connection, _cid: int, packet: L2CAP_Command_Reject_Response ) -> None: logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}') @@ -2539,34 +2562,54 @@ def on_l2cap_connection_parameter_update_request( cid: int, request: L2CAP_Connection_Parameter_Update_Request, ): - if connection.role == hci.Role.CENTRAL: + if connection.role == hci.Role.PERIPHERAL: self.send_control_frame( connection, cid, - L2CAP_Connection_Parameter_Update_Response( + L2CAP_Command_Reject_Response( identifier=request.identifier, - result=L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT, + reason=L2CAP_Command_Reject_Response.Reason.COMMAND_NOT_UNDERSTOOD, + data=b'', ), ) - self.host.send_command_sync( - hci.HCI_LE_Connection_Update_Command( - connection_handle=connection.handle, - connection_interval_min=request.interval_min, - connection_interval_max=request.interval_max, - max_latency=request.latency, - supervision_timeout=request.timeout, - min_ce_length=0, - max_ce_length=0, - ) - ) - else: - self.send_control_frame( - connection, - cid, - L2CAP_Connection_Parameter_Update_Response( - identifier=request.identifier, - result=L2CAP_CONNECTION_PARAMETERS_REJECTED_RESULT, + return + + # Ask the delegate to accept or reject the connection parameters + accept = self.delegate.accept_connection_parameters( + request.interval_min, + request.interval_max, + request.latency, + request.timeout, + ) + + # Respond + self.send_control_frame( + connection, + cid, + L2CAP_Connection_Parameter_Update_Response( + identifier=request.identifier, + result=( + L2CAP_Connection_Parameter_Update_Response.Result.ACCEPTED + if accept + else L2CAP_Connection_Parameter_Update_Response.Result.REJECTED ), + ), + ) + + if accept: + # Apply the requested parameters + utils.AsyncRunner.spawn( + self.host.send_async_command( + hci.HCI_LE_Connection_Update_Command( + connection_handle=connection.handle, + connection_interval_min=request.interval_min, + connection_interval_max=request.interval_max, + max_latency=request.latency, + supervision_timeout=request.timeout, + min_ce_length=0, + max_ce_length=0, + ) + ) ) async def update_connection_parameters( @@ -2576,7 +2619,7 @@ async def update_connection_parameters( interval_max: int, latency: int, timeout: int, - ) -> int: + ) -> L2CAP_Connection_Parameter_Update_Response.Result: # Check that there isn't already a request pending if self.connection_parameters_update_response: raise InvalidStateError('request already pending') diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py index aaef46e91..0f9ed7a4f 100644 --- a/tests/l2cap_test.py +++ b/tests/l2cap_test.py @@ -18,7 +18,6 @@ import asyncio import itertools import logging -import os import random from collections.abc import Sequence from unittest import mock @@ -34,9 +33,6 @@ logger = logging.getLogger(__name__) -# ----------------------------------------------------------------------------- - - # ----------------------------------------------------------------------------- def test_helpers(): psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x01) @@ -532,15 +528,21 @@ async def test_disconnection_collision(): # ----------------------------------------------------------------------------- -async def run(): - test_helpers() - await test_basic_connection() - await test_transfer() - await test_bidirectional_transfer() - await test_mtu() - +@pytest.mark.asyncio +async def test_channel_manager_delegate(): + class TestDelegate(l2cap.ChannelManagerDelegate): + def accept_connection_parameters( + self, interval_min: int, interval_max: int, latency: int, timeout: int + ) -> bool: + return False -# ----------------------------------------------------------------------------- -if __name__ == '__main__': - logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) - asyncio.run(run()) + devices = await TwoDevices.create_with_connection() + devices.devices[0].l2cap_channel_manager.delegate = TestDelegate() + with pytest.raises(core.ConnectionParameterUpdateError): + await devices.connections[1].update_parameters( + connection_interval_min=15, + connection_interval_max=30, + max_latency=3, + supervision_timeout=2000, + use_l2cap=True, + ) From 065f47bb892080f4feebbd030ec80f282a16ce58 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 10 Jul 2026 15:35:13 +0200 Subject: [PATCH 2/4] scale parameters --- bumble/l2cap.py | 17 ++++++++++++----- tests/l2cap_test.py | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 4fbc4b777..4e5e1d226 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -2043,12 +2043,19 @@ class ChannelManagerDelegate: """ def accept_connection_parameters( - self, interval_min: int, interval_max: int, latency: int, timeout: int + self, interval_min: float, interval_max: float, latency: int, timeout: float ) -> bool: """ Decide whether to accept the given connection parameters. - Return True to accept, False to reject. + Args: + interval_min: The minimum connection interval, in ms. + interval_max: The maximum connection interval, in ms. + latency: The connection latency, in number of connection events. + timeout: The connection timeout, in ms. + + Returns: + True to accept, False to reject. By default, accept all connection parameters. Override this method to implement custom logic. @@ -2576,10 +2583,10 @@ def on_l2cap_connection_parameter_update_request( # Ask the delegate to accept or reject the connection parameters accept = self.delegate.accept_connection_parameters( - request.interval_min, - request.interval_max, + request.interval_min * 1.25, + request.interval_max * 1.25, request.latency, - request.timeout, + request.timeout * 10.0, ) # Respond diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py index 0f9ed7a4f..fbf22a9da 100644 --- a/tests/l2cap_test.py +++ b/tests/l2cap_test.py @@ -532,7 +532,7 @@ async def test_disconnection_collision(): async def test_channel_manager_delegate(): class TestDelegate(l2cap.ChannelManagerDelegate): def accept_connection_parameters( - self, interval_min: int, interval_max: int, latency: int, timeout: int + self, interval_min: float, interval_max: float, latency: int, timeout: float ) -> bool: return False @@ -540,9 +540,9 @@ def accept_connection_parameters( devices.devices[0].l2cap_channel_manager.delegate = TestDelegate() with pytest.raises(core.ConnectionParameterUpdateError): await devices.connections[1].update_parameters( - connection_interval_min=15, - connection_interval_max=30, + connection_interval_min=15.0, + connection_interval_max=30.0, max_latency=3, - supervision_timeout=2000, + supervision_timeout=2000.0, use_l2cap=True, ) From f7a685482aea74464de98cade0c41d75d4a6e952 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 31 Aug 2026 16:02:56 -0700 Subject: [PATCH 3/4] address PR comments --- bumble/device.py | 2 +- bumble/l2cap.py | 64 ++++++++++++++++++++++++++++----------------- tests/l2cap_test.py | 38 +++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 3cbb5e2f2..10e1c81b9 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -4397,7 +4397,7 @@ async def update_connection_parameters( if use_l2cap: if connection.role != hci.Role.PERIPHERAL: raise InvalidStateError( - 'only peripheral can update connection parameters with l2cap' + 'only a peripheral can update connection parameters with l2cap' ) l2cap_result = ( await self.l2cap_channel_manager.update_connection_parameters( diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 4e5e1d226..13454e647 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -2042,25 +2042,35 @@ class ChannelManagerDelegate: such as accepting connection parameters. """ + @dataclasses.dataclass + class ConnectionParameters: + connection_interval_min: float # Minimum connection interval, in ms. + connection_interval_max: float # Maximum connection interval, in ms. + max_latency: int # Maximum latency, in number of connection events. + supervision_timeout: float # Supervision timeout, in ms. + min_ce_length: float = 0 # Minimum connection event length, in ms. + max_ce_length: float = 0 # Maximum connection event length, in ms. + def accept_connection_parameters( - self, interval_min: float, interval_max: float, latency: int, timeout: float - ) -> bool: + self, + connection: Connection, + connection_parameters: ConnectionParameters, + ) -> ConnectionParameters | None: """ - Decide whether to accept the given connection parameters. + Decide whether to accept the given connection parameters, possibly with + modifications. Args: - interval_min: The minimum connection interval, in ms. - interval_max: The maximum connection interval, in ms. - latency: The connection latency, in number of connection events. - timeout: The connection timeout, in ms. + connection: the Connection on which the request was received. + connection_parameters: the requested parameters. Returns: - True to accept, False to reject. + A ConnectionParameters object with the accepted values, or None to reject. By default, accept all connection parameters. Override this method to implement custom logic. """ - return True + return connection_parameters # ----------------------------------------------------------------------------- @@ -2083,6 +2093,7 @@ class ChannelManager: ], ] _host: Host | None + # TODO: this should really be a per-connection Future. connection_parameters_update_response: ( asyncio.Future[L2CAP_Connection_Parameter_Update_Response.Result] | None ) @@ -2582,12 +2593,13 @@ def on_l2cap_connection_parameter_update_request( return # Ask the delegate to accept or reject the connection parameters - accept = self.delegate.accept_connection_parameters( - request.interval_min * 1.25, - request.interval_max * 1.25, - request.latency, - request.timeout * 10.0, + requested = ChannelManagerDelegate.ConnectionParameters( + connection_interval_min=request.interval_min * 1.25, + connection_interval_max=request.interval_max * 1.25, + max_latency=request.latency, + supervision_timeout=request.timeout * 10.0, ) + accepted = self.delegate.accept_connection_parameters(connection, requested) # Respond self.send_control_frame( @@ -2596,25 +2608,29 @@ def on_l2cap_connection_parameter_update_request( L2CAP_Connection_Parameter_Update_Response( identifier=request.identifier, result=( - L2CAP_Connection_Parameter_Update_Response.Result.ACCEPTED - if accept - else L2CAP_Connection_Parameter_Update_Response.Result.REJECTED + L2CAP_Connection_Parameter_Update_Response.Result.REJECTED + if accepted is None + else L2CAP_Connection_Parameter_Update_Response.Result.ACCEPTED ), ), ) - if accept: + if accepted is not None: # Apply the requested parameters utils.AsyncRunner.spawn( self.host.send_async_command( hci.HCI_LE_Connection_Update_Command( connection_handle=connection.handle, - connection_interval_min=request.interval_min, - connection_interval_max=request.interval_max, - max_latency=request.latency, - supervision_timeout=request.timeout, - min_ce_length=0, - max_ce_length=0, + connection_interval_min=int( + accepted.connection_interval_min / 1.25 + ), + connection_interval_max=int( + accepted.connection_interval_max / 1.25 + ), + max_latency=accepted.max_latency, + supervision_timeout=int(accepted.max_latency / 10), + min_ce_length=int(accepted.min_ce_length / 0.625), + max_ce_length=int(accepted.max_ce_length / 0.625), ) ) ) diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py index fbf22a9da..98310c505 100644 --- a/tests/l2cap_test.py +++ b/tests/l2cap_test.py @@ -24,7 +24,7 @@ import pytest -from bumble import core, l2cap +from bumble import core, device, l2cap from bumble.testing.test_utils import TwoDevices, async_barrier # ----------------------------------------------------------------------------- @@ -529,12 +529,40 @@ async def test_disconnection_collision(): # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_channel_manager_delegate(): +async def test_channel_manager_delegate_accept(): class TestDelegate(l2cap.ChannelManagerDelegate): def accept_connection_parameters( - self, interval_min: float, interval_max: float, latency: int, timeout: float - ) -> bool: - return False + self, + connection: device.Connection, + connection_parameters: l2cap.ChannelManagerDelegate.ConnectionParameters, + ) -> l2cap.ChannelManagerDelegate.ConnectionParameters | None: + return connection_parameters + + devices = await TwoDevices.create_with_connection() + devices.devices[0].l2cap_channel_manager.delegate = TestDelegate() + await devices.connections[1].update_parameters( + connection_interval_min=15.0, + connection_interval_max=30.0, + max_latency=3, + supervision_timeout=2000.0, + use_l2cap=True, + ) + + # NOTE: we can't really test the outcome of the parameter change request here + # because the current implementation of the virtual controller doesn't yet support + # HCI_LE_CONNECTION_UPDATE_COMMAND, so we just test that the request was accepted + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_channel_manager_delegate_reject(): + class TestDelegate(l2cap.ChannelManagerDelegate): + def accept_connection_parameters( + self, + connection: device.Connection, + connection_parameters: l2cap.ChannelManagerDelegate.ConnectionParameters, + ) -> l2cap.ChannelManagerDelegate.ConnectionParameters | None: + return None devices = await TwoDevices.create_with_connection() devices.devices[0].l2cap_channel_manager.delegate = TestDelegate() From 64f3adf83a2194168e8dc2e6c7d1c05d7625fb3f Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 31 Aug 2026 16:14:42 -0700 Subject: [PATCH 4/4] fix comment --- bumble/l2cap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 13454e647..01dcb9405 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -2616,7 +2616,7 @@ def on_l2cap_connection_parameter_update_request( ) if accepted is not None: - # Apply the requested parameters + # Apply the accepted parameters utils.AsyncRunner.spawn( self.host.send_async_command( hci.HCI_LE_Connection_Update_Command(