diff --git a/bumble/device.py b/bumble/device.py index f3b431c5..33b6fd00 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -2368,6 +2368,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" @@ -4409,7 +4410,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 76e672c2..4e5e1d22 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,34 @@ 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: float, interval_max: float, latency: int, timeout: float + ) -> bool: + """ + Decide whether to accept the given connection parameters. + + 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. + """ + return True + + # ----------------------------------------------------------------------------- class ChannelManager: identifiers: dict[int, int] @@ -2058,12 +2083,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 +2113,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 +2345,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 +2357,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 +2569,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 * 1.25, + request.interval_max * 1.25, + request.latency, + request.timeout * 10.0, + ) + + # 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 +2626,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 982db66e..b1bc8c42 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 @@ -35,9 +34,6 @@ logger = logging.getLogger(__name__) -# ----------------------------------------------------------------------------- - - # ----------------------------------------------------------------------------- def test_helpers(): psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x01) @@ -533,15 +529,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: float, interval_max: float, latency: int, timeout: float + ) -> 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.0, + connection_interval_max=30.0, + max_latency=3, + supervision_timeout=2000.0, + use_l2cap=True, + )