diff --git a/bumble/controller.py b/bumble/controller.py index 2d94dd91..b126f0e7 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -128,6 +128,7 @@ class AdvertisingSet: parameters: hci.HCI_LE_Set_Extended_Advertising_Parameters_Command | None = None data: bytearray = dataclasses.field(default_factory=bytearray) scan_response_data: bytearray = dataclasses.field(default_factory=bytearray) + periodic_advertising_data: bytearray = dataclasses.field(default_factory=bytearray) enabled: bool = False timer_handle: asyncio.Handle | None = None random_address: hci.Address | None = None @@ -148,10 +149,13 @@ def _on_extended_advertising_timer_fired(self) -> None: self.send_extended_advertising_data() - interval = ( - self.parameters.primary_advertising_interval_min * 0.625 / 1000.0 - if self.parameters - else 1.0 + interval = min( + ( + self.parameters.primary_advertising_interval_min * 0.625 / 1000.0 + if self.parameters + else 1.0 + ), + 0.01, ) self.timer_handle = asyncio.get_running_loop().call_later( interval, self._on_extended_advertising_timer_fired @@ -171,8 +175,19 @@ def send_extended_advertising_data(self) -> None: if self.controller.link: address = self.address assert address - - self.controller.send_advertising_pdu(ll.AdvInd(address, bytes(self.data))) + sid = self.parameters.advertising_sid if self.parameters else 0 + self.controller.send_advertising_pdu( + ll.AdvExtInd( + advertiser_address=address, + data=bytes(self.data), + sid=sid, + periodic_advertising_data=( + bytes(self.periodic_advertising_data) + if self.periodic_advertising_data + else None + ), + ) + ) # ----------------------------------------------------------------------------- @@ -366,6 +381,8 @@ class Controller: | hci.LeFeatureMask.LE_CODED_PHY | hci.LeFeatureMask.CHANNEL_SELECTION_ALGORITHM_2 | hci.LeFeatureMask.MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE + | hci.LeFeatureMask.LE_EXTENDED_ADVERTISING + | hci.LeFeatureMask.LE_PERIODIC_ADVERTISING ) le_states: bytes = bytes.fromhex('ffff3fffff030000') advertising_channel_tx_power: int = 0 @@ -419,6 +436,10 @@ def __init__( self.central_cis_links = {} self.peripheral_cis_links = {} self.advertising_sets = {} + self.pending_periodic_advertising_syncs: dict[tuple[hci.Address, int], int] = {} + self.established_periodic_advertising_syncs: dict[ + int, tuple[hci.Address, int] + ] = {} self.default_phy = { 'all_phys': 0, 'tx_phys': 0, @@ -662,6 +683,184 @@ def on_ll_control_pdu( le_features=feature_set, ) ) + case ll.ConnectionUpdateInd(): + self.send_hci_packet( + hci.HCI_LE_Connection_Update_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + connection_interval=packet.interval, + peripheral_latency=packet.latency, + supervision_timeout=packet.timeout, + ) + ) + case ll.ConnectionRateInd(): + self.send_hci_packet( + hci.HCI_LE_Connection_Rate_Change_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + connection_interval=packet.interval, + subrate_factor=packet.subrate_factor, + peripheral_latency=packet.peripheral_latency, + continuation_number=packet.continuation_number, + supervision_timeout=packet.timeout, + ) + ) + case ll.SubrateInd(): + self.send_hci_packet( + hci.HCI_LE_Subrate_Change_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + subrate_factor=packet.subrate_factor, + peripheral_latency=packet.peripheral_latency, + continuation_number=packet.continuation_number, + supervision_timeout=packet.timeout, + ) + ) + case ll.CsConfigReq(): + self.send_hci_packet( + hci.HCI_LE_CS_Config_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + config_id=packet.config_id, + action=packet.action, + main_mode_type=packet.main_mode_type, + sub_mode_type=packet.sub_mode_type, + min_main_mode_steps=packet.min_main_mode_steps, + max_main_mode_steps=packet.max_main_mode_steps, + main_mode_repetition=packet.main_mode_repetition, + mode_0_steps=packet.mode_0_steps, + role=packet.role, + rtt_type=packet.rtt_type, + cs_sync_phy=packet.cs_sync_phy, + channel_map=packet.channel_map, + channel_map_repetition=packet.channel_map_repetition, + channel_selection_type=packet.channel_selection_type, + ch3c_shape=packet.ch3c_shape, + ch3c_jump=packet.ch3c_jump, + reserved=0, + t_ip1_time=10, + t_ip2_time=10, + t_fcs_time=10, + t_pm_time=10, + ) + ) + reply_role = ( + hci.CsRole.INITIATOR + if packet.role == hci.CsRole.REFLECTOR + else hci.CsRole.REFLECTOR + ) + connection.send_ll_control_pdu( + ll.CsConfigRsp( + config_id=packet.config_id, + action=packet.action, + main_mode_type=packet.main_mode_type, + sub_mode_type=packet.sub_mode_type, + min_main_mode_steps=packet.min_main_mode_steps, + max_main_mode_steps=packet.max_main_mode_steps, + main_mode_repetition=packet.main_mode_repetition, + mode_0_steps=packet.mode_0_steps, + role=reply_role, + rtt_type=packet.rtt_type, + cs_sync_phy=packet.cs_sync_phy, + channel_map=packet.channel_map, + channel_map_repetition=packet.channel_map_repetition, + channel_selection_type=packet.channel_selection_type, + ch3c_shape=packet.ch3c_shape, + ch3c_jump=packet.ch3c_jump, + ) + ) + case ll.CsConfigRsp(): + self.send_hci_packet( + hci.HCI_LE_CS_Config_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + config_id=packet.config_id, + action=packet.action, + main_mode_type=packet.main_mode_type, + sub_mode_type=packet.sub_mode_type, + min_main_mode_steps=packet.min_main_mode_steps, + max_main_mode_steps=packet.max_main_mode_steps, + main_mode_repetition=packet.main_mode_repetition, + mode_0_steps=packet.mode_0_steps, + role=packet.role, + rtt_type=packet.rtt_type, + cs_sync_phy=packet.cs_sync_phy, + channel_map=packet.channel_map, + channel_map_repetition=packet.channel_map_repetition, + channel_selection_type=packet.channel_selection_type, + ch3c_shape=packet.ch3c_shape, + ch3c_jump=packet.ch3c_jump, + reserved=0, + t_ip1_time=10, + t_ip2_time=10, + t_fcs_time=10, + t_pm_time=10, + ) + ) + case ll.CsSecReq(): + self.send_hci_packet( + hci.HCI_LE_CS_Security_Enable_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + ) + ) + connection.send_ll_control_pdu(ll.CsSecRsp()) + case ll.CsSecRsp(): + self.send_hci_packet( + hci.HCI_LE_CS_Security_Enable_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + ) + ) + case ll.CsReq(): + connection.send_ll_control_pdu( + ll.CsRsp( + config_id=packet.config_id, + state=packet.state, + ) + ) + case ll.CsRsp(): + connection.send_ll_control_pdu( + ll.CsInd( + config_id=packet.config_id, + state=packet.state, + ) + ) + self.send_hci_packet( + hci.HCI_LE_CS_Procedure_Enable_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + config_id=packet.config_id, + state=packet.state, + tone_antenna_config_selection=0, + selected_tx_power=0, + subevent_len=1250, + subevents_per_event=1, + subevent_interval=10, + event_interval=10, + procedure_interval=10, + procedure_count=1, + max_procedure_len=100, + ) + ) + case ll.CsInd(): + self.send_hci_packet( + hci.HCI_LE_CS_Procedure_Enable_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=connection.handle, + config_id=packet.config_id, + state=packet.state, + tone_antenna_config_selection=0, + selected_tx_power=0, + subevent_len=1250, + subevents_per_event=1, + subevent_interval=10, + event_interval=10, + procedure_interval=10, + procedure_count=1, + max_procedure_len=100, + ) + ) def on_ll_advertising_pdu(self, packet: ll.AdvertisingPdu) -> None: logger.debug("[%s] <<< Advertising PDU: %s", self.name, packet) @@ -849,6 +1048,42 @@ def on_link_acl_data( def on_advertising_pdu(self, pdu: ll.AdvInd | ll.AdvExtInd) -> None: if isinstance(pdu, ll.AdvExtInd): direct_address = pdu.target_address + sync_key = (pdu.advertiser_address, pdu.sid) + if sync_handle := self.pending_periodic_advertising_syncs.pop( + sync_key, None + ): + self.established_periodic_advertising_syncs[sync_handle] = sync_key + self.send_hci_packet( + hci.HCI_LE_Periodic_Advertising_Sync_Established_Event( + status=hci.HCI_ErrorCode.SUCCESS, + sync_handle=sync_handle, + advertising_sid=pdu.sid, + advertiser_address_type=pdu.advertiser_address.address_type, + advertiser_address=pdu.advertiser_address, + advertiser_phy=hci.Phy.LE_1M, + periodic_advertising_interval=80, + advertiser_clock_accuracy=0, + ) + ) + for sync_handle, ( + adv_addr, + sid, + ) in self.established_periodic_advertising_syncs.items(): + if ( + adv_addr == pdu.advertiser_address + and sid == pdu.sid + and pdu.periodic_advertising_data is not None + ): + self.send_hci_packet( + hci.HCI_LE_Periodic_Advertising_Report_Event( + sync_handle=sync_handle, + tx_power=0, + rssi=-50, + cte_type=0xFF, + data_status=0, + data=pdu.periodic_advertising_data, + ) + ) else: direct_address = None @@ -2615,6 +2850,15 @@ def on_hci_le_subrate_request_command( supervision_timeout=command.supervision_timeout, ) ) + if connection := self.find_le_connection_by_handle(command.connection_handle): + connection.send_ll_control_pdu( + ll.SubrateInd( + subrate_factor=2, + peripheral_latency=2, + continuation_number=command.continuation_number, + timeout=command.supervision_timeout, + ) + ) return None def on_hci_le_set_event_mask_command( @@ -2783,6 +3027,26 @@ def on_hci_le_set_scan_enable_command( self.filter_duplicates = bool(command.filter_duplicates) return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + def on_hci_le_set_extended_scan_parameters_command( + self, command: hci.HCI_LE_Set_Extended_Scan_Parameters_Command + ) -> hci.HCI_StatusReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.64 LE Set Extended Scan Parameters Command + ''' + self.le_scan_own_address_type = hci.AddressType(command.own_address_type) + self.le_scanning_filter_policy = command.scanning_filter_policy + return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + + def on_hci_le_set_extended_scan_enable_command( + self, command: hci.HCI_LE_Set_Extended_Scan_Enable_Command + ) -> hci.HCI_StatusReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.65 LE Set Extended Scan Enable Command + ''' + self.le_scan_enable = bool(command.enable) + self.filter_duplicates = bool(command.filter_duplicates) + return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + def on_hci_le_create_connection_command( self, command: hci.HCI_LE_Create_Connection_Command ) -> None: @@ -3253,21 +3517,28 @@ def on_hci_le_set_periodic_advertising_parameters_command( return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) def on_hci_le_set_periodic_advertising_data_command( - self, _command: hci.HCI_LE_Set_Periodic_Advertising_Data_Command + self, command: hci.HCI_LE_Set_Periodic_Advertising_Data_Command ) -> hci.HCI_StatusReturnParameters: ''' See Bluetooth spec Vol 4, Part E - 7.8.62 LE Set Periodic Advertising Data Command ''' + if adv_set := self.advertising_sets.get(command.advertising_handle): + adv_set.periodic_advertising_data = bytearray(command.advertising_data) return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) def on_hci_le_set_periodic_advertising_enable_command( - self, _command: hci.HCI_LE_Set_Periodic_Advertising_Enable_Command + self, command: hci.HCI_LE_Set_Periodic_Advertising_Enable_Command ) -> hci.HCI_StatusReturnParameters: ''' See Bluetooth spec Vol 4, Part E - 7.8.63 LE Set Periodic Advertising Enable Command ''' + if adv_set := self.advertising_sets.get(command.advertising_handle): + if command.enable & 0x01: + adv_set.start() + else: + adv_set.stop() return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) def on_hci_le_read_transmit_power_command( @@ -3437,3 +3708,310 @@ def on_hci_le_set_host_feature_command( See Bluetooth spec Vol 4, Part E - 7.8.115 LE Set Host Feature command ''' return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + + def on_hci_le_connection_update_command( + self, command: hci.HCI_LE_Connection_Update_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.18 LE Connection Update Command + ''' + if not ( + connection := self.find_le_connection_by_handle(command.connection_handle) + ): + self._send_hci_command_status( + hci.HCI_ErrorCode.UNKNOWN_CONNECTION_IDENTIFIER_ERROR, command.op_code + ) + return + + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + + event = hci.HCI_LE_Connection_Update_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=command.connection_handle, + connection_interval=command.connection_interval_max, + peripheral_latency=command.max_latency, + supervision_timeout=command.supervision_timeout, + ) + self.send_hci_packet(event) + + connection.send_ll_control_pdu( + ll.ConnectionUpdateInd( + interval=command.connection_interval_max, + latency=command.max_latency, + timeout=command.supervision_timeout, + ) + ) + + def on_hci_le_connection_rate_request_command( + self, command: hci.HCI_LE_Connection_Rate_Request_Command + ) -> None: + ''' + See Bluetooth spec Vol 6, Part E - 7.8.125 LE Connection Rate Request Command + ''' + if not ( + connection := self.find_le_connection_by_handle(command.connection_handle) + ): + self._send_hci_command_status( + hci.HCI_ErrorCode.UNKNOWN_CONNECTION_IDENTIFIER_ERROR, command.op_code + ) + return + + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + + self.send_hci_packet( + hci.HCI_LE_Connection_Rate_Change_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=command.connection_handle, + connection_interval=command.connection_interval_max, + subrate_factor=command.subrate_max, + peripheral_latency=command.max_latency, + continuation_number=command.continuation_number, + supervision_timeout=command.supervision_timeout, + ) + ) + connection.send_ll_control_pdu( + ll.ConnectionRateInd( + interval=command.connection_interval_max, + subrate_factor=command.subrate_max, + peripheral_latency=command.max_latency, + continuation_number=command.continuation_number, + timeout=command.supervision_timeout, + ) + ) + + def on_hci_le_periodic_advertising_create_sync_command( + self, command: hci.HCI_LE_Periodic_Advertising_Create_Sync_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.67 LE Periodic Advertising Create Sync Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + sync_handle = 0x0010 + command.advertising_sid + sync_key = (command.advertiser_address, command.advertising_sid) + self.pending_periodic_advertising_syncs[sync_key] = sync_handle + + def on_sync_timeout() -> None: + if handle := self.pending_periodic_advertising_syncs.pop(sync_key, None): + self.send_hci_packet( + hci.HCI_LE_Periodic_Advertising_Sync_Established_Event( + status=hci.HCI_ErrorCode.CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR, + sync_handle=handle, + advertising_sid=command.advertising_sid, + advertiser_address_type=command.advertiser_address.address_type, + advertiser_address=command.advertiser_address, + advertiser_phy=hci.Phy.LE_1M, + periodic_advertising_interval=0, + advertiser_clock_accuracy=0, + ) + ) + + timeout_s = command.sync_timeout * 0.01 + asyncio.get_running_loop().call_later(timeout_s, on_sync_timeout) + + def on_hci_le_periodic_advertising_create_sync_cancel_command( + self, _command: hci.HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command + ) -> hci.HCI_StatusReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.68 LE Periodic Advertising Create Sync Cancel + Command + ''' + if not self.pending_periodic_advertising_syncs: + return hci.HCI_StatusReturnParameters( + hci.HCI_ErrorCode.COMMAND_DISALLOWED_ERROR + ) + (adv_addr, sid), sync_handle = self.pending_periodic_advertising_syncs.popitem() + self.send_hci_packet( + hci.HCI_LE_Periodic_Advertising_Sync_Established_Event( + status=hci.HCI_ErrorCode.OPERATION_CANCELLED_BY_HOST_ERROR, + sync_handle=sync_handle, + advertising_sid=sid, + advertiser_address_type=adv_addr.address_type, + advertiser_address=adv_addr, + advertiser_phy=hci.Phy.LE_1M, + periodic_advertising_interval=0, + advertiser_clock_accuracy=0, + ) + ) + return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + + def on_hci_le_periodic_advertising_terminate_sync_command( + self, command: hci.HCI_LE_Periodic_Advertising_Terminate_Sync_Command + ) -> hci.HCI_StatusReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.69 LE Periodic Advertising Terminate Sync Command + ''' + self.established_periodic_advertising_syncs.pop(command.sync_handle, None) + return hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode.SUCCESS) + + def on_hci_le_create_big_command( + self, command: hci.HCI_LE_Create_BIG_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.103 LE Create BIG Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + handles = [0x0100 + i for i in range(command.num_bis)] + self.send_hci_packet( + hci.HCI_LE_Create_BIG_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + big_handle=command.big_handle, + big_sync_delay=1000, + transport_latency_big=2000, + phy=command.phy, + nse=1, + bn=1, + pto=0, + irc=1, + max_pdu=command.max_sdu, + iso_interval=8, + connection_handle=handles, + ) + ) + + def on_hci_le_terminate_big_command( + self, command: hci.HCI_LE_Terminate_BIG_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.105 LE Terminate BIG Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + self.send_hci_packet( + hci.HCI_LE_Terminate_BIG_Complete_Event( + big_handle=command.big_handle, + reason=command.reason, + ) + ) + + def on_hci_le_big_create_sync_command( + self, command: hci.HCI_LE_BIG_Create_Sync_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.106 LE BIG Create Sync Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + handles = [0x0200 + i for i in range(len(command.bis))] + self.send_hci_packet( + hci.HCI_LE_BIG_Sync_Established_Event( + status=hci.HCI_ErrorCode.SUCCESS, + big_handle=command.big_handle, + transport_latency_big=2000, + nse=1, + bn=1, + pto=0, + irc=1, + max_pdu=100, + iso_interval=8, + connection_handle=handles, + ) + ) + + def on_hci_le_big_terminate_sync_command( + self, command: hci.HCI_LE_BIG_Terminate_Sync_Command + ) -> hci.HCI_LE_BIG_Terminate_Sync_ReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - 7.8.107 LE BIG Terminate Sync Command + ''' + return hci.HCI_LE_BIG_Terminate_Sync_ReturnParameters( + status=hci.HCI_ErrorCode.SUCCESS, + big_handle=command.big_handle, + ) + + def on_hci_le_cs_create_config_command( + self, command: hci.HCI_LE_CS_Create_Config_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - LE CS Create Config Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + if command.create_context == 1 and ( + connection := self.find_le_connection_by_handle(command.connection_handle) + ): + peer_role = ( + hci.CsRole.REFLECTOR + if command.role == hci.CsRole.INITIATOR + else hci.CsRole.INITIATOR + ) + connection.send_ll_control_pdu( + ll.CsConfigReq( + config_id=command.config_id, + action=1, + main_mode_type=command.main_mode_type, + sub_mode_type=command.sub_mode_type, + min_main_mode_steps=command.min_main_mode_steps, + max_main_mode_steps=command.max_main_mode_steps, + main_mode_repetition=command.main_mode_repetition, + mode_0_steps=command.mode_0_steps, + role=peer_role, + rtt_type=command.rtt_type, + cs_sync_phy=command.cs_sync_phy, + channel_map=command.channel_map, + channel_map_repetition=command.channel_map_repetition, + channel_selection_type=command.channel_selection_type, + ch3c_shape=command.ch3c_shape, + ch3c_jump=command.ch3c_jump, + ) + ) + else: + self.send_hci_packet( + hci.HCI_LE_CS_Config_Complete_Event( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=command.connection_handle, + config_id=command.config_id, + action=1, + main_mode_type=command.main_mode_type, + sub_mode_type=command.sub_mode_type, + min_main_mode_steps=command.min_main_mode_steps, + max_main_mode_steps=command.max_main_mode_steps, + main_mode_repetition=command.main_mode_repetition, + mode_0_steps=command.mode_0_steps, + role=command.role, + rtt_type=command.rtt_type, + cs_sync_phy=command.cs_sync_phy, + channel_map=command.channel_map, + channel_map_repetition=command.channel_map_repetition, + channel_selection_type=command.channel_selection_type, + ch3c_shape=command.ch3c_shape, + ch3c_jump=command.ch3c_jump, + reserved=0, + t_ip1_time=10, + t_ip2_time=10, + t_fcs_time=10, + t_pm_time=10, + ) + ) + + def on_hci_le_cs_security_enable_command( + self, command: hci.HCI_LE_CS_Security_Enable_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - LE CS Security Enable Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + if connection := self.find_le_connection_by_handle(command.connection_handle): + connection.send_ll_control_pdu(ll.CsSecReq()) + + def on_hci_le_cs_set_procedure_parameters_command( + self, command: hci.HCI_LE_CS_Set_Procedure_Parameters_Command + ) -> hci.HCI_StatusAndConnectionHandleReturnParameters: + ''' + See Bluetooth spec Vol 4, Part E - LE CS Set Procedure Parameters Command + ''' + return hci.HCI_StatusAndConnectionHandleReturnParameters( + status=hci.HCI_ErrorCode.SUCCESS, + connection_handle=command.connection_handle, + ) + + def on_hci_le_cs_procedure_enable_command( + self, command: hci.HCI_LE_CS_Procedure_Enable_Command + ) -> None: + ''' + See Bluetooth spec Vol 4, Part E - LE CS Procedure Enable Command + ''' + self._send_hci_command_status(hci.HCI_COMMAND_STATUS_PENDING, command.op_code) + if connection := self.find_le_connection_by_handle(command.connection_handle): + connection.send_ll_control_pdu( + ll.CsReq( + config_id=command.config_id, + state=command.enable, + ) + ) diff --git a/bumble/device.py b/bumble/device.py index d3bbff32..1badafbc 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -956,7 +956,7 @@ def on_establishment( ) -> None: self.status = status - if self.state == self.State.CANCELLED: + if self.state == self.State.CANCELLED and status == hci.HCI_SUCCESS: # Somehow, we receive an established event after trying to cancel, most # likely because the cancel command was sent too late, when the sync was # already established, but before the established event was sent. diff --git a/bumble/link.py b/bumble/link.py index e6ea2d1b..71453e2d 100644 --- a/bumble/link.py +++ b/bumble/link.py @@ -59,10 +59,13 @@ def remove_controller(self, controller: controller.Controller): self.controllers.remove(controller) def find_le_controller(self, address: hci.Address) -> controller.Controller | None: - for controller in self.controllers: - for connection in controller.le_connections.values(): + for c in self.controllers: + for connection in c.le_connections.values(): if connection.self_address == address: - return controller + return c + for c in self.controllers: + if c.random_address == address or c.public_address == address: + return c return None def find_classic_controller( diff --git a/bumble/ll.py b/bumble/ll.py index 0cbf3e9f..6e47e46c 100644 --- a/bumble/ll.py +++ b/bumble/ll.py @@ -70,7 +70,9 @@ class AdvExtInd(AdvertisingPdu): target_address: hci.Address | None = None adi: int | None = None + sid: int = 0 tx_power: int | None = None + periodic_advertising_data: bytes | None = None # ----------------------------------------------------------------------------- @@ -219,3 +221,111 @@ class PeripheralFeatureReq(ControlPdu): opcode = ControlPdu.Opcode.LL_PERIPHERAL_FEATURE_REQ feature_set: bytes + + +@dataclasses.dataclass +class ConnectionUpdateInd(ControlPdu): + opcode = ControlPdu.Opcode.LL_CONNECTION_UPDATE_IND + + interval: int + latency: int + timeout: int + + +@dataclasses.dataclass +class ConnectionRateInd(ControlPdu): + opcode = ControlPdu.Opcode.LL_CONNECTION_UPDATE_IND + + interval: int + subrate_factor: int + peripheral_latency: int + continuation_number: int + timeout: int + + +@dataclasses.dataclass +class SubrateInd(ControlPdu): + opcode = ControlPdu.Opcode.LL_SUBRATE_IND + + subrate_factor: int + peripheral_latency: int + continuation_number: int + timeout: int + + +@dataclasses.dataclass +class CsConfigReq(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_CONFIG_REQ + + config_id: int + action: int + main_mode_type: int + sub_mode_type: int + min_main_mode_steps: int + max_main_mode_steps: int + main_mode_repetition: int + mode_0_steps: int + role: int + rtt_type: int + cs_sync_phy: int + channel_map: bytes + channel_map_repetition: int + channel_selection_type: int + ch3c_shape: int + ch3c_jump: int + + +@dataclasses.dataclass +class CsConfigRsp(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_CONFIG_RSP + + config_id: int + action: int + main_mode_type: int + sub_mode_type: int + min_main_mode_steps: int + max_main_mode_steps: int + main_mode_repetition: int + mode_0_steps: int + role: int + rtt_type: int + cs_sync_phy: int + channel_map: bytes + channel_map_repetition: int + channel_selection_type: int + ch3c_shape: int + ch3c_jump: int + + +@dataclasses.dataclass +class CsSecReq(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_SEC_REQ + + +@dataclasses.dataclass +class CsSecRsp(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_SEC_RSP + + +@dataclasses.dataclass +class CsReq(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_REQ + + config_id: int + state: int + + +@dataclasses.dataclass +class CsRsp(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_RSP + + config_id: int + state: int + + +@dataclasses.dataclass +class CsInd(ControlPdu): + opcode = ControlPdu.Opcode.LL_CS_IND + + config_id: int + state: int diff --git a/tests/device_test.py b/tests/device_test.py index 3e65dbea..2630bf64 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -24,18 +24,26 @@ import pytest -from bumble import gatt, hci, utils -from bumble.core import PhysicalTransport +from bumble import gatt, hci, smp, utils +from bumble.core import ( + AdvertisingData, + InvalidStateError, + OutOfResourcesError, + PhysicalTransport, +) from bumble.device import ( Advertisement, AdvertisingEventProperties, AdvertisingParameters, + BigParameters, BigSyncParameters, CigParameters, CisLink, Connection, Device, + DeviceConfiguration, PeriodicAdvertisingParameters, + PeriodicAdvertisingSync, ) from bumble.hci import ( HCI_ACCEPT_CONNECTION_REQUEST_COMMAND, @@ -1225,6 +1233,405 @@ async def test_classic_ssp_no_input_no_output_keyboard_fallback(): assert link_key0 == link_key1 +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_connection_parameters_and_subrate(): + two_devices = TwoDevices() + await two_devices.setup_connection() + connection = two_devices.connections[0] + remote_connection = two_devices.connections[1] + + await connection.update_parameters( + connection_interval_min=15.0, + connection_interval_max=30.0, + max_latency=2, + supervision_timeout=1000.0, + ) + await async_barrier() + assert connection.parameters.connection_interval == 30.0 + assert connection.parameters.peripheral_latency == 2 + assert remote_connection.parameters.connection_interval == 30.0 + assert remote_connection.parameters.peripheral_latency == 2 + + await two_devices.devices[0].set_default_connection_subrate( + subrate_min=1, + subrate_max=4, + max_latency=1, + continuation_number=0, + supervision_timeout=1000.0, + ) + + await connection.update_subrate( + subrate_min=1, + subrate_max=4, + max_latency=1, + continuation_number=0, + supervision_timeout=1000.0, + ) + await async_barrier() + assert connection.parameters.subrate_factor == 2 + assert remote_connection.parameters.subrate_factor == 2 + + await connection.update_parameters_with_subrate( + connection_interval_min=15.0, + connection_interval_max=20.0, + subrate_min=1, + subrate_max=3, + max_latency=1, + continuation_number=0, + supervision_timeout=1000.0, + min_ce_length=0.0, + max_ce_length=0.0, + ) + await async_barrier() + assert connection.parameters.subrate_factor == 3 + assert remote_connection.parameters.subrate_factor == 3 + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_periodic_advertising_sync(): + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + adv_set = await two_devices.devices[0].create_advertising_set( + advertising_parameters=AdvertisingParameters( + advertising_event_properties=AdvertisingEventProperties( + is_connectable=False, is_scannable=False + ) + ), + periodic_advertising_parameters=PeriodicAdvertisingParameters( + periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 + ), + periodic_advertising_data=b'\x05\x09Sync', + auto_start=True, + ) + await adv_set.start_periodic() + + established = asyncio.Event() + report_received = asyncio.Event() + received_reports = [] + + sync = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=0, + ) + sync.on('establishment', established.set) + sync.on( + 'periodic_advertisement', + lambda report: (received_reports.append(report), report_received.set()), + ) + + if sync.state != PeriodicAdvertisingSync.State.ESTABLISHED: + await asyncio.wait_for(established.wait(), _TIMEOUT) + assert sync.state == PeriodicAdvertisingSync.State.ESTABLISHED + + await asyncio.wait_for(report_received.wait(), _TIMEOUT) + assert len(received_reports) > 0 + + await sync.terminate() + assert sync.state == PeriodicAdvertisingSync.State.TERMINATED + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_big_and_big_sync(): + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + adv_set = await two_devices.devices[0].create_advertising_set( + advertising_parameters=AdvertisingParameters( + advertising_event_properties=AdvertisingEventProperties( + is_connectable=False, is_scannable=False + ) + ), + periodic_advertising_parameters=PeriodicAdvertisingParameters( + periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 + ), + auto_start=True, + ) + await adv_set.start_periodic() + + big = await two_devices.devices[0].create_big( + advertising_set=adv_set, + parameters=BigParameters( + num_bis=2, + sdu_interval=10000, + max_sdu=100, + max_transport_latency=40, + rtn=2, + ), + ) + assert len(big.bis_links) == 2 + + established = asyncio.Event() + pa_sync = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=0, + ) + pa_sync.on('establishment', established.set) + if pa_sync.state != PeriodicAdvertisingSync.State.ESTABLISHED: + await asyncio.wait_for(established.wait(), _TIMEOUT) + + big_sync = await two_devices.devices[1].create_big_sync( + pa_sync, + BigSyncParameters(big_sync_timeout=1000, bis=[1, 2]), + ) + assert len(big_sync.bis_links) == 2 + + await big_sync.terminate() + await big.terminate() + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_find_peer_by_name_and_identity_address(): + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + two_devices.devices[0].advertising_data = bytes( + AdvertisingData([(AdvertisingData.Type.COMPLETE_LOCAL_NAME, b'TargetPeer')]) + ) + await two_devices.devices[0].start_advertising() + + addr = await two_devices.devices[1].find_peer_by_name('TargetPeer') + assert addr == two_devices.devices[0].random_address + + two_devices.devices[1].address_resolver = smp.AddressResolver( + [(b'\x11' * 16, two_devices.devices[0].public_address)] + ) + addr2 = await two_devices.devices[1].find_peer_by_identity_address( + two_devices.devices[0].random_address + ) + assert addr2 == two_devices.devices[0].random_address + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_channel_sounding(): + two_devices = TwoDevices() + await two_devices.setup_connection() + connection = two_devices.connections[0] + remote_connection = two_devices.connections[1] + + config = await two_devices.devices[0].create_cs_config( + connection=connection, create_context=1 + ) + await async_barrier() + assert config.config_id == 0 + assert config.role == hci.CsRole.INITIATOR + assert 0 in remote_connection.cs_configs + assert remote_connection.cs_configs[0].role == hci.CsRole.REFLECTOR + + await two_devices.devices[0].enable_cs_security(connection=connection) + await async_barrier() + + await two_devices.devices[0].set_cs_procedure_parameters( + connection=connection, config=config + ) + procedure = await two_devices.devices[0].enable_cs_procedure( + connection=connection, config=config + ) + await async_barrier() + assert procedure.config_id == 0 + assert 0 in remote_connection.cs_procedures + assert remote_connection.cs_procedures[0].state == 1 + + +# ----------------------------------------------------------------------------- +def test_device_configuration_load_from_dict(): + config = DeviceConfiguration() + config.load_from_dict( + { + 'name': 'TestDevice', + 'address': 'F0:F1:F2:F3:F4:F5', + 'class_of_device': 0x240404, + 'advertising_interval_min': 100, + 'advertising_interval_max': 200, + 'le_enabled': True, + 'classic_enabled': True, + 'le_subrate_enabled': True, + 'irk': '00112233445566778899aabbccddeeff', + 'keystore': 'JsonKeyStore', + } + ) + assert config.name == 'TestDevice' + assert config.le_subrate_enabled is True + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_multiple_periodic_advertising_and_big_syncs(): + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + adv_set_0 = await two_devices.devices[0].create_advertising_set( + advertising_parameters=AdvertisingParameters( + advertising_event_properties=AdvertisingEventProperties( + is_connectable=False, is_scannable=False + ), + advertising_sid=0, + ), + periodic_advertising_parameters=PeriodicAdvertisingParameters( + periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 + ), + periodic_advertising_data=b'\x07\x09Train0', + auto_start=True, + ) + await adv_set_0.start_periodic() + + adv_set_1 = await two_devices.devices[0].create_advertising_set( + advertising_parameters=AdvertisingParameters( + advertising_event_properties=AdvertisingEventProperties( + is_connectable=False, is_scannable=False + ), + advertising_sid=1, + ), + periodic_advertising_parameters=PeriodicAdvertisingParameters( + periodic_advertising_interval_min=100, periodic_advertising_interval_max=200 + ), + periodic_advertising_data=b'\x07\x09Train1', + auto_start=True, + ) + await adv_set_1.start_periodic() + + big_0 = await two_devices.devices[0].create_big( + advertising_set=adv_set_0, + parameters=BigParameters( + num_bis=2, + sdu_interval=10000, + max_sdu=100, + max_transport_latency=40, + rtn=2, + ), + ) + big_1 = await two_devices.devices[0].create_big( + advertising_set=adv_set_1, + parameters=BigParameters( + num_bis=1, + sdu_interval=10000, + max_sdu=100, + max_transport_latency=40, + rtn=2, + ), + ) + assert len(big_0.bis_links) == 2 + assert len(big_1.bis_links) == 1 + + est_0 = asyncio.Event() + est_1 = asyncio.Event() + rep_0 = asyncio.Event() + rep_1 = asyncio.Event() + data_0 = [] + data_1 = [] + + sync_0 = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=0, + ) + sync_0.on('establishment', est_0.set) + sync_0.on( + 'periodic_advertisement', + lambda r: (data_0.append(bytes(r.data)), rep_0.set()), + ) + if sync_0.state != PeriodicAdvertisingSync.State.ESTABLISHED: + await asyncio.wait_for(est_0.wait(), _TIMEOUT) + + sync_1 = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=1, + ) + sync_1.on('establishment', est_1.set) + sync_1.on( + 'periodic_advertisement', + lambda r: (data_1.append(bytes(r.data)), rep_1.set()), + ) + if sync_1.state != PeriodicAdvertisingSync.State.ESTABLISHED: + await asyncio.wait_for(est_1.wait(), _TIMEOUT) + + await asyncio.wait_for(rep_0.wait(), _TIMEOUT) + await asyncio.wait_for(rep_1.wait(), _TIMEOUT) + assert b'\x07\x09Train0' in data_0 + assert b'\x07\x09Train1' in data_1 + + big_sync_0 = await two_devices.devices[1].create_big_sync( + sync_0, BigSyncParameters(big_sync_timeout=1000, bis=[1, 2]) + ) + big_sync_1 = await two_devices.devices[1].create_big_sync( + sync_1, BigSyncParameters(big_sync_timeout=1000, bis=[1]) + ) + assert len(big_sync_0.bis_links) == 2 + assert len(big_sync_1.bis_links) == 1 + + await big_sync_0.terminate() + await big_sync_1.terminate() + await sync_0.terminate() + await sync_1.terminate() + await big_0.terminate() + await big_1.terminate() + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_periodic_advertising_and_big_failure_exceptions(): + two_devices = TwoDevices() + for dev in two_devices.devices: + await dev.power_on() + + # 1. Duplicate Periodic Advertising Sync raises ValueError + sync = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=5, + ) + with pytest.raises(ValueError, match="equivalent entry already created"): + await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=5, + ) + + # 2. Create BIG Sync on unestablished PA Sync raises InvalidStateError + with pytest.raises(InvalidStateError, match="PA Sync is not established"): + await two_devices.devices[1].create_big_sync( + sync, BigSyncParameters(big_sync_timeout=1000, bis=[1]) + ) + + # 3. Cancel pending Periodic Advertising Sync before establishment via terminate() + await sync.terminate() + assert sync.state == PeriodicAdvertisingSync.State.CANCELLED + + # 4. Periodic Advertising Sync establishment timeout error (status != SUCCESS) + sync_err = await two_devices.devices[1].create_periodic_advertising_sync( + advertiser_address=two_devices.devices[0].random_address, + sid=6, + sync_timeout=0.02, + ) + error_event = asyncio.Event() + sync_err.on('establishment_error', error_event.set) + await asyncio.wait_for(error_event.wait(), _TIMEOUT) + assert sync_err.state == PeriodicAdvertisingSync.State.ERROR + assert ( + sync_err.status == hci.HCI_ErrorCode.CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR + ) + + # 5. Exhaust BIG handles raises OutOfResourcesError + original_bigs = dict(two_devices.devices[1].big_syncs) + try: + for handle in range(0x00, 0xEF + 1): + two_devices.devices[1].big_syncs[handle] = None # type: ignore + with pytest.raises( + OutOfResourcesError, match="All valid BIG handles already in use" + ): + await two_devices.devices[1].create_big_sync( + sync, BigSyncParameters(big_sync_timeout=1000, bis=[1]) + ) + finally: + two_devices.devices[1].big_syncs = original_bigs + + # ----------------------------------------------------------------------------- async def run_test_device(): await test_device_connect_parallel()