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
6 changes: 5 additions & 1 deletion bumble/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
130 changes: 90 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,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]
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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'',
),
)
Expand All @@ -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}')

Expand Down Expand Up @@ -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(
Expand All @@ -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')
Expand Down
32 changes: 17 additions & 15 deletions tests/l2cap_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import asyncio
import itertools
import logging
import os
import random
from collections.abc import Sequence
from unittest import mock
Expand All @@ -35,9 +34,6 @@
logger = logging.getLogger(__name__)


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


# -----------------------------------------------------------------------------
def test_helpers():
psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x01)
Expand Down Expand Up @@ -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,
)
Loading