diff --git a/bumble/controller.py b/bumble/controller.py index 48329b11..9b572018 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -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 @@ def random_address(self, address: hci.Address | str) -> None: 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 @@ def on_lmp_packet(self, sender_address: hci.Address, packet: lmp.Packet) -> None 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 @@ def on_hci_remote_name_request_command( 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 @@ def on_hci_write_extended_inquiry_response_command( 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 @@ def on_hci_read_le_host_support_command( 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 @@ def on_hci_read_local_supported_commands_command( 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 @@ def on_hci_read_local_supported_features_command( 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 @@ def on_hci_read_local_extended_features_command( ''' 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 ], ) diff --git a/bumble/device.py b/bumble/device.py index dbaeb52e..65eba878 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1837,6 +1837,7 @@ def __init__( 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 @@ async def get_remote_le_features(self) -> hci.LeFeatureMask: 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 @@ def on_failure(handle: int, status: int): ) 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 diff --git a/bumble/host.py b/bumble/host.py index f0d0f144..46fd546b 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -1660,6 +1660,19 @@ def on_hci_encryption_change_event(self, event: hci.HCI_Encryption_Change_Event) '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 @@ def on_hci_inquiry_result_with_rssi_event( 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 ): diff --git a/bumble/lmp.py b/bumble/lmp.py index c37a57ee..d66f73e3 100644 --- a/bumble/lmp.py +++ b/bumble/lmp.py @@ -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)) diff --git a/tests/device_test.py b/tests/device_test.py index dcb83465..2d1b6cc9 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -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() diff --git a/tests/host_test.py b/tests/host_test.py index d7cc8332..02ad6172 100644 --- a/tests/host_test.py +++ b/tests/host_test.py @@ -22,6 +22,7 @@ import pytest +from bumble import controller, hci from bumble.controller import Controller from bumble.hci import ( HCI_AclDataPacket, @@ -49,34 +50,27 @@ # ----------------------------------------------------------------------------- @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)) )