Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions bumble/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -4396,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(
Expand All @@ -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
Expand Down
146 changes: 106 additions & 40 deletions bumble/l2cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))


# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -2038,6 +2035,44 @@ 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.
"""

@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,
connection: Connection,
connection_parameters: ConnectionParameters,
) -> ConnectionParameters | None:
"""
Decide whether to accept the given connection parameters, possibly with
modifications.

Args:
connection: the Connection on which the request was received.
connection_parameters: the requested parameters.

Returns:
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 connection_parameters


# -----------------------------------------------------------------------------
class ChannelManager:
identifiers: dict[int, int]
Expand All @@ -2058,12 +2093,17 @@ class ChannelManager:
],
]
_host: Host | None
connection_parameters_update_response: asyncio.Future[int] | None
# TODO: this should really be a per-connection Future.
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
Expand All @@ -2084,6 +2124,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:
Expand Down Expand Up @@ -2315,9 +2356,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'',
),
)
Expand All @@ -2327,15 +2368,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}')

Expand Down Expand Up @@ -2539,34 +2580,59 @@ 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
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(
connection,
cid,
L2CAP_Connection_Parameter_Update_Response(
identifier=request.identifier,
result=(
L2CAP_Connection_Parameter_Update_Response.Result.REJECTED
if accepted is None
else L2CAP_Connection_Parameter_Update_Response.Result.ACCEPTED
),
),
)

if accepted is not None:
# Apply the accepted parameters
utils.AsyncRunner.spawn(
self.host.send_async_command(
hci.HCI_LE_Connection_Update_Command(
connection_handle=connection.handle,
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),
)
)
)

async def update_connection_parameters(
Expand All @@ -2576,7 +2642,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')
Expand Down
58 changes: 44 additions & 14 deletions tests/l2cap_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
import asyncio
import itertools
import logging
import os
import random
from collections.abc import Sequence
from unittest import mock

import pytest

from bumble import core, l2cap
from bumble import core, device, l2cap
from bumble.testing.test_utils import TwoDevices, async_barrier

# -----------------------------------------------------------------------------
Expand All @@ -34,9 +33,6 @@
logger = logging.getLogger(__name__)


# -----------------------------------------------------------------------------


# -----------------------------------------------------------------------------
def test_helpers():
psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x01)
Expand Down Expand Up @@ -532,15 +528,49 @@ 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_accept():
class TestDelegate(l2cap.ChannelManagerDelegate):
def accept_connection_parameters(
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


# -----------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(run())
@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()
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,
)
Loading