Compare commits

..

2 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 1f246f8782 scale parameters 2026-07-10 15:35:13 +02:00
Gilles Boccon-Gibod 8e97f09041 add support for channel manager delegates 2026-07-10 15:20:41 +02:00
5 changed files with 115 additions and 83 deletions
+7 -4
View File
@@ -913,8 +913,7 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
)
)
if self.state == self.State.INIT:
self.state = self.State.PENDING
self.state = self.State.PENDING
async def terminate(self) -> None:
if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
@@ -2369,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"
@@ -4410,7 +4410,10 @@ class Device(utils.CompositeEventEmitter):
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
@@ -5443,7 +5446,7 @@ class Device(utils.CompositeEventEmitter):
role: int = hci.CsRole.INITIATOR,
rtt_type: int = hci.RttType.AA_ONLY,
cs_sync_phy: int = hci.CsSyncPhy.LE_1M,
channel_map: bytes = b'\x54\x55\x55\x54\x55\x55\x55\x55\x55\x05',
channel_map: bytes = b'\x54\x55\x55\x54\x55\x55\x55\x55\x55\x15',
channel_map_repetition: int = 0x01,
channel_selection_type: int = hci.HCI_LE_CS_Create_Config_Command.ChannelSelectionType.ALGO_3B,
ch3c_shape: int = hci.HCI_LE_CS_Create_Config_Command.Ch3cShape.HAT,
+90 -40
View File
@@ -88,7 +88,7 @@ L2CAP_LE_PSM_DYNAMIC_RANGE_START = 0x0080
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 @@ class L2CAP_Control_Frame:
# -----------------------------------------------------------------------------
@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 @@ class LeCreditBasedChannelServer(utils.EventEmitter):
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 @@ class ChannelManager:
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 @@ class ChannelManager:
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 @@ class ChannelManager:
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,35 +2569,55 @@ class ChannelManager:
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,
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,
)
)
)
else:
self.send_control_frame(
connection,
cid,
L2CAP_Connection_Parameter_Update_Response(
identifier=request.identifier,
result=L2CAP_CONNECTION_PARAMETERS_REJECTED_RESULT,
),
)
async def update_connection_parameters(
self,
@@ -2576,7 +2626,7 @@ class ChannelManager:
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')
+1 -4
View File
@@ -729,10 +729,7 @@ class UsbTransport(Transport):
# We no longer need the event loop to run
with self.lock:
self.event_loop_should_exit = True
try:
self.context.interruptEventHandler()
except (AttributeError, usb1.USBError) as error:
logger.warning(f"Failed to interrupt event handler: {error}")
self.context.interruptEventHandler()
self.device.releaseInterface(self.acl_interface.getNumber())
if self.sco_interface:
-20
View File
@@ -17,7 +17,6 @@
# -----------------------------------------------------------------------------
import asyncio
import functools
import inspect
import logging
import os
from unittest import mock
@@ -694,25 +693,6 @@ async def test_power_on_default_static_address_should_not_be_any():
assert devices[0].static_address != Address.ANY_RANDOM
# -----------------------------------------------------------------------------
def test_cs_channel_map_excludes_forbidden_channels():
forbidden = {0, 1, 23, 24, 25, 76, 77, 78, 79}
default_map = (
inspect.signature(Device.create_cs_config).parameters['channel_map'].default
)
enabled = {
byte_idx * 8 + bit
for byte_idx, byte in enumerate(default_map)
for bit in range(8)
if byte & (1 << bit)
}
assert enabled.isdisjoint(
forbidden
), f"Default channel_map enables forbidden CS channels: {enabled & forbidden}"
# -----------------------------------------------------------------------------
def test_gatt_services_with_gas_and_gatt():
device = Device(host=Host(None, None))
+17 -15
View File
@@ -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 @@ from .test_utils import TwoDevices, async_barrier
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,
)