From 80c89875fa0ded83687dc3fbd7f749ecc6a5c178 Mon Sep 17 00:00:00 2001 From: Josh Wu Date: Thu, 26 Dec 2024 15:16:25 +0800 Subject: [PATCH] CS commands and events --- bumble/device.py | 352 ++++++++++++++++++ bumble/hci.py | 606 ++++++++++++++++++++++++++++++- bumble/host.py | 24 ++ bumble/smp.py | 2 +- bumble/utils.py | 4 +- examples/cs_initiator.json | 9 + examples/cs_reflector.json | 9 + examples/run_channel_sounding.py | 160 ++++++++ 8 files changed, 1160 insertions(+), 6 deletions(-) create mode 100644 examples/cs_initiator.json create mode 100644 examples/cs_reflector.json create mode 100644 examples/run_channel_sounding.py diff --git a/bumble/device.py b/bumble/device.py index 56f4f003..a076b2b0 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -119,6 +119,8 @@ DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE = 0xEF DEVICE_MIN_BIG_HANDLE = 0x00 DEVICE_MAX_BIG_HANDLE = 0xEF +DEVICE_MIN_CS_CONFIG_ID = 0x00 +DEVICE_MAX_CS_CONFIG_ID = 0x03 DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00' DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms @@ -1116,6 +1118,57 @@ async def terminate(self) -> None: await terminated.wait() +# ----------------------------------------------------------------------------- +@dataclass +class ChannelSoundingCapabilities: + num_config_supported: int + max_consecutive_procedures_supported: int + num_antennas_supported: int + max_antenna_paths_supported: int + roles_supported: int + modes_supported: int + rtt_capability: int + rtt_aa_only_n: int + rtt_sounding_n: int + rtt_random_payload_n: int + nadm_sounding_capability: int + nadm_random_capability: int + cs_sync_phys_supported: int + subfeatures_supported: int + t_ip1_times_supported: int + t_ip2_times_supported: int + t_fcs_times_supported: int + t_pm_times_supported: int + t_sw_time_supported: int + tx_snr_capability: int + + +# ----------------------------------------------------------------------------- +@dataclass +class ChannelSoundingConfig: + + id: 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 + reserved: int + t_ip1_time: int + t_ip2_time: int + t_fcs_time: int + t_pm_time: int + + # ----------------------------------------------------------------------------- class LePhyOptions: # Coded PHY preference @@ -1464,6 +1517,7 @@ class Connection(CompositeEventEmitter): gatt_client: gatt_client.Client pairing_peer_io_capability: Optional[int] pairing_peer_authentication_requirements: Optional[int] + cs_configs: dict[int, ChannelSoundingConfig] = {} @composite_listener class Listener: @@ -1754,6 +1808,7 @@ class DeviceConfiguration: address_resolution_offload: bool = False address_generation_offload: bool = False cis_enabled: bool = False + channel_sounding_enabled: bool = False identity_address_type: Optional[int] = None io_capability: int = pairing.PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT @@ -1938,6 +1993,7 @@ class Device(CompositeEventEmitter): bis_links = dict[int, BisLink]() big_syncs = dict[int, BigSync]() _pending_cis: Dict[int, tuple[int, int]] + cs_capabilities: ChannelSoundingCapabilities | None = None @composite_listener class Listener: @@ -2435,6 +2491,41 @@ async def power_on(self) -> None: check_result=True, ) + if self.config.channel_sounding_enabled: + await self.send_command( + hci.HCI_LE_Set_Host_Feature_Command( + bit_number=hci.LeFeature.CHANNEL_SOUNDING_HOST_SUPPORT, + bit_value=1, + ), + check_result=True, + ) + result = await self.send_command( + hci.HCI_LE_CS_Read_Local_Supported_Capabilities_Command(), + check_result=True, + ) + self.cs_capabilities = ChannelSoundingCapabilities( + num_config_supported=result.return_parameters.num_config_supported, + max_consecutive_procedures_supported=result.return_parameters.max_consecutive_procedures_supported, + num_antennas_supported=result.return_parameters.num_antennas_supported, + max_antenna_paths_supported=result.return_parameters.max_antenna_paths_supported, + roles_supported=result.return_parameters.roles_supported, + modes_supported=result.return_parameters.modes_supported, + rtt_capability=result.return_parameters.rtt_capability, + rtt_aa_only_n=result.return_parameters.rtt_aa_only_n, + rtt_sounding_n=result.return_parameters.rtt_sounding_n, + rtt_random_payload_n=result.return_parameters.rtt_random_payload_n, + nadm_sounding_capability=result.return_parameters.nadm_sounding_capability, + nadm_random_capability=result.return_parameters.nadm_random_capability, + cs_sync_phys_supported=result.return_parameters.cs_sync_phys_supported, + subfeatures_supported=result.return_parameters.subfeatures_supported, + t_ip1_times_supported=result.return_parameters.t_ip1_times_supported, + t_ip2_times_supported=result.return_parameters.t_ip2_times_supported, + t_fcs_times_supported=result.return_parameters.t_fcs_times_supported, + t_pm_times_supported=result.return_parameters.t_pm_times_supported, + t_sw_time_supported=result.return_parameters.t_sw_time_supported, + tx_snr_capability=result.return_parameters.tx_snr_capability, + ) + if self.classic_enabled: await self.send_command( hci.HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8')) @@ -4481,6 +4572,181 @@ def on_failure(handle: int, status: int): ) return await read_feature_future + @experimental('Only for testing.') + async def get_remote_cs_capabilities( + self, connection: Connection + ) -> ChannelSoundingCapabilities: + complete_future: asyncio.Future[ChannelSoundingCapabilities] = ( + asyncio.get_running_loop().create_future() + ) + + with closing(EventWatcher()) as watcher: + watcher.once( + connection, 'channel_sounding_capabilities', complete_future.set_result + ) + watcher.once( + connection, + 'channel_sounding_capabilities_failure', + lambda status: complete_future.set_exception(hci.HCI_Error(status)), + ) + await self.send_command( + hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Command( + connection_handle=connection.handle + ), + check_result=True, + ) + return await complete_future + + @experimental('Only for testing.') + async def create_cs_config( + self, + connection: Connection, + config_id: int | None = None, + create_context: int = 0x01, + main_mode_type: int = 0x02, + sub_mode_type: int = 0xFF, + min_main_mode_steps: int = 0x02, + max_main_mode_steps: int = 0x05, + main_mode_repetition: int = 0x00, + mode_0_steps: int = 0x03, + 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\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, + ch3c_jump: int = 0x03, + ) -> ChannelSoundingConfig: + complete_future: asyncio.Future[ChannelSoundingConfig] = ( + asyncio.get_running_loop().create_future() + ) + if config_id is None: + # Allocate an ID. + config_id = next( + ( + i + for i in range(DEVICE_MIN_CS_CONFIG_ID, DEVICE_MAX_CS_CONFIG_ID + 1) + if i not in connection.cs_configs + ), + None, + ) + if config_id is None: + raise OutOfResourcesError("No available config ID on this connection!") + + with closing(EventWatcher()) as watcher: + watcher.once( + connection, 'channel_sounding_config', complete_future.set_result + ) + watcher.once( + connection, + 'channel_sounding_config_failure', + lambda status: complete_future.set_exception(hci.HCI_Error(status)), + ) + await self.send_command( + hci.HCI_LE_CS_Create_Config_Command( + connection_handle=connection.handle, + config_id=config_id, + create_context=create_context, + main_mode_type=main_mode_type, + sub_mode_type=sub_mode_type, + min_main_mode_steps=min_main_mode_steps, + max_main_mode_steps=max_main_mode_steps, + main_mode_repetition=main_mode_repetition, + mode_0_steps=mode_0_steps, + role=role, + rtt_type=rtt_type, + cs_sync_phy=cs_sync_phy, + channel_map=channel_map, + channel_map_repetition=channel_map_repetition, + channel_selection_type=channel_selection_type, + ch3c_shape=ch3c_shape, + ch3c_jump=ch3c_jump, + reserved=0x00, + ), + check_result=True, + ) + return await complete_future + + @experimental('Only for testing.') + async def enable_cs_security(self, connection: Connection) -> None: + complete_future: asyncio.Future[None] = ( + asyncio.get_running_loop().create_future() + ) + with closing(EventWatcher()) as watcher: + + def on_event(event: hci.HCI_LE_CS_Security_Enable_Complete_Event) -> None: + if event.connection_handle != connection.handle: + return + if event.status == hci.HCI_SUCCESS: + complete_future.set_result(None) + else: + complete_future.set_exception(hci.HCI_Error(event.status)) + + watcher.once(self.host, 'cs_security', on_event) + await self.send_command( + hci.HCI_LE_CS_Security_Enable_Command( + connection_handle=connection.handle + ), + check_result=True, + ) + return await complete_future + + @experimental('Only for testing.') + async def set_cs_procedure_parameters( + self, + connection: Connection, + config: ChannelSoundingConfig, + tone_antenna_config_selection=0x00, + preferred_peer_antenna=0x00, + max_procedure_len=0x2710, # 6.25s + min_procedure_interval=0x01, + max_procedure_interval=0xFF, + max_procedure_count=0x01, + min_subevent_len=0x0004E2, # 1250us + max_subevent_len=0x1E8480, # 2s + phy=hci.CsSyncPhy.LE_1M, + tx_power_delta=0x00, + snr_control_initiator=hci.CsSnr.NOT_APPLIED, + snr_control_reflector=hci.CsSnr.NOT_APPLIED, + ) -> None: + await self.send_command( + hci.HCI_LE_CS_Set_Procedure_Parameters_Command( + connection_handle=connection.handle, + config_id=config.id, + max_procedure_len=max_procedure_len, + min_procedure_interval=min_procedure_interval, + max_procedure_interval=max_procedure_interval, + max_procedure_count=max_procedure_count, + min_subevent_len=min_subevent_len, + max_subevent_len=max_subevent_len, + tone_antenna_config_selection=tone_antenna_config_selection, + phy=phy, + tx_power_delta=tx_power_delta, + preferred_peer_antenna=preferred_peer_antenna, + snr_control_initiator=snr_control_initiator, + snr_control_reflector=snr_control_reflector, + ), + check_result=True, + ) + + @experimental('Only for testing.') + async def enable_cs_procedure( + self, + connection: Connection, + config: ChannelSoundingConfig, + enabled: bool = True, + ) -> None: + with closing(EventWatcher()) as watcher: + await self.send_command( + hci.HCI_LE_CS_Procedure_Enable_Command( + connection_handle=connection.handle, + config_id=config.id, + enable=enabled, + ), + check_result=True, + ) + @host_event_handler def on_flush(self): self.emit('flush') @@ -5439,6 +5705,92 @@ def on_connection_data_length_change( ) connection.emit('connection_data_length_change') + @host_event_handler + def on_cs_remote_supported_capabilities( + self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event + ): + if not (connection := self.lookup_connection(event.connection_handle)): + return + + if event.status != hci.HCI_SUCCESS: + connection.emit('channel_sounding_capabilities_failure', event.status) + return + + capabilities = ChannelSoundingCapabilities( + num_config_supported=event.num_config_supported, + max_consecutive_procedures_supported=event.max_consecutive_procedures_supported, + num_antennas_supported=event.num_antennas_supported, + max_antenna_paths_supported=event.max_antenna_paths_supported, + roles_supported=event.roles_supported, + modes_supported=event.modes_supported, + rtt_capability=event.rtt_capability, + rtt_aa_only_n=event.rtt_aa_only_n, + rtt_sounding_n=event.rtt_sounding_n, + rtt_random_payload_n=event.rtt_random_payload_n, + nadm_sounding_capability=event.nadm_sounding_capability, + nadm_random_capability=event.nadm_random_capability, + cs_sync_phys_supported=event.cs_sync_phys_supported, + subfeatures_supported=event.subfeatures_supported, + t_ip1_times_supported=event.t_ip1_times_supported, + t_ip2_times_supported=event.t_ip2_times_supported, + t_fcs_times_supported=event.t_fcs_times_supported, + t_pm_times_supported=event.t_pm_times_supported, + t_sw_time_supported=event.t_sw_time_supported, + tx_snr_capability=event.tx_snr_capability, + ) + connection.emit('channel_sounding_capabilities', capabilities) + + @host_event_handler + def on_cs_config(self, event: hci.HCI_LE_CS_Config_Complete_Event): + if not (connection := self.lookup_connection(event.connection_handle)): + return + + if event.status != hci.HCI_SUCCESS: + connection.emit('channel_sounding_config_failure', event.status) + return + if event.action == hci.HCI_LE_CS_Config_Complete_Event.Action.CREATED: + config = ChannelSoundingConfig( + id=event.config_id, + main_mode_type=event.main_mode_type, + sub_mode_type=event.sub_mode_type, + min_main_mode_steps=event.min_main_mode_steps, + max_main_mode_steps=event.max_main_mode_steps, + main_mode_repetition=event.main_mode_repetition, + mode_0_steps=event.mode_0_steps, + role=event.role, + rtt_type=event.rtt_type, + cs_sync_phy=event.cs_sync_phy, + channel_map=event.channel_map, + channel_map_repetition=event.channel_map_repetition, + channel_selection_type=event.channel_selection_type, + ch3c_shape=event.ch3c_shape, + ch3c_jump=event.ch3c_jump, + reserved=event.reserved, + t_ip1_time=event.t_ip1_time, + t_ip2_time=event.t_ip2_time, + t_fcs_time=event.t_fcs_time, + t_pm_time=event.t_pm_time, + ) + connection.cs_configs[event.config_id] = config + connection.emit('channel_sounding_config', config) + elif event.action == hci.HCI_LE_CS_Config_Complete_Event.Action.REMOVED: + try: + config = connection.cs_configs.pop(event.config_id) + connection.emit('channel_sounding_config_removed', config.id) + except KeyError: + logger.error('Removing unknown config %d', event.config_id) + + @host_event_handler + def on_cs_procedure(self, event: hci.HCI_LE_CS_Procedure_Enable_Complete_Event): + if not (connection := self.lookup_connection(event.connection_handle)): + return + + if event.status != hci.HCI_SUCCESS: + connection.emit('channel_sounding_procedure_failure', event.status) + return + + connection.emit('channel_sounding_procedure', event.state) + # [Classic only] @host_event_handler @with_connection_from_address diff --git a/bumble/hci.py b/bumble/hci.py index 3d849cc2..77a95e72 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -275,7 +275,7 @@ def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int: HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMPLETE_EVENT = 0x2D HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT = 0x2E HCI_LE_CS_CONFIG_COMPLETE_EVENT = 0x2F -HCI_LE_CS_PROCEDURE_ENABLE_EVENT = 0x30 +HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT = 0x30 HCI_LE_CS_SUBEVENT_RESULT_EVENT = 0x31 HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT = 0x32 HCI_LE_CS_TEST_END_COMPLETE_EVENT = 0x33 @@ -599,7 +599,7 @@ def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int: HCI_LE_READ_ALL_REMOTE_FEATURES_COMMAND = hci_command_op_code(0x08, 0x0088) HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x0089) HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x008A) -HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES = hci_command_op_code(0x08, 0x008B) +HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x008B) HCI_LE_CS_SECURITY_ENABLE_COMMAND = hci_command_op_code(0x08, 0x008C) HCI_LE_CS_SET_DEFAULT_SETTINGS_COMMAND = hci_command_op_code(0x08, 0x008D) HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMMAND = hci_command_op_code(0x08, 0x008E) @@ -751,6 +751,46 @@ class PhyBit(enum.IntFlag): LE_CODED = 1 << HCI_LE_CODED_PHY_BIT +class CsRole(OpenIntEnum): + INITIATOR = 0x00 + REFLECTOR = 0x01 + + +class CsRoleMask(enum.IntFlag): + INITIATOR = 0x01 + REFLECTOR = 0x02 + + +class CsSyncPhy(OpenIntEnum): + LE_1M = 1 + LE_2M = 2 + LE_2M_2BT = 3 + + +class CsSyncPhySupported(enum.IntFlag): + LE_2M = 0x01 + LE_2M_2BT = 0x02 + + +class RttType(OpenIntEnum): + AA_ONLY = 0x00 + SOUNDING_SEQUENCE_32_BIT = 0x01 + SOUNDING_SEQUENCE_96_BIT = 0x02 + RANDOM_SEQUENCE_32_BIT = 0x03 + RANDOM_SEQUENCE_64_BIT = 0x04 + RANDOM_SEQUENCE_96_BIT = 0x05 + RANDOM_SEQUENCE_128_BIT = 0x06 + + +class CsSnr(OpenIntEnum): + SNR_18_DB = 0x00 + SNR_21_DB = 0x01 + SNR_24_DB = 0x02 + SNR_27_DB = 0x03 + SNR_30_DB = 0x04 + NOT_APPLIED = 0xFF + + # Connection Parameters HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25 HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25 @@ -971,7 +1011,7 @@ class PhyBit(enum.IntFlag): HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4), HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+5), HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+6), - HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES : 1 << (20*8+7), + HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+7), HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2), HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0), HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1), @@ -1462,6 +1502,12 @@ class LmpFeatureMask(enum.IntFlag): # ----------------------------------------------------------------------------- # pylint: disable-next=unnecessary-lambda STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)} +CS_ROLE_SPEC = {'size': 1, 'mapper': lambda x: CsRole(x).name} +CS_ROLE_MASK_SPEC = {'size': 1, 'mapper': lambda x: CsRoleMask(x).name} +CS_SYNC_PHY_SPEC = {'size': 1, 'mapper': lambda x: CsSyncPhy(x).name} +CS_SYNC_PHY_SUPPORTED_SPEC = {'size': 1, 'mapper': lambda x: CsSyncPhySupported(x).name} +RTT_TYPE_SPEC = {'size': 1, 'mapper': lambda x: RttType(x).name} +CS_SNR_SPEC = {'size': 1, 'mapper': lambda x: CsSnr(x).name} class CodecID(OpenIntEnum): @@ -5059,6 +5105,275 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command): ''' +# ----------------------------------------------------------------------------- +@HCI_Command.command( + return_parameters_fields=[ + ('status', STATUS_SPEC), + ('num_config_supported', 1), + ('max_consecutive_procedures_supported', 2), + ('num_antennas_supported', 1), + ('max_antenna_paths_supported', 1), + ('roles_supported', 1), + ('modes_supported', 1), + ('rtt_capability', 1), + ('rtt_aa_only_n', 1), + ('rtt_sounding_n', 1), + ('rtt_random_payload_n', 1), + ('nadm_sounding_capability', 2), + ('nadm_random_capability', 2), + ('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC), + ('subfeatures_supported', 2), + ('t_ip1_times_supported', 2), + ('t_ip2_times_supported', 2), + ('t_fcs_times_supported', 2), + ('t_pm_times_supported', 2), + ('t_sw_time_supported', 1), + ('tx_snr_capability', CS_SNR_SPEC), + ] +) +class HCI_LE_CS_Read_Local_Supported_Capabilities_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.130 LE CS Read Local Supported Capabilities command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command([('connection_handle', 2)]) +class HCI_LE_CS_Read_Remote_Supported_Capabilities_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.131 LE CS Read Remote Supported Capabilities command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('num_config_supported', 1), + ('max_consecutive_procedures_supported', 2), + ('num_antennas_supported', 1), + ('max_antenna_paths_supported', 1), + ('roles_supported', 1), + ('modes_supported', 1), + ('rtt_capability', 1), + ('rtt_aa_only_n', 1), + ('rtt_sounding_n', 1), + ('rtt_random_payload_n', 1), + ('nadm_sounding_capability', 2), + ('nadm_random_capability', 2), + ('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC), + ('subfeatures_supported', 2), + ('t_ip1_times_supported', 2), + ('t_ip2_times_supported', 2), + ('t_fcs_times_supported', 2), + ('t_pm_times_supported', 2), + ('t_sw_time_supported', 1), + ('tx_snr_capability', CS_SNR_SPEC), + ], + return_parameters_fields=[ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ], +) +class HCI_LE_CS_Write_Cached_Remote_Supported_Capabilities_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.132 LE CS Write Cached Remote Supported Capabilities command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command([('connection_handle', 2)]) +class HCI_LE_CS_Security_Enable_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.133 LE CS Security Enable command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ( + 'role_enable', + CS_ROLE_MASK_SPEC, + ), + ('cs_sync_antenna_selection', 1), + ('max_tx_power', 1), + ], + return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)], +) +class HCI_LE_CS_Set_Default_Settings_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.134 LE CS Security Enable command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command([('connection_handle', 2)]) +class HCI_LE_CS_Read_Remote_FAE_Table_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.135 LE CS Read Remote FAE Table command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('remote_fae_table', 72), + ], + return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)], +) +class HCI_LE_CS_Write_Cached_Remote_FAE_Table_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.136 LE CS Write Cached Remote FAE Table command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('config_id', 1), + ('create_context', 1), + ('main_mode_type', 1), + ('sub_mode_type', 1), + ('min_main_mode_steps', 1), + ('max_main_mode_steps', 1), + ('main_mode_repetition', 1), + ('mode_0_steps', 1), + ('role', CS_ROLE_SPEC), + ('rtt_type', RTT_TYPE_SPEC), + ('cs_sync_phy', CS_SYNC_PHY_SPEC), + ('channel_map', 10), + ('channel_map_repetition', 1), + ('channel_selection_type', 1), + ('ch3c_shape', 1), + ('ch3c_jump', 1), + ('reserved', 1), + ], +) +class HCI_LE_CS_Create_Config_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.137 LE CS Create Config command + ''' + + class ChannelSelectionType(OpenIntEnum): + ALGO_3B = 0 + ALGO_3C = 1 + + class Ch3cShape(OpenIntEnum): + HAT = 0x00 + X = 0x01 + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('config_id', 1), + ], +) +class HCI_LE_CS_Remove_Config_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.138 LE CS Remove Config command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [('channel_classification', 10)], return_parameters_fields=[('status', STATUS_SPEC)] +) +class HCI_LE_CS_Set_Channel_Classification_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.139 LE CS Set Channel Classification command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('config_id', 1), + ('max_procedure_len', 2), + ('min_procedure_interval', 2), + ('max_procedure_interval', 2), + ('max_procedure_count', 2), + ('min_subevent_len', 3), + ('max_subevent_len', 3), + ('tone_antenna_config_selection', 1), + ('phy', 1), + ('tx_power_delta', 1), + ('preferred_peer_antenna', 1), + ('snr_control_initiator', CS_SNR_SPEC), + ('snr_control_reflector', CS_SNR_SPEC), + ], + return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)], +) +class HCI_LE_CS_Set_Procedure_Parameters_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.140 LE CS Set Procedure Parameters command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('connection_handle', 2), + ('config_id', 1), + ('enable', 1), + ], +) +class HCI_LE_CS_Procedure_Enable_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.141 LE CS Procedure Enable command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( + [ + ('main_mode_type', 1), + ('sub_mode_type', 1), + ('main_mode_repetition', 1), + ('mode_0_steps', 1), + ('role', CS_ROLE_SPEC), + ('rtt_type', RTT_TYPE_SPEC), + ('cs_sync_phy', CS_SYNC_PHY_SPEC), + ('cs_sync_antenna_selection', 1), + ('subevent_len', 3), + ('subevent_interval', 2), + ('max_num_subevents', 1), + ('transmit_power_level', 1), + ('t_ip1_time', 1), + ('t_ip2_time', 1), + ('t_fcs_time', 1), + ('t_pm_time', 1), + ('t_sw_time', 1), + ('tone_antenna_config_selection', 1), + ('reserved', 1), + ('snr_control_initiator', CS_SNR_SPEC), + ('snr_control_reflector', CS_SNR_SPEC), + ('drbg_nonce', 2), + ('channel_map_repetition', 1), + ('override_config', 2), + ('override_parameters_data', 'v'), + ], +) +class HCI_LE_CS_Test_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.142 LE CS Test command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command() +class HCI_LE_CS_Test_End_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.143 LE CS Test End command + ''' + + # ----------------------------------------------------------------------------- # HCI Events # ----------------------------------------------------------------------------- @@ -6009,6 +6324,291 @@ class HCI_LE_BIGInfo_Advertising_Report_Event(HCI_LE_Meta_Event): ''' +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ('num_config_supported', 1), + ('max_consecutive_procedures_supported', 2), + ('num_antennas_supported', 1), + ('max_antenna_paths_supported', 1), + ('roles_supported', 1), + ('modes_supported', 1), + ('rtt_capability', 1), + ('rtt_aa_only_n', 1), + ('rtt_sounding_n', 1), + ('rtt_random_payload_n', 1), + ('nadm_sounding_capability', 2), + ('nadm_random_capability', 2), + ('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC), + ('subfeatures_supported', 2), + ('t_ip1_times_supported', 2), + ('t_ip2_times_supported', 2), + ('t_fcs_times_supported', 2), + ('t_pm_times_supported', 2), + ('t_sw_time_supported', 1), + ('tx_snr_capability', CS_SNR_SPEC), + ] +) +class HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.39 LE CS Read Remote Supported Capabilities Complete event + ''' + + status: int + connection_handle: int + num_config_supported: int + max_consecutive_procedures_supported: int + num_antennas_supported: int + max_antenna_paths_supported: int + roles_supported: int + modes_supported: int + rtt_capability: int + rtt_aa_only_n: int + rtt_sounding_n: int + rtt_random_payload_n: int + nadm_sounding_capability: int + nadm_random_capability: int + cs_sync_phys_supported: int + subfeatures_supported: int + t_ip1_times_supported: int + t_ip2_times_supported: int + t_fcs_times_supported: int + t_pm_times_supported: int + t_sw_time_supported: int + tx_snr_capability: int + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ('remote_fae_table', 72), + ] +) +class HCI_LE_CS_Read_Remote_FAE_Table_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.40 LE CS Read Remote FAE Table Complete event + ''' + + status: int + connection_handle: int + remote_fae_table: bytes + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ] +) +class HCI_LE_CS_Security_Enable_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.41 LE CS Security Enable Complete event + ''' + + status: int + connection_handle: int + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ('config_id', 1), + ( + 'action', + { + 'size': 1, + 'mapper': lambda x: HCI_LE_CS_Config_Complete_Event.Action(x).name, + }, + ), + ('main_mode_type', 1), + ('sub_mode_type', 1), + ('min_main_mode_steps', 1), + ('max_main_mode_steps', 1), + ('main_mode_repetition', 1), + ('mode_0_steps', 1), + ('role', CS_ROLE_SPEC), + ('rtt_type', RTT_TYPE_SPEC), + ('cs_sync_phy', CS_SYNC_PHY_SPEC), + ('channel_map', 10), + ('channel_map_repetition', 1), + ('channel_selection_type', 1), + ('ch3c_shape', 1), + ('ch3c_jump', 1), + ('reserved', 1), + ('t_ip1_time', 1), + ('t_ip2_time', 1), + ('t_fcs_time', 1), + ('t_pm_time', 1), + ] +) +class HCI_LE_CS_Config_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.42 LE CS Config Complete event + ''' + + class Action(OpenIntEnum): + REMOVED = 0 + CREATED = 1 + + status: int + connection_handle: int + 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 + reserved: int + t_ip1_time: int + t_ip2_time: int + t_fcs_time: int + t_pm_time: int + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ('config_id', 1), + ('state', 1), + ('tone_antenna_config_selection', 1), + ('selected_tx_power', 1), + ('subevent_len', 3), + ('subevents_per_event', 1), + ('subevent_interval', 2), + ('event_interval', 2), + ('procedure_interval', 2), + ('procedure_count', 2), + ('max_procedure_len', 2), + ] +) +class HCI_LE_CS_Procedure_Enable_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.43 LE CS Procedure Enable Complete event + ''' + + class State(OpenIntEnum): + DISABLED = 0 + ENABLED = 1 + + status: int + connection_handle: int + config_id: int + state: int + tone_antenna_config_selection: int + selected_tx_power: int + subevent_len: int + subevents_per_event: int + subevent_interval: int + event_interval: int + procedure_interval: int + procedure_count: int + max_procedure_len: int + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('connection_handle', 2), + ('config_id', 1), + ('start_acl_conn_event_counter', 2), + ('procedure_counter', 2), + ('frequency_compensation', 2), + ('reference_power_level', 1), + ('procedure_done_status', 1), + ('subevent_done_status', 1), + ('abort_reason', 1), + ('num_antenna_paths', 1), + [ + ('step_mode', 1), + ('step_channel', 1), + ('step_data', 'v'), + ], + ] +) +class HCI_LE_CS_Subevent_Result_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.44 LE CS Subevent Result event + ''' + + status: int + config_id: int + start_acl_conn_event_counter: int + procedure_counter: int + frequency_compensation: int + reference_power_level: int + procedure_done_status: int + subevent_done_status: int + abort_reason: int + num_antenna_paths: int + step_mode: list[int] + step_channel: list[int] + step_data: list[bytes] + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('connection_handle', 2), + ('config_id', 1), + ('procedure_done_status', 1), + ('subevent_done_status', 1), + ('abort_reason', 1), + ('num_antenna_paths', 1), + [ + ('step_mode', 1), + ('step_channel', 1), + ('step_data', 'v'), + ], + ] +) +class HCI_LE_CS_Subevent_Result_Continue_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.45 LE CS Subevent Result Continue event + ''' + + status: int + config_id: int + procedure_done_status: int + subevent_done_status: int + abort_reason: int + num_antenna_paths: int + step_mode: list[int] + step_channel: list[int] + step_data: list[bytes] + + +# ----------------------------------------------------------------------------- +@HCI_LE_Meta_Event.event( + [ + ('connection_handle', 2), + ('status', STATUS_SPEC), + ] +) +class HCI_LE_CS_Test_End_Complete_Event(HCI_LE_Meta_Event): + ''' + See Bluetooth spec @ 7.7.65.46 LE CS Test End Complete event + ''' + + # ----------------------------------------------------------------------------- @HCI_Event.event([('status', STATUS_SPEC)]) class HCI_Inquiry_Complete_Event(HCI_Event): diff --git a/bumble/host.py b/bumble/host.py index 1ce4263a..dfc6c545 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -389,6 +389,12 @@ async def reset(self, driver_factory=drivers.get_driver_for_host): hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT, hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT, hci.HCI_LE_SUBRATE_CHANGE_EVENT, + hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT, + hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT, + hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT, + hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT, + hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT, + hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT, ] ) @@ -1296,5 +1302,23 @@ def on_hci_le_read_remote_features_complete_event(self, event): int.from_bytes(event.le_features, 'little'), ) + def on_hci_le_cs_read_remote_supported_capabilities_complete_event(self, event): + self.emit('cs_remote_supported_capabilities', event) + + def on_hci_le_cs_security_enable_complete_event(self, event): + self.emit('cs_security', event) + + def on_hci_le_cs_config_complete_event(self, event): + self.emit('cs_config', event) + + def on_hci_le_cs_procedure_enable_complete_event(self, event): + self.emit('cs_procedure', event) + + def on_hci_le_cs_subevent_result_event(self, event): + self.emit('cs_subevent_result', event) + + def on_hci_le_cs_subevent_result_continue_event(self, event): + self.emit('cs_subevent_result_continue', event) + def on_hci_vendor_event(self, event): self.emit('vendor_event', event) diff --git a/bumble/smp.py b/bumble/smp.py index 4a7cff49..344959a1 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -1326,7 +1326,7 @@ def on_peer_key_distribution_complete(self) -> None: self.connection.abort_on('disconnection', self.on_pairing()) def on_connection_encryption_change(self) -> None: - if self.connection.is_encrypted: + if self.connection.is_encrypted and not self.completed: if self.is_responder: # The responder distributes its keys first, the initiator later self.distribute_keys() diff --git a/bumble/utils.py b/bumble/utils.py index d8864bb1..ba09f141 100644 --- a/bumble/utils.py +++ b/bumble/utils.py @@ -447,7 +447,7 @@ def deprecated(msg: str): def wrapper(function): @functools.wraps(function) def inner(*args, **kwargs): - warnings.warn(msg, DeprecationWarning) + warnings.warn(msg, DeprecationWarning, stacklevel=2) return function(*args, **kwargs) return inner @@ -464,7 +464,7 @@ def experimental(msg: str): def wrapper(function): @functools.wraps(function) def inner(*args, **kwargs): - warnings.warn(msg, FutureWarning) + warnings.warn(msg, FutureWarning, stacklevel=2) return function(*args, **kwargs) return inner diff --git a/examples/cs_initiator.json b/examples/cs_initiator.json new file mode 100644 index 00000000..82adc097 --- /dev/null +++ b/examples/cs_initiator.json @@ -0,0 +1,9 @@ +{ + "name": "Bumble CS Initiator", + "address": "F0:F1:F2:F3:F4:F5", + "advertising_interval": 100, + "keystore": "JsonKeyStore", + "irk": "865F81FF5A8B486EAAE29A27AD9F77DC", + "identity_address_type": 1, + "channel_sounding_enabled": true +} diff --git a/examples/cs_reflector.json b/examples/cs_reflector.json new file mode 100644 index 00000000..d76edb64 --- /dev/null +++ b/examples/cs_reflector.json @@ -0,0 +1,9 @@ +{ + "name": "Bumble CS Reflector", + "address": "F0:F1:F2:F3:F4:F6", + "advertising_interval": 100, + "keystore": "JsonKeyStore", + "irk": "0c7d74db03a1c98e7be691f76141d53d", + "identity_address_type": 1, + "channel_sounding_enabled": true +} diff --git a/examples/run_channel_sounding.py b/examples/run_channel_sounding.py new file mode 100644 index 00000000..011a3474 --- /dev/null +++ b/examples/run_channel_sounding.py @@ -0,0 +1,160 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations + +import asyncio +import logging +import sys +import os +import functools + +from bumble import core +from bumble import hci +from bumble.device import Connection, Device, ChannelSoundingCapabilities +from bumble.transport import open_transport_or_link + +# From https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/system/gd/hci/distance_measurement_manager.cc. +CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE = [ + [0, 4, 5, 6], + [1, 7, 7, 7], + [2, 7, 7, 7], + [3, 7, 7, 7], +] +CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE = [1, 1, 1, 1, 3, 7, 15, 3] +CS_ANTENNA_PERMUTATION_ARRAY = [ + [1, 2, 3, 4], + [2, 1, 3, 4], + [1, 3, 2, 4], + [3, 1, 2, 4], + [3, 2, 1, 4], + [2, 3, 1, 4], + [1, 2, 4, 3], + [2, 1, 4, 3], + [1, 4, 2, 3], + [4, 1, 2, 3], + [4, 2, 1, 3], + [2, 4, 1, 3], + [1, 4, 3, 2], + [4, 1, 3, 2], + [1, 3, 4, 2], + [3, 1, 4, 2], + [3, 4, 1, 2], + [4, 3, 1, 2], + [4, 2, 3, 1], + [2, 4, 3, 1], + [4, 3, 2, 1], + [3, 4, 2, 1], + [3, 2, 4, 1], + [2, 3, 4, 1], +] + + +# ----------------------------------------------------------------------------- +async def main() -> None: + if len(sys.argv) < 3: + print( + 'Usage: run_channel_sounding.py ' + '[target_address](If missing, run as reflector)' + ) + print('example: run_channel_sounding.py cs_reflector.json usb:0') + print( + 'example: run_channel_sounding.py cs_initiator.json usb:0 F0:F1:F2:F3:F4:F5' + ) + return + + print('<<< connecting to HCI...') + async with await open_transport_or_link(sys.argv[2]) as hci_transport: + print('<<< connected') + + device = Device.from_config_file_with_hci( + sys.argv[1], hci_transport.source, hci_transport.sink + ) + await device.power_on() + assert (local_cs_capabilities := device.cs_capabilities) + + if len(sys.argv) == 3: + await device.start_advertising( + own_address_type=hci.OwnAddressType.RANDOM, auto_restart=True + ) + + def on_cs_capabilities( + connection: Connection, capabilities: ChannelSoundingCapabilities + ): + del capabilities + asyncio.create_task( + device.send_command( + hci.HCI_LE_CS_Set_Default_Settings_Command( + connection_handle=connection.handle, + role_enable=( + hci.CsRoleMask.INITIATOR | hci.CsRoleMask.REFLECTOR + ), + cs_sync_antenna_selection=0xFF, + max_tx_power=0x04, + ), + check_result=True, + ) + ) + + device.on( + 'connection', + lambda connection: connection.on( + 'channel_sounding_capabilities', + functools.partial(on_cs_capabilities, connection), + ), + ) + else: + target_address = hci.Address(sys.argv[3]) + + connection = await device.connect( + target_address, transport=core.BT_LE_TRANSPORT + ) + if not (await device.get_long_term_key(connection.handle, b'', 0)): + await connection.pair() + await connection.encrypt() + + remote_capabilities = await device.get_remote_cs_capabilities(connection) + await device.send_command( + hci.HCI_LE_CS_Set_Default_Settings_Command( + connection_handle=connection.handle, + role_enable=(hci.CsRoleMask.INITIATOR | hci.CsRoleMask.REFLECTOR), + cs_sync_antenna_selection=0xFF, + max_tx_power=0x04, + ), + check_result=True, + ) + config = await device.create_cs_config(connection) + await device.enable_cs_security(connection) + tone_antenna_config_selection = CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE[ + local_cs_capabilities.num_antennas_supported - 1 + ][remote_capabilities.num_antennas_supported - 1] + await device.set_cs_procedure_parameters( + connection=connection, + config=config, + tone_antenna_config_selection=tone_antenna_config_selection, + preferred_peer_antenna=CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE[ + tone_antenna_config_selection + ], + ) + await device.enable_cs_procedure(connection=connection, config=config) + + await hci_transport.source.terminated + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main())