From d12b15b5d4e56d0a3d582879e3b51ddde24b7a05 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 16 Dec 2022 19:40:57 -0800 Subject: [PATCH] Merge/rebase --- apps/controller_info.py | 37 ++ apps/controllers.py | 30 +- apps/link_relay/link_relay.py | 11 +- bumble/controller.py | 886 +++++++++++++++++++++++++++++----- bumble/hci.py | 12 +- bumble/host.py | 4 +- bumble/link.py | 50 +- tests/controller_test.py | 138 ++++++ 8 files changed, 1016 insertions(+), 152 deletions(-) create mode 100644 tests/controller_test.py diff --git a/apps/controller_info.py b/apps/controller_info.py index 1e02a322..611163cc 100644 --- a/apps/controller_info.py +++ b/apps/controller_info.py @@ -24,6 +24,10 @@ from bumble.colors import color from bumble.core import name_or_number from bumble.hci import ( + HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND, + HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, + HCI_Read_Local_Extended_Features_Command, + HCI_Read_Local_Supported_Features_Command, map_null_terminated_utf8_string, HCI_SUCCESS, HCI_LE_SUPPORTED_FEATURES_NAMES, @@ -58,6 +62,36 @@ def command_succeeded(response): return False +# ----------------------------------------------------------------------------- +async def get_common_info(host): + if host.supports_command(HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND): + response = await host.send_command(HCI_Read_Local_Supported_Features_Command()) + if response.return_parameters.status == HCI_SUCCESS: + print() + print(color('LMP Features:', 'yellow')) + # TODO: support printing discrete enum values + print(' ', response.return_parameters.lmp_features.hex()) + + if host.supports_command(HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND): + response = await host.send_command( + HCI_Read_Local_Extended_Features_Command(page_number=0) + ) + if response.return_parameters.status == HCI_SUCCESS: + if response.return_parameters.max_page_number > 0: + print() + print(color('Extended LMP Features:', 'yellow')) + + for page in range(1, response.return_parameters.max_page_number + 1): + response = await host.send_command( + HCI_Read_Local_Extended_Features_Command(page_number=page) + ) + + if response.return_parameters.status == HCI_SUCCESS: + # TODO: support printing discrete enum values + print(f' Page {page}:') + print(' ', response.return_parameters.extended_lmp_features.hex()) + + # ----------------------------------------------------------------------------- async def get_classic_info(host): if host.supports_command(HCI_READ_BD_ADDR_COMMAND): @@ -162,6 +196,9 @@ async def async_main(transport): ) print(color(' LMP Subversion:', 'green'), host.local_version.lmp_subversion) + # Get the common info + await get_common_info(host) + # Get the Classic info await get_classic_info(host) diff --git a/apps/controllers.py b/apps/controllers.py index ac6477ec..fc74f734 100644 --- a/apps/controllers.py +++ b/apps/controllers.py @@ -17,31 +17,25 @@ # ----------------------------------------------------------------------------- import logging import asyncio -import sys import os -from bumble.controller import Controller +import click + +from bumble.controller import Controller, Options from bumble.link import LocalLink from bumble.transport import open_transport_or_link # ----------------------------------------------------------------------------- -async def async_main(): - if len(sys.argv) != 3: - print( - 'Usage: controllers.py ' - '[ ...]' - ) - print('example: python controllers.py pty:ble1 pty:ble2') - return - +async def async_main(extended_advertising, transport_names): # Create a local link to attach the controllers to link = LocalLink() # Create a transport and controller for all requested names transports = [] controllers = [] - for index, transport_name in enumerate(sys.argv[1:]): + options = Options(extended_advertising=extended_advertising) + for index, transport_name in enumerate(transport_names): transport = await open_transport_or_link(transport_name) transports.append(transport) controller = Controller( @@ -49,6 +43,7 @@ async def async_main(): host_source=transport.source, host_sink=transport.sink, link=link, + options=options, ) controllers.append(controller) @@ -61,9 +56,14 @@ async def async_main(): # ----------------------------------------------------------------------------- -def main(): - logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) - asyncio.run(async_main()) +@click.command() +@click.option( + '--extended-advertising', is_flag=True, help="Enable extended advertising" +) +@click.argument('transports', nargs=-1, required=True) +def main(extended_advertising, transports): + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper()) + asyncio.run(async_main(extended_advertising, transports)) # ----------------------------------------------------------------------------- diff --git a/apps/link_relay/link_relay.py b/apps/link_relay/link_relay.py index 6036fa0f..b58b6a91 100644 --- a/apps/link_relay/link_relay.py +++ b/apps/link_relay/link_relay.py @@ -253,7 +253,7 @@ async def serve(self, websocket, path): # ---------------------------------------------------------------------------- -def main(): +async def async_main(): # Check the Python version if sys.version_info < (3, 6, 1): print('ERROR: Python 3.6.1 or higher is required') @@ -280,8 +280,13 @@ def main(): # Start a relay relay = Relay(args.port) - asyncio.get_event_loop().run_until_complete(relay.start()) - asyncio.get_event_loop().run_forever() + async with relay.start(): + await asyncio.Future() + + +# ---------------------------------------------------------------------------- +def main(): + asyncio.run(async_main()) # ---------------------------------------------------------------------------- diff --git a/bumble/controller.py b/bumble/controller.py index 4ead098e..9ec472f7 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -19,9 +19,13 @@ import logging import asyncio +import heapq import itertools import random import struct +import time +from dataclasses import dataclass + from bumble.colors import color from bumble.core import ( BT_CENTRAL_ROLE, @@ -35,15 +39,105 @@ HCI_COMMAND_DISALLOWED_ERROR, HCI_COMMAND_PACKET, HCI_COMMAND_STATUS_PENDING, + HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE, HCI_CONNECTION_TIMEOUT_ERROR, HCI_CONTROLLER_BUSY_ERROR, + HCI_DISCONNECT_COMMAND, HCI_EVENT_PACKET, + HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE, + HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE, + HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND, + HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE, + HCI_HOST_BUFFER_SIZE_COMMAND, + HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND, HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR, HCI_LE_1M_PHY, + HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND, + HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_CLEAR_RESOLVING_LIST_COMMAND, + HCI_LE_CONNECTION_UPDATE_COMMAND, + HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND, + HCI_LE_CREATE_CONNECTION_COMMAND, + HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE, + HCI_LE_ENABLE_ENCRYPTION_COMMAND, + HCI_LE_ENCRYPT_COMMAND, + HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE, + HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND, + HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND, + HCI_LE_PING_LE_SUPPORTED_FEATURE, + HCI_LE_RAND_COMMAND, + HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND, + HCI_LE_READ_BUFFER_SIZE_COMMAND, + HCI_LE_READ_CHANNEL_MAP_COMMAND, + HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND, + HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND, + HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, + HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND, + HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND, + HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND, + HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND, + HCI_LE_READ_PHY_COMMAND, + HCI_LE_READ_REMOTE_FEATURES_COMMAND, + HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND, + HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND, + HCI_LE_READ_SUPPORTED_STATES_COMMAND, + HCI_LE_READ_TRANSMIT_POWER_COMMAND, + HCI_LE_RECEIVER_TEST_COMMAND, + HCI_LE_RECEIVER_TEST_V2_COMMAND, + HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND, + HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND, + HCI_LE_REMOVE_ADVERTISING_SET_COMMAND, + HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND, + HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND, + HCI_LE_SET_ADVERTISING_DATA_COMMAND, + HCI_LE_SET_ADVERTISING_ENABLE_COMMAND, + HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND, + HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND, + HCI_LE_SET_DATA_LENGTH_COMMAND, + HCI_LE_SET_DEFAULT_PHY_COMMAND, + HCI_LE_SET_EVENT_MASK_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND, + HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND, + HCI_LE_SET_PHY_COMMAND, + HCI_LE_SET_PRIVACY_MODE_COMMAND, + HCI_LE_SET_RANDOM_ADDRESS_COMMAND, + HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND, + HCI_LE_SET_SCAN_ENABLE_COMMAND, + HCI_LE_SET_SCAN_PARAMETERS_COMMAND, + HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND, + HCI_LE_TEST_END_COMMAND, + HCI_LE_TRANSMITTER_TEST_COMMAND, + HCI_LE_TRANSMITTER_TEST_V2_COMMAND, + HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND, + HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE, + HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE, + HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND, + HCI_READ_BD_ADDR_COMMAND, + HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, + HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND, + HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND, + HCI_READ_RSSI_COMMAND, + HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND, + HCI_RESET_COMMAND, + HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND, + HCI_SET_EVENT_MASK_COMMAND, + HCI_SET_EVENT_MASK_PAGE_2_COMMAND, HCI_SUCCESS, + HCI_SUPPORTED_COMMANDS_FLAGS, + HCI_UNKNOWN_ADVERTISING_IDENTIFIER_ERROR, HCI_UNKNOWN_HCI_COMMAND_ERROR, HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, + HCI_CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR, + HCI_MEMORY_CAPACITY_EXCEEDED_ERROR, HCI_VERSION_BLUETOOTH_CORE_5_0, + HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND, Address, HCI_AclDataPacket, HCI_AclDataPacketAssembler, @@ -51,16 +145,19 @@ HCI_Command_Status_Event, HCI_Connection_Complete_Event, HCI_Connection_Request_Event, + HCI_Constant, HCI_Disconnection_Complete_Event, HCI_Encryption_Change_Event, HCI_LE_Advertising_Report_Event, HCI_LE_Connection_Complete_Event, + HCI_LE_Extended_Advertising_Report_Event, HCI_LE_Read_Remote_Features_Complete_Event, HCI_Number_Of_Completed_Packets_Event, HCI_Packet, HCI_Role_Change_Event, + HCI_Command ) -from typing import Optional, Union, Dict, TYPE_CHECKING +from typing import Dict, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING if TYPE_CHECKING: from bumble.transport.common import TransportSink, TransportSource @@ -71,6 +168,12 @@ logger = logging.getLogger(__name__) +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +TIMER_TOLERANCE = 0.01 # 10ms + + # ----------------------------------------------------------------------------- # Utils # ----------------------------------------------------------------------------- @@ -78,6 +181,25 @@ class DataObject: pass +# ----------------------------------------------------------------------------- +def le_supported_features_as_bytes(supported_features: Sequence[int]) -> bytes: + return struct.pack(' bytes: + result = [0] * 64 + + for command in supported_commands: + for octet in range(64): + flags = HCI_SUPPORTED_COMMANDS_FLAGS[octet] + if command in flags: + result[octet] |= 1 << flags.index(command) + break + + return bytes(result) + + # ----------------------------------------------------------------------------- class Connection: def __init__(self, controller, handle, role, peer_address, link, transport): @@ -96,10 +218,242 @@ def on_hci_acl_data_packet(self, packet): ) def on_acl_pdu(self, data): - if self.link: - self.link.send_acl_data( - self.controller, self.peer_address, self.transport, data - ) + self.link.send_acl_data( + self.controller, self.peer_address, self.transport, data + ) + + def __str__(self): + return ( + f'Connection[{HCI_Constant.role_name(self.role)}]' + f'({self.controller.random_address} -> ' + f'{self.peer_address})' + ) + + +# ----------------------------------------------------------------------------- +@dataclass +class Options: + extended_advertising: bool = False + + +# ----------------------------------------------------------------------------- +class Advertiser: + enabled: bool = False + def send_advertising_data(self, controller: Controller): + pass + + +# ----------------------------------------------------------------------------- +class LegacyAdvertiser(Advertiser): + def __init__(self, parameters, address): + self.address = address + self.parameters = parameters + self.data = b'' + self.scan_response_data = b'' + + def send_advertising_data(self, controller: Controller): + if self.parameters is None or not self.enabled: + return + + controller.link.send_advertising_data(self.address, self.data, self.scan_response_data) + + next_advertising_time = ( + time.time() + self.parameters.advertising_interval_min / 625.0 + ) + controller.schedule_advertiser(self, next_advertising_time) + + +# ----------------------------------------------------------------------------- +class ExtendedAdvertiser(Advertiser): + def __init__(self, parameters, address): + self.parameters = parameters + self.address = address + self.data = b'' + self.scan_response_data = b'' + self.max_extended_advertising_events = 0 + self.extended_advertising_events = 0 + self.duration = 0 + self.first_advertising_time = 0.0 + self.expired = False + + @property + def tx_power(self): + return ( + 0 + if self.parameters.advertising_tx_power == 0x7F + else self.parameters.advertising_tx_power + ) + + @property + def is_connectable(self): + return self.parameters.advertising_event_properties & (1 << 0) != 0 + + @property + def is_scannable(self): + return self.parameters.advertising_event_properties & (1 << 1) != 0 + + @property + def is_directed(self): + return self.parameters.advertising_event_properties & (1 << 2) != 0 + + @property + def is_high_duty_cycle_directed(self): + return self.parameters.advertising_event_properties & (1 << 3) != 0 + + @property + def is_legacy(self): + return self.parameters.advertising_event_properties & (1 << 4) != 0 + + @property + def is_anonymous(self): + return self.parameters.advertising_event_properties & (1 << 5) != 0 + + def send_advertising_data(self, controller: Controller): + if not self.enabled or self.expired: + return + + now = time.time() + if self.extended_advertising_events == 0: + self.first_advertising_time = now + + if self.duration: + elapsed = now - self.first_advertising_time + if elapsed > self.duration / 100.0: + self.expired = True + return + + controller.link.send_extended_advertising_data( + self.address, + self.parameters.advertising_event_properties, + self.data, + self.scan_response_data, + ) + + self.extended_advertising_events += 1 + if ( + self.max_extended_advertising_events > 0 + and self.extended_advertising_events >= self.max_extended_advertising_events + ): + self.expired = True + return + + next_advertising_time = ( + time.time() + self.parameters.primary_advertising_interval_min / 625.0 + ) + controller.schedule_advertiser(self, next_advertising_time) + + +# ----------------------------------------------------------------------------- +@dataclass +class Options: + extended_advertising: bool = False + + +# ----------------------------------------------------------------------------- +class LegacyAdvertiser: + def __init__(self, parameters, address): + self.address = address + self.parameters = parameters + self.data = b'' + self.scan_response_data = b'' + self.enabled = False + self.next_advertising_time = 0 # When to advertise next + + def schedule(self): + if self.parameters is None: + return + self.next_advertising_time = ( + time.time() + self.parameters.advertising_interval_min / 625.0 + ) + + def send_advertising_data(self, link): + if self.parameters is None or not self.enabled: + return + + link.send_advertising_data(self.address, self.data, self.scan_response_data) + self.schedule() + + +# ----------------------------------------------------------------------------- +class ExtendedAdvertiser: + def __init__(self, parameters, address): + self.parameters = parameters + self.address = address + self.data = b'' + self.scan_response_data = b'' + self.enabled = False + self.max_extended_advertising_events = 0 + self.extended_advertising_events = 0 + self.duration = 0 + self.first_advertising_time = 0 + self.next_advertising_time = 0 # When to advertise next + + @property + def tx_power(self): + return ( + 0 + if self.parameters.advertising_tx_power == 0x7F + else self.parameters.advertising_tx_power + ) + + @property + def is_connectable(self): + return self.parameters.advertising_event_properties & (1 << 0) != 0 + + @property + def is_scannable(self): + return self.parameters.advertising_event_properties & (1 << 1) != 0 + + @property + def is_directed(self): + return self.parameters.advertising_event_properties & (1 << 2) != 0 + + @property + def is_high_duty_cycle_directed(self): + return self.parameters.advertising_event_properties & (1 << 3) != 0 + + @property + def is_legacy(self): + return self.parameters.advertising_event_properties & (1 << 4) != 0 + + @property + def is_anonymous(self): + return self.parameters.advertising_event_properties & (1 << 5) != 0 + + def schedule(self): + self.next_advertising_time = ( + time.time() + self.parameters.primary_advertising_interval_min / 625.0 + ) + + def send_advertising_data(self, link): + if not self.enabled: + return + + if ( + self.max_extended_advertising_events > 0 + and self.extended_advertising_events >= self.max_extended_advertising_events + ): + self.next_advertising_time = 0 + return + + now = time.time() + if self.extended_advertising_events == 0: + self.first_advertising_time = now + + if self.duration: + elapsed = now - self.first_advertising_time + if elapsed > self.duration / 100.0: + self.next_advertising_time = 0 + return + + self.extended_advertising_events += 1 + link.send_extended_advertising_data( + self.address, + self.parameters.advertising_event_properties, + self.data, + self.scan_response_data, + ) + self.schedule() # ----------------------------------------------------------------------------- @@ -111,20 +465,15 @@ def __init__( host_sink: Optional[TransportSink] = None, link=None, public_address: Optional[Union[bytes, str, Address]] = None, + options: Optional[Options] = None, ): self.name = name self.hci_sink = None self.link = link + self.options = options or Options() - self.central_connections: Dict[ - Address, Connection - ] = {} # Connections where this controller is the central - self.peripheral_connections: Dict[ - Address, Connection - ] = {} # Connections where this controller is the peripheral - self.classic_connections: Dict[ - Address, Connection - ] = {} # Connections in BR/EDR + self.le_connections: Dict[Address, Connection] = {} # BLE Connections + self.classic_connections: Dict[Address, Connection] = {} # BR/EDR Connections self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0 self.hci_revision = 0 @@ -138,13 +487,105 @@ def __init__( self.hc_total_num_le_data_packets = 64 self.event_mask = 0 self.event_mask_page_2 = 0 - self.supported_commands = bytes.fromhex( - '2000800000c000000000e40000002822000000000000040000f7ffff7f000000' - '30f0f9ff01008004000000000000000000000000000000000000000000000000' - ) + supported_commands = [ + HCI_DISCONNECT_COMMAND, + HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND, + HCI_SET_EVENT_MASK_COMMAND, + HCI_RESET_COMMAND, + HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND, + HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND, + HCI_HOST_BUFFER_SIZE_COMMAND, + HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND, + HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND, + HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, + HCI_READ_BD_ADDR_COMMAND, + HCI_READ_RSSI_COMMAND, + HCI_SET_EVENT_MASK_PAGE_2_COMMAND, + HCI_LE_SET_EVENT_MASK_COMMAND, + HCI_LE_READ_BUFFER_SIZE_COMMAND, + HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, + HCI_LE_SET_RANDOM_ADDRESS_COMMAND, + HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND, + HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND, + HCI_LE_SET_ADVERTISING_DATA_COMMAND, + HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND, + HCI_LE_SET_ADVERTISING_ENABLE_COMMAND, + HCI_LE_SET_SCAN_PARAMETERS_COMMAND, + HCI_LE_SET_SCAN_ENABLE_COMMAND, + HCI_LE_CREATE_CONNECTION_COMMAND, + HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND, + HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND, + HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND, + HCI_LE_CONNECTION_UPDATE_COMMAND, + HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND, + HCI_LE_READ_CHANNEL_MAP_COMMAND, + HCI_LE_READ_REMOTE_FEATURES_COMMAND, + HCI_LE_ENCRYPT_COMMAND, + HCI_LE_RAND_COMMAND, + HCI_LE_ENABLE_ENCRYPTION_COMMAND, + HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND, + HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND, + HCI_LE_READ_SUPPORTED_STATES_COMMAND, + HCI_LE_RECEIVER_TEST_COMMAND, + HCI_LE_TRANSMITTER_TEST_COMMAND, + HCI_LE_TEST_END_COMMAND, + HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND, + HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND, + HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND, + HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND, + HCI_LE_SET_DATA_LENGTH_COMMAND, + HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND, + HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND, + HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND, + HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND, + HCI_LE_CLEAR_RESOLVING_LIST_COMMAND, + HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND, + HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND, + HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND, + HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND, + HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND, + HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND, + HCI_LE_READ_PHY_COMMAND, + HCI_LE_SET_DEFAULT_PHY_COMMAND, + HCI_LE_SET_PHY_COMMAND, + HCI_LE_RECEIVER_TEST_V2_COMMAND, + HCI_LE_TRANSMITTER_TEST_V2_COMMAND, + HCI_LE_READ_TRANSMIT_POWER_COMMAND, + HCI_LE_SET_PRIVACY_MODE_COMMAND, + ] + if self.options.extended_advertising: + supported_commands.extend( + [ + HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND, + HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND, + HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND, + HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND, + HCI_LE_REMOVE_ADVERTISING_SET_COMMAND, + HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND, + HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND, + ] + ) + self.supported_commands = supported_commands_as_bytes(supported_commands) self.le_event_mask = 0 - self.advertising_parameters = None - self.le_features = bytes.fromhex('ff49010000000000') + le_features = [ + HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE, + HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE, + HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE, + HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE, + HCI_LE_PING_LE_SUPPORTED_FEATURE, + HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE, + HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE, + HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE, + ] + if self.options.extended_advertising: + le_features.append(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE) + self.le_features = le_supported_features_as_bytes(le_features) self.le_states = bytes.fromhex('ffff3fffff030000') self.advertising_channel_tx_power = 0 self.filter_accept_list_size = 8 @@ -163,16 +604,13 @@ def __init__( self.le_scan_enable = 0 self.le_scan_own_address_type = Address.RANDOM_DEVICE_ADDRESS self.le_scanning_filter_policy = 0 - self.le_scan_response_data = None self.le_address_resolution = False self.le_rpa_timeout = 0 + self.le_maximum_advertising_data_length = 0x0672 + self.le_number_of_supported_advertising_sets = 64 self.sync_flow_control = False self.local_name = 'Bumble' - self.advertising_interval = 2000 # Fixed for now - self.advertising_data = None - self.advertising_timer_handle = None - self._random_address = Address('00:00:00:00:00:00') if isinstance(public_address, Address): self._public_address = public_address @@ -183,6 +621,11 @@ def __init__( else: self._public_address = Address('00:00:00:00:00:00') + self.advertising_timer_handle = None + self.advertising_times: List[Tuple[float, Advertiser]] + self.legacy_advertiser = LegacyAdvertiser(None, self.random_address) + self.extended_advertisers: Dict[int, Exception] = {} # Advertisers, by handle + # Set the source and sink interfaces if host_source: host_source.set_packet_sink(self) @@ -235,8 +678,7 @@ def random_address(self, address): self._random_address = address logger.debug(f'new random address: {address}') - if self.link: - self.link.on_address_changed(self) + self.link.on_address_changed(self) # Packet Sink protocol (packets coming from the host via HCI) def on_packet(self, packet): @@ -305,8 +747,7 @@ def allocate_connection_handle(self): handle = 0 max_handle = 0 for connection in itertools.chain( - self.central_connections.values(), - self.peripheral_connections.values(), + self.le_connections.values(), self.classic_connections.values(), ): max_handle = max(max_handle, connection.handle) @@ -316,25 +757,22 @@ def allocate_connection_handle(self): return handle def find_le_connection_by_address(self, address): - return self.central_connections.get(address) or self.peripheral_connections.get( - address - ) + return self.le_connections.get(address) def find_classic_connection_by_address(self, address): return self.classic_connections.get(address) def find_connection_by_handle(self, handle): for connection in itertools.chain( - self.central_connections.values(), - self.peripheral_connections.values(), + self.le_connections.values(), self.classic_connections.values(), ): if connection.handle == handle: return connection return None - def find_central_connection_by_handle(self, handle): - for connection in self.central_connections.values(): + def find_le_connection_by_handle(self, handle): + for connection in self.le_connections.values(): if connection.handle == handle: return connection return None @@ -353,7 +791,7 @@ def on_link_central_connected(self, central_address): # Allocate (or reuse) a connection handle peer_address = central_address peer_address_type = central_address.address_type - connection = self.peripheral_connections.get(peer_address) + connection = self.le_connections.get(peer_address) if connection is None: connection_handle = self.allocate_connection_handle() connection = Connection( @@ -364,7 +802,7 @@ def on_link_central_connected(self, central_address): self.link, BT_LE_TRANSPORT, ) - self.peripheral_connections[peer_address] = connection + self.le_connections[peer_address] = connection logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}') # Then say that the connection has completed @@ -382,13 +820,13 @@ def on_link_central_connected(self, central_address): ) ) - def on_link_central_disconnected(self, peer_address, reason): + def on_link_peer_disconnected(self, peer_address, reason): ''' Called when an active disconnection occurs from a peer ''' # Send a disconnection complete event - if connection := self.peripheral_connections.get(peer_address): + if connection := self.le_connections.get(peer_address): self.send_hci_packet( HCI_Disconnection_Complete_Event( status=HCI_SUCCESS, @@ -398,8 +836,11 @@ def on_link_central_disconnected(self, peer_address, reason): ) # Remove the connection - del self.peripheral_connections[peer_address] + logger.debug(f'PEER connection removed: {connection}') + del self.le_connections[peer_address] else: + for address in self.le_connections: + print(str(address), str(self.le_connections[address])) logger.warning(f'!!! No peripheral connection found for {peer_address}') def on_link_peripheral_connection_complete( @@ -412,7 +853,7 @@ def on_link_peripheral_connection_complete( if status == HCI_SUCCESS: # Allocate (or reuse) a connection handle peer_address = le_create_connection_command.peer_address - connection = self.central_connections.get(peer_address) + connection = self.le_connections.get(peer_address) if connection is None: connection_handle = self.allocate_connection_handle() connection = Connection( @@ -423,7 +864,7 @@ def on_link_peripheral_connection_complete( self.link, BT_LE_TRANSPORT, ) - self.central_connections[peer_address] = connection + self.le_connections[peer_address] = connection logger.debug( f'New CENTRAL connection handle: 0x{connection_handle:04X}' ) @@ -446,9 +887,9 @@ def on_link_peripheral_connection_complete( ) ) - def on_link_peripheral_disconnection_complete(self, disconnection_command, status): + def on_link_initiated_disconnection_complete(self, disconnection_command, status): ''' - Called when a disconnection has been completed + Called when a disconnection that we initiated has been completed ''' # Send a disconnection complete event @@ -456,24 +897,24 @@ def on_link_peripheral_disconnection_complete(self, disconnection_command, statu HCI_Disconnection_Complete_Event( status=status, connection_handle=disconnection_command.connection_handle, - reason=disconnection_command.reason, + reason=HCI_CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR, ) ) # Remove the connection - if connection := self.find_central_connection_by_handle( + if connection := self.find_le_connection_by_handle( disconnection_command.connection_handle ): - logger.debug(f'CENTRAL Connection removed: {connection}') - del self.central_connections[connection.peer_address] + logger.debug(f'INITIATOR connection removed: {connection}') + del self.le_connections[connection.peer_address] - def on_link_peripheral_disconnected(self, peer_address): + def on_link_connection_lost(self, peer_address): ''' - Called when a connection to a peripheral is broken + Called when a connection to a peer is broken ''' # Send a disconnection complete event - if connection := self.central_connections.get(peer_address): + if connection := self.le_connections.get(peer_address): self.send_hci_packet( HCI_Disconnection_Complete_Event( status=HCI_SUCCESS, @@ -483,7 +924,8 @@ def on_link_peripheral_disconnected(self, peer_address): ) # Remove the connection - del self.central_connections[peer_address] + logger.debug(f'PEER connection lost: {connection}') + del self.le_connections[peer_address] else: logger.warning(f'!!! No central connection found for {peer_address}') @@ -511,12 +953,12 @@ def on_link_acl_data(self, sender_address, transport, data): acl_packet = HCI_AclDataPacket(connection.handle, 2, 0, len(data), data) self.send_hci_packet(acl_packet) - def on_link_advertising_data(self, sender_address, data): + def on_link_advertising_data(self, sender_address, data, scan_response): # Ignore if we're not scanning if self.le_scan_enable == 0: return - # Send a scan report + # Send an advertising report report = HCI_LE_Advertising_Report_Event.Report( HCI_LE_Advertising_Report_Event.Report.FIELDS, event_type=HCI_LE_Advertising_Report_Event.ADV_IND, @@ -533,11 +975,62 @@ def on_link_advertising_data(self, sender_address, data): event_type=HCI_LE_Advertising_Report_Event.SCAN_RSP, address_type=sender_address.address_type, address=sender_address, - data=data, + data=scan_response, rssi=-50, ) self.send_hci_packet(HCI_LE_Advertising_Report_Event([report])) + def on_link_extended_advertising_data( + self, sender_address, event_properties, data, scan_response + ): + # Ignore if we're not scanning + if self.le_scan_enable == 0: + return + + # Send an advertising report + event_type = ( + 1 << HCI_LE_Extended_Advertising_Report_Event.CONNECTABLE_ADVERTISING + ) + report = HCI_LE_Extended_Advertising_Report_Event.Report( + HCI_LE_Extended_Advertising_Report_Event.Report.FIELDS, + event_type=event_type, + address_type=sender_address.address_type, + address=sender_address, + primary_phy=HCI_LE_1M_PHY, + secondary_phy=HCI_LE_1M_PHY, + advertising_sid=0, + tx_power=0, + rssi=-50, + periodic_advertising_interval=0, + direct_address_type=0, + direct_address=Address.NIL, + data=data, + ) + self.send_hci_packet(HCI_LE_Extended_Advertising_Report_Event([report])) + + # Simulate a scan response if needed + if event_properties & (1 << 1) == 0: + # The event is not scannable + return + + event_type |= 1 << HCI_LE_Extended_Advertising_Report_Event.SCAN_RESPONSE + report = HCI_LE_Extended_Advertising_Report_Event.Report( + HCI_LE_Extended_Advertising_Report_Event.Report.FIELDS, + event_type=event_type, + address_type=sender_address.address_type, + address=sender_address, + primary_phy=HCI_LE_1M_PHY, + secondary_phy=HCI_LE_1M_PHY, + advertising_sid=0, + tx_power=0, + rssi=-50, + periodic_advertising_interval=0, + direct_address_type=0, + direct_address=Address.NIL, + data=scan_response, + ) + self.send_hci_packet(HCI_LE_Extended_Advertising_Report_Event([report])) + ############################################################ # Classic link connections ############################################################ @@ -623,32 +1116,29 @@ def on_classic_role_change(self, peer_address, new_role): # Advertising support ############################################################ def on_advertising_timer_fired(self): - self.send_advertising_data() - self.advertising_timer_handle = asyncio.get_running_loop().call_later( - self.advertising_interval / 1000.0, self.on_advertising_timer_fired - ) - - def start_advertising(self): - # Stop any ongoing advertising before we start again - self.stop_advertising() - - # Advertise now - self.advertising_timer_handle = asyncio.get_running_loop().call_soon( - self.on_advertising_timer_fired - ) + self.advertising_timer_handle = None - def stop_advertising(self): - if self.advertising_timer_handle is not None: - self.advertising_timer_handle.cancel() - self.advertising_timer_handle = None + while self.advertising_times: + now = time.time() + (when, advertiser) = self.advertising_times[0] + if when + TIMER_TOLERANCE < now: + break + advertiser.send_advertising_data(self) + + # Schedule the next event + if self.advertising_times: + delay = max(self.advertising_times[0][0] - time.time(), 0) + self.advertising_timer_handle = asyncio.get_running_loop().call_later( + delay, self.on_advertising_timer_fired + ) - def send_advertising_data(self): - if self.link and self.advertising_data: - self.link.send_advertising_data(self.random_address, self.advertising_data) + def schedule_advertiser(self, advertiser: Advertiser, when: float): + heapq.heappush(self.advertising_times, (when, advertiser)) + if self.advertising_timer_handle is None: + self.advertising_timer_handle = asyncio.get_running_loop().call_soon( + self.on_advertising_timer_fired + ) - @property - def is_advertising(self): - return self.advertising_timer_handle is not None ############################################################ # HCI handlers @@ -662,8 +1152,6 @@ def on_hci_create_connection_command(self, command): See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command ''' - if self.link is None: - return logger.debug(f'Connection request to {command.bd_addr}') # Check that we don't already have a pending connection @@ -703,32 +1191,20 @@ def on_hci_disconnect_command(self, command): # Notify the link of the disconnection handle = command.connection_handle - if connection := self.find_central_connection_by_handle(handle): - if self.link: - self.link.disconnect( - self.random_address, connection.peer_address, command - ) - else: - # Remove the connection - del self.central_connections[connection.peer_address] + if connection := self.find_le_connection_by_handle(handle): + self.link.disconnect(self.random_address, connection.peer_address, command) elif connection := self.find_classic_connection_by_handle(handle): - if self.link: - self.link.classic_disconnect( - self, - connection.peer_address, - HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, - ) - else: - # Remove the connection - del self.classic_connections[connection.peer_address] + self.link.classic_disconnect( + self, + connection.peer_address, + HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, + ) def on_hci_accept_connection_request_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command ''' - if self.link is None: - return self.send_hci_packet( HCI_Command_Status_Event( status=HCI_SUCCESS, @@ -743,8 +1219,6 @@ def on_hci_switch_role_command(self, command): See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command ''' - if self.link is None: - return self.send_hci_packet( HCI_Command_Status_Event( status=HCI_SUCCESS, @@ -925,6 +1399,12 @@ def on_hci_read_bd_addr_command(self, _command): ) return bytes([HCI_SUCCESS]) + bd_addr + def on_hci_read_local_extended_features_command(self, _command): + ''' + See Bluetooth spec @ 7.4.4 Read Local Extended Features Command + ''' + return bytes([HCI_SUCCESS]) + bytes(8) + def on_hci_le_set_event_mask_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command @@ -961,7 +1441,7 @@ def on_hci_le_set_advertising_parameters_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command ''' - self.advertising_parameters = command + self.legacy_advertiser.parameters = command return bytes([HCI_SUCCESS]) def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command): @@ -975,14 +1455,14 @@ def on_hci_le_set_advertising_data_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command ''' - self.advertising_data = command.advertising_data + self.legacy_advertiser.data = command.advertising_data return bytes([HCI_SUCCESS]) def on_hci_le_set_scan_response_data_command(self, command): ''' See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command ''' - self.le_scan_response_data = command.scan_response_data + self.legacy_advertiser.scan_response_data = command.scan_response_data return bytes([HCI_SUCCESS]) def on_hci_le_set_advertising_enable_command(self, command): @@ -990,9 +1470,11 @@ def on_hci_le_set_advertising_enable_command(self, command): See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command ''' if command.advertising_enable: - self.start_advertising() + if not self.legacy_advertiser.enabled: + self.legacy_advertiser.enabled = True + self.schedule_advertiser(self.legacy_advertiser, time.time()) else: - self.stop_advertising() + self.legacy_advertiser.enabled = False return bytes([HCI_SUCCESS]) @@ -1120,9 +1602,7 @@ def on_hci_le_enable_encryption_command(self, command): # Check the parameters if not ( - connection := self.find_central_connection_by_handle( - command.connection_handle - ) + connection := self.find_le_connection_by_handle(command.connection_handle) ): logger.warning('connection not found') return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR]) @@ -1258,6 +1738,192 @@ def on_hci_le_set_default_phy_command(self, command): } return bytes([HCI_SUCCESS]) + def on_hci_le_set_advertising_set_random_address_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.52 LE Set Advertising Set Random Address + Command + ''' + if ( + advertiser := self.extended_advertisers.get( + command.advertising_handle, None + ) + ) is None: + return bytes([HCI_UNKNOWN_ADVERTISING_IDENTIFIER_ERROR]) + + if advertiser.enabled and advertiser.is_connectable: + return bytes([HCI_COMMAND_DISALLOWED_ERROR]) + + advertiser.address = command.random_address + + return bytes([HCI_SUCCESS]) + + def on_hci_le_set_extended_advertising_parameters_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.53 LE Set Extended Advertising Parameters + Command + ''' + # Check if the advertiser already exists + if advertiser := self.extended_advertisers.get( + command.advertising_handle, None + ): + # We cannot update an advertiser that's currently enabled + if advertiser.enabled: + return bytes([HCI_COMMAND_DISALLOWED_ERROR, 0]) + + # Update the advertiser + advertiser.parameters = command + else: + # Try to create a new advertiser + if ( + len(self.extended_advertisers) + >= self.le_number_of_supported_advertising_sets + ): + logger.warning('too many advertisers') + return bytes([HCI_MEMORY_CAPACITY_EXCEEDED_ERROR, 0]) + + logger.debug(f'new advertiser: {command.advertising_handle}') + # TODO: allow other addresses + advertiser = ExtendedAdvertiser(command, self.random_address) + self.extended_advertisers[command.advertising_handle] = advertiser + + return bytes([HCI_SUCCESS, advertiser.tx_power]) + + def on_hci_le_set_extended_advertising_data_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.54 LE Set Extended Advertising Data + Command + ''' + if ( + advertiser := self.extended_advertisers.get( + command.advertising_handle, None + ) + ) is None: + return bytes([HCI_UNKNOWN_ADVERTISING_IDENTIFIER_ERROR]) + + if command.operation not in (3, 4) and not command.advertising_data: + return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR]) + + if advertiser.enabled and command.operation not in (3, 4): + return bytes([HCI_COMMAND_DISALLOWED_ERROR]) + + updated_data = None + if command.operation == 0: + # Intermediate fragment of fragmented extended advertising data + updated_data = advertiser.data + command.advertising_data + elif command.operation == 1: + # First fragment of fragmented extended advertising data + updated_data = command.advertising_data + elif command.operation == 2: + # Last fragment of fragmented extended advertising data + updated_data = advertiser.data + command.advertising_data + elif command.operation == 3: + # Complete extended advertising data + updated_data = command.advertising_data + elif command.operation == 4: + # Unchanged data (just update the Advertising DID) + if ( + not advertiser.enabled + or not advertiser.data + or advertiser.is_legacy + or command.advertising_data + ): + return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR]) + else: + return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR]) + + if updated_data is not None: + if len(updated_data) > self.le_maximum_advertising_data_length: + return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR]) + advertiser.data = updated_data + logger.debug(f'updating advertiser data: {updated_data.hex()}') + + return bytes([HCI_SUCCESS]) + + def on_hci_le_set_extended_scan_response_data_command(self, _command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.55 LE Set Extended Scan Response Data + Command + ''' + # TODO: not implemented yet + return bytes([HCI_SUCCESS]) + + def on_hci_le_set_extended_advertising_enable_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.56 LE Set Extended Advertising Enable + Command + ''' + for advertising_handle in command.advertising_handles: + if ( + advertiser := self.extended_advertisers.get(advertising_handle, None) + ) is None: + return bytes([HCI_UNKNOWN_ADVERTISING_IDENTIFIER_ERROR]) + + for i, advertising_handle in enumerate(command.advertising_handles): + advertiser = self.extended_advertisers[advertising_handle] + if command.enable: + if advertiser.expired or not advertiser.enabled: + advertiser.enabled = True + advertiser.duration = command.durations[i] + advertiser.extended_advertising_events = 0 + advertiser.max_extended_advertising_events = ( + command.max_extended_advertising_events[i] + ) + self.schedule_advertiser(advertiser, time.time()) + else: + advertiser.enabled = False + + return bytes([HCI_SUCCESS]) + + def on_hci_le_read_maximum_advertising_data_length_command(self, _command): + ''' + See Bluetooth spec Vol 2, Part E - 7.8.57 LE Read Maximum Advertising Data + Length Command + ''' + return struct.pack(' None: and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets ): packet = self.acl_packet_queue.pop() - self.send_hci_packet(packet) self.acl_packets_in_flight += 1 + self.send_hci_packet(packet) def supports_command(self, command): # Find the support flag position for this command @@ -568,7 +568,7 @@ def on_hci_number_of_completed_packets_event(self, event): else: logger.warning( color( - '!!! {total_packets} completed but only ' + f'!!! {total_packets} completed but only ' f'{self.acl_packets_in_flight} in flight' ) ) diff --git a/bumble/link.py b/bumble/link.py index 85ad96e4..9997051f 100644 --- a/bumble/link.py +++ b/bumble/link.py @@ -95,11 +95,21 @@ def get_pending_connection(self): def on_address_changed(self, controller): pass - def send_advertising_data(self, sender_address, data): + def send_advertising_data(self, sender_address, data, scan_response): # Send the advertising data to all controllers, except the sender for controller in self.controllers: if controller.random_address != sender_address: - controller.on_link_advertising_data(sender_address, data) + controller.on_link_advertising_data(sender_address, data, scan_response) + + def send_extended_advertising_data( + self, sender_address, event_type, data, scan_response + ): + # Send the advertising data to all controllers, except the sender + for controller in self.controllers: + if controller.random_address != sender_address: + controller.on_link_extended_advertising_data( + sender_address, event_type, data, scan_response + ) def send_acl_data(self, sender_controller, destination_address, transport, data): # Send the data to the first controller with a matching address @@ -151,30 +161,34 @@ def connect(self, central_address, le_create_connection_command): asyncio.get_running_loop().call_soon(self.on_connection_complete) def on_disconnection_complete( - self, central_address, peripheral_address, disconnect_command + self, initiator_address, peer_address, disconnect_command ): # Find the controller that initiated the disconnection - if not (central_controller := self.find_controller(central_address)): + if not (initiator_controller := self.find_controller(initiator_address)): logger.warning('!!! Initiating controller not found') return # Disconnect from the first controller with a matching address - if peripheral_controller := self.find_controller(peripheral_address): - peripheral_controller.on_link_central_disconnected( - central_address, disconnect_command.reason + if peer_controller := self.find_controller(peer_address): + peer_controller.on_link_peer_disconnected( + initiator_address, disconnect_command.reason ) - central_controller.on_link_peripheral_disconnection_complete( + initiator_controller.on_link_initiated_disconnection_complete( disconnect_command, HCI_SUCCESS ) - def disconnect(self, central_address, peripheral_address, disconnect_command): + def disconnect(self, initiator_address, peer_address, disconnect_command): logger.debug( - f'$$$ DISCONNECTION {central_address} -> ' - f'{peripheral_address}: reason = {disconnect_command.reason}' + f'$$$ DISCONNECTION {initiator_address} -> ' + f'{peer_address}: reason = {disconnect_command.reason}' + ) + asyncio.get_running_loop().call_soon( + self.on_disconnection_complete, + initiator_address, + peer_address, + disconnect_command, ) - args = [central_address, peripheral_address, disconnect_command] - asyncio.get_running_loop().call_soon(self.on_disconnection_complete, *args) # pylint: disable=too-many-arguments def on_connection_encrypted( @@ -360,11 +374,11 @@ async def on_result_received(self, result): async def on_left_received(self, address): if address in self.central_connections: - self.controller.on_link_peripheral_disconnected(Address(address)) + self.controller.on_link_connection_lost(Address(address)) self.central_connections.remove(address) if address in self.peripheral_connections: - self.controller.on_link_central_disconnected( + self.controller.on_link_peer_disconnected( address, HCI_CONNECTION_TIMEOUT_ERROR ) self.peripheral_connections.remove(address) @@ -384,7 +398,7 @@ async def on_message_received(self, message): async def on_advertisement_message_received(self, sender, advertisement): try: self.controller.on_link_advertising_data( - Address(sender), bytes.fromhex(advertisement) + Address(sender), bytes.fromhex(advertisement), b'' ) except Exception: logger.exception('exception') @@ -424,7 +438,7 @@ async def on_disconnect_message_received(self, sender, message): # Notify the controller params = parse_parameters(message) reason = int(params.get('reason', str(HCI_CONNECTION_TIMEOUT_ERROR))) - self.controller.on_link_central_disconnected(Address(sender), reason) + self.controller.on_link_peer_disconnected(Address(sender), reason) # Forget the connection if sender in self.peripheral_connections: @@ -471,7 +485,7 @@ def on_address_changed(self, controller): async def send_advertising_data_to_relay(self, data): await self.send_targeted_message('*', f'advertisement:{data.hex()}') - def send_advertising_data(self, _, data): + def send_advertising_data(self, _, data, scan_response): self.execute(partial(self.send_advertising_data_to_relay, data)) async def send_acl_data_to_relay(self, peer_address, data): diff --git a/tests/controller_test.py b/tests/controller_test.py new file mode 100644 index 00000000..19ca40dd --- /dev/null +++ b/tests/controller_test.py @@ -0,0 +1,138 @@ +# Copyright 2023 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 +# ----------------------------------------------------------------------------- +import asyncio +import logging +import os +import pytest +from typing import List, Optional +from unittest.mock import MagicMock + +from bumble.device import Connection, Device +from bumble.host import Host +from bumble.link import LocalLink +from bumble.controller import Controller +from bumble.hci import ( + Address, + HCI_CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR, + HCI_REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR, +) +from bumble.transport import AsyncPipeSink + + +# ----------------------------------------------------------------------------- +class TwoDevices: + connections: List[Optional[Connection]] + + def __init__(self) -> None: + self.connections = [None, None] + + self.link = LocalLink() + self.controllers = [ + Controller('C1', link=self.link), + Controller('C2', link=self.link), + ] + self.devices = [ + Device( + address=Address('F0:F1:F2:F3:F4:F5'), + host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), + ), + Device( + address=Address('F5:F4:F3:F2:F1:F0'), + host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), + ), + ] + + self.paired = [None, None] + + def on_connection(self, which, connection): + self.connections[which] = connection + connection.on( + 'disconnection', lambda reason: self.on_disconnection(which, reason) + ) + + def on_disconnection(self, which, _): + self.connections[which] = None + + async def setup(self): + self.devices[0].on( + 'connection', lambda connection: self.on_connection(0, connection) + ) + self.devices[1].on( + 'connection', lambda connection: self.on_connection(1, connection) + ) + + await self.devices[0].power_on() + await self.devices[1].power_on() + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_self_connection(): + two_devices = TwoDevices() + await two_devices.setup() + + await two_devices.devices[0].connect(two_devices.devices[1].random_address) + + assert two_devices.connections[0] is not None + assert two_devices.connections[1] is not None + + mock0 = MagicMock() + mock1 = MagicMock() + two_devices.connections[0].once('disconnection', mock0) + two_devices.connections[1].once('disconnection', mock1) + await two_devices.connections[0].disconnect( + HCI_REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR + ) + mock0.assert_called_once_with(HCI_CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR) + mock1.assert_called_once_with( + HCI_REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR + ) + + assert two_devices.connections[0] is None + assert two_devices.connections[1] is None + + await two_devices.devices[0].connect(two_devices.devices[1].random_address) + + assert two_devices.connections[0] is not None + assert two_devices.connections[1] is not None + + mock0 = MagicMock() + mock1 = MagicMock() + two_devices.connections[0].once('disconnection', mock0) + two_devices.connections[1].once('disconnection', mock1) + await two_devices.connections[1].disconnect( + HCI_REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR + ) + mock1.assert_called_once_with(HCI_CONNECTION_TERMINATED_BY_LOCAL_HOST_ERROR) + mock0.assert_called_once_with( + HCI_REMOTE_DEVICE_TERMINATED_CONNECTION_DUE_TO_LOW_RESOURCES_ERROR + ) + + assert two_devices.connections[0] is None + assert two_devices.connections[1] is None + + +# ----------------------------------------------------------------------------- +async def run_test_controller(): + await test_self_connection() + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + asyncio.run(run_test_controller())