Merge pull request #900 from zxzxwu/lmp-feat

Add read classic remote features support
This commit is contained in:
Josh Wu
2026-03-24 14:03:29 +08:00
committed by GitHub
6 changed files with 370 additions and 33 deletions

View File

@@ -238,9 +238,12 @@ class Controller:
hci_revision: int = 0
lmp_version: int = hci.HCI_VERSION_BLUETOOTH_CORE_5_0
lmp_subversion: int = 0
lmp_features: bytes = bytes.fromhex(
'0000000060000000'
) # BR/EDR Not Supported, LE Supported (Controller)
lmp_features: hci.LmpFeatureMask = (
hci.LmpFeatureMask.LE_SUPPORTED_CONTROLLER
| hci.LmpFeatureMask.BR_EDR_NOT_SUPPORTED
| hci.LmpFeatureMask.EXTENDED_FEATURES
)
lmp_features_max_page_number: int = 3
manufacturer_company_identifier: int = 0xFFFF
acl_data_packet_length: int = 27
total_num_acl_data_packets: int = 64
@@ -250,10 +253,78 @@ class Controller:
total_num_iso_data_packets: int = 64
event_mask: int = 0
event_mask_page_2: int = 0
supported_commands: bytes = bytes.fromhex(
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
)
supported_commands: set[int] = {
hci.HCI_DISCONNECT_COMMAND,
hci.HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND,
hci.HCI_READ_CLOCK_OFFSET_COMMAND,
hci.HCI_READ_LMP_HANDLE_COMMAND,
hci.HCI_SET_EVENT_MASK_COMMAND,
hci.HCI_RESET_COMMAND,
hci.HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND,
hci.HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND,
hci.HCI_HOST_BUFFER_SIZE_COMMAND,
hci.HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND,
hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
hci.HCI_READ_BUFFER_SIZE_COMMAND,
hci.HCI_READ_BD_ADDR_COMMAND,
hci.HCI_READ_RSSI_COMMAND,
hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND,
hci.HCI_LE_SET_EVENT_MASK_COMMAND,
hci.HCI_LE_READ_BUFFER_SIZE_COMMAND,
hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
hci.HCI_LE_SET_RANDOM_ADDRESS_COMMAND,
hci.HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND,
hci.HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND,
hci.HCI_LE_SET_ADVERTISING_DATA_COMMAND,
hci.HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND,
hci.HCI_LE_SET_ADVERTISING_ENABLE_COMMAND,
hci.HCI_LE_SET_SCAN_PARAMETERS_COMMAND,
hci.HCI_LE_SET_SCAN_ENABLE_COMMAND,
hci.HCI_LE_CREATE_CONNECTION_COMMAND,
hci.HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND,
hci.HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND,
hci.HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND,
hci.HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND,
hci.HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND,
hci.HCI_LE_CONNECTION_UPDATE_COMMAND,
hci.HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND,
hci.HCI_LE_READ_CHANNEL_MAP_COMMAND,
hci.HCI_LE_READ_REMOTE_FEATURES_COMMAND,
hci.HCI_LE_ENCRYPT_COMMAND,
hci.HCI_LE_RAND_COMMAND,
hci.HCI_LE_ENABLE_ENCRYPTION_COMMAND,
hci.HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND,
hci.HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
hci.HCI_LE_READ_SUPPORTED_STATES_COMMAND,
hci.HCI_LE_RECEIVER_TEST_COMMAND,
hci.HCI_LE_TRANSMITTER_TEST_COMMAND,
hci.HCI_LE_TEST_END_COMMAND,
hci.HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
hci.HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
hci.HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND,
hci.HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND,
hci.HCI_LE_SET_DATA_LENGTH_COMMAND,
hci.HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
hci.HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND,
hci.HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND,
hci.HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
hci.HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND,
hci.HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND,
hci.HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND,
hci.HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND,
hci.HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND,
hci.HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
hci.HCI_LE_READ_PHY_COMMAND,
hci.HCI_LE_SET_DEFAULT_PHY_COMMAND,
hci.HCI_LE_SET_PHY_COMMAND,
hci.HCI_LE_RECEIVER_TEST_V2_COMMAND,
hci.HCI_LE_TRANSMITTER_TEST_V2_COMMAND,
hci.HCI_LE_READ_TRANSMIT_POWER_COMMAND,
hci.HCI_LE_SET_PRIVACY_MODE_COMMAND,
hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
}
le_event_mask: int = 0
le_features: hci.LeFeatureMask = (
hci.LeFeatureMask.LE_ENCRYPTION
@@ -392,6 +463,12 @@ class Controller:
if self.link:
self.link.on_address_changed(self)
@property
def lmp_features_bytes(self) -> bytes:
return self.lmp_features.to_bytes(
(self.lmp_features_max_page_number + 1) * 8, 'little'
)
# Packet Sink protocol (packets coming from the host via HCI)
def on_packet(self, packet: bytes) -> None:
self.on_hci_packet(hci.HCI_Packet.from_bytes(packet))
@@ -968,6 +1045,51 @@ class Controller:
packet.name_length,
packet.name_fregment,
)
case lmp.LmpFeaturesReq(features):
self.send_lmp_packet(
sender_address,
lmp.LmpFeaturesRes(features=self.lmp_features_bytes[:8]),
)
case lmp.LmpFeaturesRes(features):
if connection := self.classic_connections.get(sender_address):
self.send_hci_packet(
hci.HCI_Read_Remote_Supported_Features_Complete_Event(
status=hci.HCI_ErrorCode.SUCCESS,
connection_handle=connection.handle,
lmp_features=features,
)
)
case lmp.LmpFeaturesReqExt(features_page, features):
# Calculate start/end of page
page_start = features_page * 8
page_end = page_start + 8
features_bytes = self.lmp_features_bytes
if page_start < len(features_bytes):
page_features = features_bytes[page_start:page_end].ljust(
8, b'\x00'
)
else:
page_features = b'\x00' * 8
self.send_lmp_packet(
sender_address,
lmp.LmpFeaturesResExt(
features_page=features_page,
max_features_page=len(features_bytes) // 8 - 1,
features=page_features,
),
)
case lmp.LmpFeaturesResExt(features_page, max_features_page, features):
if connection := self.classic_connections.get(sender_address):
self.send_hci_packet(
hci.HCI_Read_Remote_Extended_Features_Complete_Event(
status=hci.HCI_ErrorCode.SUCCESS,
connection_handle=connection.handle,
page_number=features_page,
maximum_page_number=max_features_page,
extended_lmp_features=features,
)
)
case _:
logger.error("!!! Unhandled packet: %s", packet)
@@ -1349,6 +1471,53 @@ class Controller:
return None
def on_hci_read_remote_supported_features_command(
self, command: hci.HCI_Read_Remote_Supported_Features_Command
) -> None:
'''
See Bluetooth spec Vol 4, Part E - 7.1.20 Read Remote Supported Features command
'''
handle = command.connection_handle
if not (connection := self.find_classic_connection_by_handle(handle)):
self._send_hci_command_status(
hci.HCI_ErrorCode.UNKNOWN_CONNECTION_IDENTIFIER_ERROR, command.op_code
)
return None
self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code)
self.send_lmp_packet(
connection.peer_address,
lmp.LmpFeaturesReq(self.lmp_features_bytes[:8]),
)
return None
def on_hci_read_remote_extended_features_command(
self, command: hci.HCI_Read_Remote_Extended_Features_Command
) -> None:
'''
See Bluetooth spec Vol 4, Part E - 7.1.21 Read Remote Extended Features command
'''
handle = command.connection_handle
if not (connection := self.find_classic_connection_by_handle(handle)):
self._send_hci_command_status(
hci.HCI_ErrorCode.UNKNOWN_CONNECTION_IDENTIFIER_ERROR, command.op_code
)
return None
self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code)
self.send_lmp_packet(
connection.peer_address,
lmp.LmpFeaturesReqExt(
features_page=command.page_number,
features=self.lmp_features_bytes[
command.page_number * 8 : (command.page_number + 1) * 8
],
),
)
return None
def on_hci_enhanced_setup_synchronous_connection_command(
self, command: hci.HCI_Enhanced_Setup_Synchronous_Connection_Command
) -> None:
@@ -1645,11 +1814,15 @@ class Controller:
return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS)
def on_hci_write_simple_pairing_mode_command(
self, _command: hci.HCI_Write_Simple_Pairing_Mode_Command
self, command: hci.HCI_Write_Simple_Pairing_Mode_Command
) -> hci.HCI_StatusReturnParameters:
'''
See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
'''
if command.simple_pairing_mode:
self.lmp_features |= hci.LmpFeatureMask.SECURE_SIMPLE_PAIRING_HOST_SUPPORT
else:
self.lmp_features &= ~hci.LmpFeatureMask.SECURE_SIMPLE_PAIRING_HOST_SUPPORT
return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS)
def on_hci_set_event_mask_page_2_command(
@@ -1670,16 +1843,23 @@ class Controller:
See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
'''
return hci.HCI_Read_LE_Host_Support_ReturnParameters(
status=hci.HCI_ErrorCode.SUCCESS, le_supported_host=1, unused=0
status=hci.HCI_ErrorCode.SUCCESS,
le_supported_host=(
1 if self.lmp_features & hci.LmpFeatureMask.LE_SUPPORTED_HOST else 0
),
unused=0,
)
def on_hci_write_le_host_support_command(
self, _command: hci.HCI_Write_LE_Host_Support_Command
self, command: hci.HCI_Write_LE_Host_Support_Command
) -> hci.HCI_StatusReturnParameters:
'''
See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
'''
# TODO / Just ignore for now
if command.le_supported_host:
self.lmp_features |= hci.LmpFeatureMask.LE_SUPPORTED_HOST
else:
self.lmp_features &= ~hci.LmpFeatureMask.LE_SUPPORTED_HOST
return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS)
def on_hci_write_authenticated_payload_timeout_command(
@@ -1716,7 +1896,11 @@ class Controller:
See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
'''
return hci.HCI_Read_Local_Supported_Commands_ReturnParameters(
hci.HCI_ErrorCode.SUCCESS, supported_commands=self.supported_commands
hci.HCI_ErrorCode.SUCCESS,
supported_commands=sum(
hci.HCI_SUPPORTED_COMMANDS_MASKS.get(opcode, 0)
for opcode in self.supported_commands
).to_bytes(64, 'little'),
)
def on_hci_read_local_supported_features_command(
@@ -1726,7 +1910,8 @@ class Controller:
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
'''
return hci.HCI_Read_Local_Supported_Features_ReturnParameters(
hci.HCI_ErrorCode.SUCCESS, lmp_features=self.lmp_features[:8]
hci.HCI_ErrorCode.SUCCESS,
lmp_features=self.lmp_features_bytes[:8],
)
def on_hci_read_local_extended_features_command(
@@ -1735,18 +1920,19 @@ class Controller:
'''
See Bluetooth spec Vol 4, Part E - 7.4.4 Read Local Extended Features Command
'''
if command.page_number * 8 > len(self.lmp_features):
feature_bytes = self.lmp_features_bytes
if command.page_number * 8 > len(feature_bytes):
return hci.HCI_Read_Local_Extended_Features_ReturnParameters(
status=hci.HCI_ErrorCode.INVALID_COMMAND_PARAMETERS_ERROR,
page_number=command.page_number,
maximum_page_number=len(self.lmp_features) // 8 - 1,
maximum_page_number=len(feature_bytes) // 8 - 1,
extended_lmp_features=bytes(8),
)
return hci.HCI_Read_Local_Extended_Features_ReturnParameters(
status=hci.HCI_ErrorCode.SUCCESS,
page_number=command.page_number,
maximum_page_number=len(self.lmp_features) // 8 - 1,
extended_lmp_features=self.lmp_features[
maximum_page_number=len(feature_bytes) // 8 - 1,
extended_lmp_features=feature_bytes[
command.page_number * 8 : (command.page_number + 1) * 8
],
)

View File

@@ -1837,6 +1837,7 @@ class Connection(utils.CompositeEventEmitter):
self.pairing_peer_io_capability = None
self.pairing_peer_authentication_requirements = None
self.peer_le_features = hci.LeFeatureMask(0)
self.peer_classic_features = hci.LmpFeatureMask(0)
self.cs_configs = {}
self.cs_procedures = {}
@@ -2054,6 +2055,15 @@ class Connection(utils.CompositeEventEmitter):
self.peer_le_features = await self.device.get_remote_le_features(self)
return self.peer_le_features
async def get_remote_classic_features(self) -> hci.LmpFeatureMask:
"""[Classic Only] Reads remote LMP supported features.
Returns:
LMP features supported by the remote device.
"""
self.peer_classic_features = await self.device.get_remote_classic_features(self)
return self.peer_classic_features
def on_att_mtu_update(self, mtu: int):
logger.debug(
f'*** Connection ATT MTU Update: [0x{self.handle:04X}] '
@@ -5281,6 +5291,77 @@ class Device(utils.CompositeEventEmitter):
)
return await read_feature_future
async def get_remote_classic_features(
self, connection: Connection
) -> hci.LmpFeatureMask:
"""[Classic Only] Reads remote LE supported features.
Args:
handle: connection handle to read LMP features.
Returns:
LMP features supported by the remote device.
"""
with closing(utils.EventWatcher()) as watcher:
read_feature_future: asyncio.Future[tuple[int, int]] = (
asyncio.get_running_loop().create_future()
)
read_features = hci.LmpFeatureMask(0)
current_page_number = 0
@watcher.on(self.host, 'classic_remote_features')
def on_classic_remote_features(
handle: int,
status: int,
features: int,
page_number: int,
max_page_number: int,
) -> None:
if handle != connection.handle:
logger.warning(
"Received classic_remote_features for wrong handle, expected=0x%04X, got=0x%04X",
connection.handle,
handle,
)
return
if page_number != current_page_number:
logger.warning(
"Received classic_remote_features for wrong page, expected=%d, got=%d",
current_page_number,
page_number,
)
return
if status == hci.HCI_ErrorCode.SUCCESS:
read_feature_future.set_result((features, max_page_number))
else:
read_feature_future.set_exception(hci.HCI_Error(status))
await self.send_async_command(
hci.HCI_Read_Remote_Supported_Features_Command(
connection_handle=connection.handle
)
)
new_features, max_page_number = await read_feature_future
read_features |= new_features
if not (read_features & hci.LmpFeatureMask.EXTENDED_FEATURES):
return read_features
while current_page_number <= max_page_number:
read_feature_future = asyncio.get_running_loop().create_future()
await self.send_async_command(
hci.HCI_Read_Remote_Extended_Features_Command(
connection_handle=connection.handle,
page_number=current_page_number,
)
)
new_features, max_page_number = await read_feature_future
read_features |= new_features << (current_page_number * 64)
current_page_number += 1
return read_features
@utils.experimental('Only for testing.')
async def get_remote_cs_capabilities(
self, connection: Connection

View File

@@ -1660,6 +1660,19 @@ class Host(utils.EventEmitter):
'connection_encryption_failure', event.connection_handle, event.status
)
def on_hci_read_remote_supported_features_complete_event(
self, event: hci.HCI_Read_Remote_Supported_Features_Complete_Event
) -> None:
# Notify the client
self.emit(
'classic_remote_features',
event.connection_handle,
event.status,
int.from_bytes(event.lmp_features, 'little'),
0, # page number
0, # max page number
)
def on_hci_encryption_change_v2_event(
self, event: hci.HCI_Encryption_Change_V2_Event
):
@@ -1816,6 +1829,18 @@ class Host(utils.EventEmitter):
rssi,
)
def on_hci_read_remote_extended_features_complete_event(
self, event: hci.HCI_Read_Remote_Extended_Features_Complete_Event
):
self.emit(
'classic_remote_features',
event.connection_handle,
event.status,
int.from_bytes(event.extended_lmp_features, 'little'),
event.page_number,
event.maximum_page_number,
)
def on_hci_extended_inquiry_result_event(
self, event: hci.HCI_Extended_Inquiry_Result_Event
):

View File

@@ -322,3 +322,38 @@ class LmpNameRes(Packet):
name_offset: int = field(metadata=hci.metadata(2))
name_length: int = field(metadata=hci.metadata(3))
name_fregment: bytes = field(metadata=hci.metadata('*'))
@Packet.subclass
@dataclass
class LmpFeaturesReq(Packet):
opcode = Opcode.LMP_FEATURES_REQ
features: bytes = field(metadata=hci.metadata(8))
@Packet.subclass
@dataclass
class LmpFeaturesRes(Packet):
opcode = Opcode.LMP_FEATURES_RES
features: bytes = field(metadata=hci.metadata(8))
@Packet.subclass
@dataclass
class LmpFeaturesReqExt(Packet):
opcode = Opcode.LMP_FEATURES_REQ_EXT
features_page: int = field(metadata=hci.metadata(1))
features: bytes = field(metadata=hci.metadata(8))
@Packet.subclass
@dataclass
class LmpFeaturesResExt(Packet):
opcode = Opcode.LMP_FEATURES_RES_EXT
features_page: int = field(metadata=hci.metadata(1))
max_features_page: int = field(metadata=hci.metadata(1))
features: bytes = field(metadata=hci.metadata(8))

View File

@@ -826,6 +826,22 @@ async def test_remote_name_request():
assert actual_name == expected_name
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_remote_classic_features():
devices = TwoDevices()
devices[0].classic_enabled = True
devices[1].classic_enabled = True
await devices[0].power_on()
await devices[1].power_on()
connection = await devices[0].connect_classic(devices[1].public_address)
assert (
await asyncio.wait_for(connection.get_remote_classic_features(), _TIMEOUT)
== devices.controllers[1].lmp_features
)
# -----------------------------------------------------------------------------
async def run_test_device():
await test_device_connect_parallel()

View File

@@ -22,6 +22,7 @@ import unittest.mock
import pytest
from bumble import controller, hci
from bumble.controller import Controller
from bumble.hci import (
HCI_AclDataPacket,
@@ -49,34 +50,27 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
'supported_commands, lmp_features',
'supported_commands, max_lmp_features_page_number',
[
(
# Default commands
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004000000000000000000000000000000000000000000000000',
# Only LE LMP feature
'0000000060000000',
),
(controller.Controller.supported_commands, 0),
(
# All commands
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
set(hci.HCI_Command.command_names.keys()),
# 3 pages of LMP features
'000102030405060708090A0B0C0D0E0F011112131415161718191A1B1C1D1E1F',
2,
),
],
)
async def test_reset(supported_commands: str, lmp_features: str):
async def test_reset(supported_commands: set[int], max_lmp_features_page_number: int):
controller = Controller('C')
controller.supported_commands = bytes.fromhex(supported_commands)
controller.lmp_features = bytes.fromhex(lmp_features)
controller.supported_commands = supported_commands
controller.lmp_features_max_page_number = max_lmp_features_page_number
host = Host(controller, AsyncPipeSink(controller))
await host.reset()
assert host.local_lmp_features == int.from_bytes(
bytes.fromhex(lmp_features), 'little'
assert host.local_lmp_features == (
controller.lmp_features & ~(1 << (64 * max_lmp_features_page_number + 1))
)