From 65deefdc644796dbd31c58339e1bb478ef3ab565 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Wed, 2 Nov 2022 20:17:44 +0000 Subject: [PATCH 01/13] host: allow bytes return paramaters when checking command result --- bumble/host.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bumble/host.py b/bumble/host.py index 20a92bf..74b82c8 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -176,6 +176,9 @@ class Host(EventEmitter): if check_result: if type(response.return_parameters) is int: status = response.return_parameters + elif type(response.return_parameters) is bytes: + # return parameters first field is a one byte status code + status = response.return_parameters[0] else: status = response.return_parameters.status From 8119bc210cb9c87c9e506103bf30b14fae05b600 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 31 Oct 2022 18:20:48 +0000 Subject: [PATCH 02/13] host: pass `remote_host_supported_features` event to upper layer The `HCI_Remote_Name_Request` command may trigger this HCI event. Instead of warn for not being handled, pass it to upper layer. --- bumble/host.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bumble/host.py b/bumble/host.py index 74b82c8..01c25a4 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -648,3 +648,6 @@ class Host(EventEmitter): self.emit('remote_name_failure', event.bd_addr, event.status) else: self.emit('remote_name', event.bd_addr, event.remote_name) + + def on_hci_remote_host_supported_features_notification_event(self, event): + self.emit('remote_host_supported_features', event.bd_addr, event.host_supported_features) From fc331b7aea49fd4666e05765f905042875d8bef1 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Fri, 28 Oct 2022 19:13:52 +0000 Subject: [PATCH 03/13] core: improve `Advertisement.ad_data_to_object` with support for more data types --- bumble/core.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/bumble/core.py b/bumble/core.py index 3d4ef41..a74a17a 100644 --- a/bumble/core.py +++ b/bumble/core.py @@ -769,17 +769,20 @@ class AdvertisingData: def ad_data_to_object(ad_type, ad_data): if ad_type in { AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, - AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS }: return AdvertisingData.uuid_list_to_objects(ad_data, 2) elif ad_type in { AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, - AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS + AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS }: return AdvertisingData.uuid_list_to_objects(ad_data, 4) elif ad_type in { AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, - AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS + AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS }: return AdvertisingData.uuid_list_to_objects(ad_data, 16) elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID: @@ -790,11 +793,24 @@ class AdvertisingData: return (UUID.from_bytes(ad_data[:16]), ad_data[16:]) elif ad_type in { AdvertisingData.SHORTENED_LOCAL_NAME, - AdvertisingData.COMPLETE_LOCAL_NAME + AdvertisingData.COMPLETE_LOCAL_NAME, + AdvertisingData.URI }: return ad_data.decode("utf-8") - elif ad_type == AdvertisingData.TX_POWER_LEVEL: + elif ad_type in { + AdvertisingData.TX_POWER_LEVEL, + AdvertisingData.FLAGS + }: return ad_data[0] + elif ad_type in { + AdvertisingData.APPEARANCE, + AdvertisingData.ADVERTISING_INTERVAL + }: + return struct.unpack(' Date: Mon, 31 Oct 2022 20:49:41 +0000 Subject: [PATCH 04/13] core: change `AdvertisingData.get` default `raw` behavior to False --- apps/console.py | 4 ++-- bumble/core.py | 2 +- bumble/device.py | 4 ++-- tests/core_test.py | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/console.py b/apps/console.py index 744d7da..489fdfd 100644 --- a/apps/console.py +++ b/apps/console.py @@ -877,9 +877,9 @@ class ScanResult: else: type_color = colors.cyan - name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME) + name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) if name is None: - name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME) + name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True) if name: # Convert to string try: diff --git a/bumble/core.py b/bumble/core.py index a74a17a..302ee6a 100644 --- a/bumble/core.py +++ b/bumble/core.py @@ -827,7 +827,7 @@ class AdvertisingData: self.ad_structures.append((ad_type, ad_data)) offset += length - def get(self, type_id, return_all=False, raw=True): + def get(self, type_id, return_all=False, raw=False): ''' Get Advertising Data Structure(s) with a given type diff --git a/bumble/device.py b/bumble/device.py index dc48fc2..3e83153 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1402,9 +1402,9 @@ class Device(CompositeEventEmitter): # Scan/inquire with event handlers to handle scan/inquiry results def on_peer_found(address, ad_data): - local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME) + local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) if local_name is None: - local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME) + local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True) if local_name is not None: if local_name.decode('utf-8') == name: peer_address.set_result(address) diff --git a/tests/core_test.py b/tests/core_test.py index f4bdd83..fa397db 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -24,19 +24,19 @@ def test_ad_data(): ad = AdvertisingData.from_bytes(data) ad_bytes = bytes(ad) assert(data == ad_bytes) - assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None) - assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123])) - assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == []) - assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123])]) + assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None) + assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])) + assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == []) + assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123])]) data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234]) ad.append(data2) ad_bytes = bytes(ad) assert(ad_bytes == data + data2) - assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None) - assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123])) - assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == []) - assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123]), bytes([234])]) + assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None) + assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])) + assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == []) + assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123]), bytes([234])]) # ----------------------------------------------------------------------------- From 78534b659a8cb4a2e7ece27a81be171abed92155 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 31 Oct 2022 18:19:47 +0000 Subject: [PATCH 05/13] device: enhance `.request_remote_name` to also accept an `Address` as argument --- bumble/device.py | 74 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 3e83153..13217b7 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -589,6 +589,17 @@ def with_connection_from_address(function): return wrapper +# Decorator that tries to convert the first argument from a bluetooth address to a connection +def try_with_connection_from_address(function): + @functools.wraps(function) + def wrapper(self, address, *args, **kwargs): + for connection in self.connections.values(): + if connection.peer_address == address: + return function(self, connection, address, *args, **kwargs) + return function(self, None, address, *args, **kwargs) + return wrapper + + # Decorator that adds a method to the list of event handlers for host events. # This assumes that the method name starts with `on_` def host_event_handler(function): @@ -1586,23 +1597,31 @@ class Device(CompositeEventEmitter): connection.remove_listener('connection_encryption_failure', on_encryption_failure) # [Classic only] - async def request_remote_name(self, connection): + async def request_remote_name(self, remote: Connection | Address): # Set up event handlers pending_name = asyncio.get_running_loop().create_future() - def on_remote_name(): - pending_name.set_result(connection.peer_name) - - def on_remote_name_failure(error_code): - pending_name.set_exception(HCI_Error(error_code)) - - connection.on('remote_name', on_remote_name) - connection.on('remote_name_failure', on_remote_name_failure) + if type(remote) == Address: + peer_address = remote + handler = self.on('remote_name', + lambda address, remote_name: + pending_name.set_result(remote_name) if address == remote else None) + failure_handler = self.on('remote_name_failure', + lambda address, error_code: + pending_name.set_exception(HCI_Error(error_code)) if address == remote else None) + else: + peer_address = remote.peer_address + handler = remote.on('remote_name', + lambda: + pending_name.set_result(remote.peer_name)) + failure_handler = remote.on('remote_name_failure', + lambda error_code: + pending_name.set_exception(HCI_Error(error_code))) try: result = await self.send_command( HCI_Remote_Name_Request_Command( - bd_addr = connection.peer_address, + bd_addr = peer_address, page_scan_repetition_mode = HCI_Remote_Name_Request_Command.R0, # TODO investigate other options reserved = 0, clock_offset = 0 # TODO investigate non-0 values @@ -1616,8 +1635,12 @@ class Device(CompositeEventEmitter): # Wait for the result return await pending_name finally: - connection.remove_listener('remote_name', on_remote_name) - connection.remove_listener('remote_name_failure', on_remote_name_failure) + if type(remote) == Address: + self.remove_listener('remote_name', handler) + self.remove_listener('remote_name_failure', failure_handler) + else: + remote.remove_listener('remote_name', handler) + remote.remove_listener('remote_name_failure', failure_handler) # [Classic only] @host_event_handler @@ -1899,21 +1922,32 @@ class Device(CompositeEventEmitter): # [Classic only] @host_event_handler - @with_connection_from_address - def on_remote_name(self, connection, remote_name): + @try_with_connection_from_address + def on_remote_name(self, connection, address, remote_name): # Try to decode the name try: - connection.peer_name = remote_name.decode('utf-8') - connection.emit('remote_name') + remote_name = remote_name.decode('utf-8') + if connection: + connection.peer_name = remote_name + connection.emit('remote_name') + else: + self.emit('remote_name', address, remote_name) except UnicodeDecodeError as error: logger.warning('peer name is not valid UTF-8') - connection.emit('remote_name_failure', error) + if connection: + connection.emit('remote_name_failure', error) + else: + self.emit('remote_name_failure', address, error) + # [Classic only] @host_event_handler - @with_connection_from_address - def on_remote_name_failure(self, connection, error): - connection.emit('remote_name_failure', error) + @try_with_connection_from_address + def on_remote_name_failure(self, connection, address, error): + if connection: + connection.emit('remote_name_failure', error) + else: + self.emit('remote_name_failure', address, error) @host_event_handler @with_connection_from_handle From 51ddb36c91700f873bba9a2760e0cb70d1c7c469 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 31 Oct 2022 18:31:38 +0000 Subject: [PATCH 06/13] device: add `auto_restart` mechanism to `.start_discovery` (default to `True`) --- bumble/device.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 13217b7..d500efa 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -662,6 +662,7 @@ class Device(CompositeEventEmitter): self.powered_on = False self.advertising = False self.advertising_type = None + self.auto_restart_inquiry = True self.auto_restart_advertising = False self.command_timeout = 10 # seconds self.gatt_server = gatt_server.Server(self) @@ -1055,7 +1056,7 @@ class Device(CompositeEventEmitter): if advertisement := accumulator.update(report): self.emit('advertisement', advertisement) - async def start_discovery(self): + async def start_discovery(self, auto_restart=True): await self.send_command(HCI_Write_Inquiry_Mode_Command( inquiry_mode=HCI_EXTENDED_INQUIRY_MODE ), check_result=True) @@ -1069,11 +1070,14 @@ class Device(CompositeEventEmitter): self.discovering = False raise HCI_StatusError(response) - self.discovering = True + self.auto_restart_inquiry = auto_restart + self.discovering = True async def stop_discovery(self): - await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True) - self.discovering = False + if self.discovering: + await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True) + self.auto_restart_inquiry = True + self.discovering = False @host_event_handler def on_inquiry_result(self, address, class_of_device, data, rssi): @@ -1795,9 +1799,13 @@ class Device(CompositeEventEmitter): @host_event_handler @AsyncRunner.run_in_task() async def on_inquiry_complete(self): - if self.discovering: + if self.auto_restart_inquiry: # Inquire again - await self.start_discovery() + await self.start_discovery(auto_restart=True) + else: + self.auto_restart_inquiry = True + self.discovering = False + self.emit('inquiry_complete') @host_event_handler @with_connection_from_handle From b961affd3d1f4ba76c9de6bf4952ab0e9746bb27 Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Wed, 2 Nov 2022 20:16:02 +0000 Subject: [PATCH 07/13] device: update `Device.connect` documentation to match BR/EDR behavior --- bumble/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index d500efa..98d3ecf 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1142,7 +1142,7 @@ class Device(CompositeEventEmitter): ): ''' Request a connection to a peer. - This method cannot be called if there is already a pending connection. + When transport is BLE, this method cannot be called if there is already a pending connection. connection_parameters_preferences: (BLE only, ignored for BR/EDR) * None: use all PHYs with default parameters From e9e14f51833b1d58b95dc22e48b8786612ace66d Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Wed, 19 Oct 2022 17:25:05 +0000 Subject: [PATCH 08/13] le: make the device connecting state relative to LE only We may need to add a distinct BR/EDR connecting state in the future. --- apps/console.py | 4 ++-- bumble/device.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/console.py b/apps/console.py index 489fdfd..93bb6ae 100644 --- a/apps/console.py +++ b/apps/console.py @@ -311,7 +311,7 @@ class ConsoleApp: rssi = '' if self.connection_rssi is None else rssi_bar(self.connection_rssi) if self.device: - if self.device.is_connecting: + if self.device.is_le_connecting: connection_state = 'CONNECTING' elif self.connected_peer: connection = self.connected_peer.connection @@ -574,7 +574,7 @@ class ConsoleApp: self.show_error('connection timed out') async def do_disconnect(self, params): - if self.device.connecting: + if self.device.is_le_connecting: await self.device.cancel_connection() else: if not self.connected_peer: diff --git a/bumble/device.py b/bumble/device.py index 98d3ecf..047b053 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -674,7 +674,7 @@ class Device(CompositeEventEmitter): self.scanning = False self.scanning_is_passive = False self.discovering = False - self.connecting = False + self.le_connecting = False self.disconnecting = False self.connections = {} # Connections, by connection handle self.classic_enabled = False @@ -1160,7 +1160,7 @@ class Device(CompositeEventEmitter): transport = BT_LE_TRANSPORT # Check that there isn't already a pending connection - if transport == BT_LE_TRANSPORT and self.is_connecting: + if transport == BT_LE_TRANSPORT and self.is_le_connecting: raise InvalidStateError('connection already pending') if type(peer_address) is str: @@ -1277,7 +1277,7 @@ class Device(CompositeEventEmitter): # Wait for the connection process to complete if transport == BT_LE_TRANSPORT: - self.connecting = True + self.le_connecting = True if timeout is None: return await pending_connection else: @@ -1297,7 +1297,7 @@ class Device(CompositeEventEmitter): self.remove_listener('connection', on_connection) self.remove_listener('connection_failure', on_connection_failure) if transport == BT_LE_TRANSPORT: - self.connecting = False + self.le_connecting = False @asynccontextmanager async def connect_as_gatt(self, peer_address): @@ -1308,15 +1308,15 @@ class Device(CompositeEventEmitter): yield peer @property - def is_connecting(self): - return self.connecting + def is_le_connecting(self): + return self.le_connecting @property def is_disconnecting(self): return self.disconnecting async def cancel_connection(self): - if not self.is_connecting: + if not self.is_le_connecting: return await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True) From ca8f2848885e9158890a487fb3292ca20f60291d Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 24 Oct 2022 22:39:05 +0000 Subject: [PATCH 09/13] le: add `own_address_type` parameter to `Device.start_advertising` --- bumble/device.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index 047b053..6f8f8cb 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -887,6 +887,7 @@ class Device(CompositeEventEmitter): self, advertising_type=AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE, target=None, + own_address_type=Address.RANDOM_DEVICE_ADDRESS, auto_restart=False ): # If we're advertising, stop first @@ -921,7 +922,7 @@ class Device(CompositeEventEmitter): advertising_interval_min = self.advertising_interval_min, advertising_interval_max = self.advertising_interval_max, advertising_type = int(advertising_type), - own_address_type = Address.RANDOM_DEVICE_ADDRESS, # TODO: allow using the public address + own_address_type = own_address_type, peer_address_type = peer_address_type, peer_address = peer_address, advertising_channel_map = 7, From 7044102e05422fab64841e5f328ceecead0e9d4d Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Wed, 19 Oct 2022 17:35:51 +0000 Subject: [PATCH 10/13] classic: upgrade `Device.cancel_connection` logic to support canceling ongoing BR/EDR connections --- bumble/device.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index 6f8f8cb..f68e330 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1316,10 +1316,25 @@ class Device(CompositeEventEmitter): def is_disconnecting(self): return self.disconnecting - async def cancel_connection(self): - if not self.is_le_connecting: - return - await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True) + async def cancel_connection(self, peer_address=None): + # Low-energy: cancel ongoing connection + if peer_address is None: + if not self.is_le_connecting: + return + await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True) + + # BR/EDR: try to cancel to ongoing connection + # NOTE: This API does not prevent from trying to cancel a connection which is not currently being created + else: + if type(peer_address) is str: + try: + peer_address = Address(peer_address) + except ValueError: + # If the address is not parsable, assume it is a name instead + logger.debug('looking for peer by name') + peer_address = await self.find_peer_by_name(peer_address, BT_BR_EDR_TRANSPORT) # TODO: timeout + + await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address), check_result=True) async def disconnect(self, connection, reason): # Create a future so that we can wait for the disconnection's result From 56ed46adfa1003fff0bfc7cd9aeb25fec9fcce5d Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Wed, 19 Oct 2022 19:00:03 +0000 Subject: [PATCH 11/13] classic: add BR/EDR accept connection logic --- bumble/device.py | 131 +++++++++++++++++++++++++++++++++++++++++++ bumble/hci.py | 21 +++++++ bumble/host.py | 13 ++--- tests/device_test.py | 17 ++++-- 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/bumble/device.py b/bumble/device.py index f68e330..13097a1 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -519,6 +519,7 @@ class DeviceConfiguration: self.le_simultaneous_enabled = True self.classic_sc_enabled = True self.classic_ssp_enabled = True + self.classic_accept_any = True self.connectable = True self.discoverable = True self.advertising_data = bytes( @@ -539,6 +540,7 @@ class DeviceConfiguration: self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled) self.classic_sc_enabled = config.get('classic_sc_enabled', self.classic_sc_enabled) self.classic_ssp_enabled = config.get('classic_ssp_enabled', self.classic_ssp_enabled) + self.classic_accept_any = config.get('classic_accept_any', self.classic_accept_any) self.connectable = config.get('connectable', self.connectable) self.discoverable = config.get('discoverable', self.discoverable) @@ -630,6 +632,9 @@ class Device(CompositeEventEmitter): def on_connection_failure(self, error): pass + def on_connection_request(self, bd_addr, class_of_device, link_type): + pass + def on_characteristic_subscription(self, connection, characteristic, notify_enabled, indicate_enabled): pass @@ -680,6 +685,7 @@ class Device(CompositeEventEmitter): self.classic_enabled = False self.inquiry_response = None self.address_resolver = None + self.classic_pending_accepts = { Address.ANY: [] } # Futures, by BD address OR [Futures] for Address.ANY # Use the initial config or a default self.public_address = Address('00:00:00:00:00:00') @@ -700,6 +706,7 @@ class Device(CompositeEventEmitter): self.classic_sc_enabled = config.classic_sc_enabled self.discoverable = config.discoverable self.connectable = config.connectable + self.classic_accept_any = config.classic_accept_any # If a name is passed, override the name from the config if name: @@ -1300,6 +1307,89 @@ class Device(CompositeEventEmitter): if transport == BT_LE_TRANSPORT: self.le_connecting = False + async def accept( + self, + peer_address=Address.ANY, + role=BT_PERIPHERAL_ROLE, + timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT + ): + ''' + Wait and accept any incoming connection or a connection from `peer_address` when set. + + Notes: + * A `connect` to the same peer will also complete this call. + * The `timeout` parameter is only handled while waiting for the connection request, + once received and accepeted, the controller shall issue a connection complete event. + ''' + + if type(peer_address) is str: + try: + peer_address = Address(peer_address) + except ValueError: + # If the address is not parsable, assume it is a name instead + logger.debug('looking for peer by name') + peer_address = await self.find_peer_by_name(peer_address, BT_BR_EDR_TRANSPORT) # TODO: timeout + + if peer_address == Address.NIL: + raise ValueError('accept on nil address') + + # Create a future so that we can wait for the request + pending_request = asyncio.get_running_loop().create_future() + + if peer_address == Address.ANY: + self.classic_pending_accepts[Address.ANY].append(pending_request) + elif peer_address in self.classic_pending_accepts: + raise InvalidStateError('accept connection already pending') + else: + self.classic_pending_accepts[peer_address] = pending_request + + try: + # Wait for a request or a completed connection + result = await (asyncio.wait_for(pending_request, timeout) if timeout else pending_request) + + except: + # Remove future from device context + if peer_address == Address.ANY: + self.classic_pending_accepts[Address.ANY].remove(pending_request) + else: + self.classic_pending_accepts.pop(peer_address) + raise + + # Result may already be a completed connection, + # see `on_connection` for details + if isinstance(result, Connection): + return result + + # Otherwise, result came from `on_connection_request` + peer_address, class_of_device, link_type = result + + def on_connection(connection): + if connection.transport == BT_BR_EDR_TRANSPORT and connection.peer_address == peer_address: + pending_connection.set_result(connection) + + def on_connection_failure(error): + if error.transport == BT_BR_EDR_TRANSPORT and error.peer_address == peer_address: + pending_connection.set_exception(error) + + # Create a future so that we can wait for the connection's result + pending_connection = asyncio.get_running_loop().create_future() + self.on('connection', on_connection) + self.on('connection_failure', on_connection_failure) + + try: + # Accept connection request + await self.send_command(HCI_Accept_Connection_Request_Command( + bd_addr = peer_address, + role = role + )) + + # Wait for connection complete + return await pending_connection + + finally: + self.remove_listener('connection', on_connection) + self.remove_listener('connection_failure', on_connection_failure) + @asynccontextmanager async def connect_as_gatt(self, peer_address): async with AsyncExitStack() as stack: @@ -1716,6 +1806,14 @@ class Device(CompositeEventEmitter): ) self.connections[connection_handle] = connection + # We may have an accept ongoing waiting for a connection request for `peer_address`. + # Typicaly happen when using `connect` to the same `peer_address` we are waiting with + # an `accept` for. + # In this case, set the completed `connection` to the `accept` future result. + if peer_address in self.classic_pending_accepts: + future = self.classic_pending_accepts.pop(peer_address) + future.set_result(connection) + # Emit an event to notify listeners of the new connection self.emit('connection', connection) else: @@ -1779,6 +1877,39 @@ class Device(CompositeEventEmitter): ) self.emit('connection_failure', error) + # FIXME: Explore a delegate-model for BR/EDR wait connection #56. + @host_event_handler + def on_connection_request(self, bd_addr, class_of_device, link_type): + logger.debug(f'*** Connection request: {bd_addr}') + + # match a pending future using `bd_addr` + if bd_addr in self.classic_pending_accepts: + future = self.classic_pending_accepts.pop(bd_addr) + future.set_result((bd_addr, class_of_device, link_type)) + + # match first pending future for ANY address + elif len(self.classic_pending_accepts[Address.ANY]) > 0: + future = self.classic_pending_accepts[Address.ANY].pop(0) + future.set_result((bd_addr, class_of_device, link_type)) + + # device configuration is set to accept any incoming connection + elif self.classic_accept_any: + self.host.send_command_sync( + HCI_Accept_Connection_Request_Command( + bd_addr = bd_addr, + role = 0x01 # Remain the peripheral + ) + ) + + # reject incoming connection + else: + self.host.send_command_sync( + HCI_Reject_Connection_Request_Command( + bd_addr = bd_addr, + reason = HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR + ) + ) + @host_event_handler @with_connection_from_handle def on_disconnection(self, connection, reason): diff --git a/bumble/hci.py b/bumble/hci.py index af26374..d4cf7cc 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -1652,6 +1652,16 @@ class Address: ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)} + @classmethod + @property + def ANY(cls): + return cls(b"\xff\xff\xff\xff\xff\xff", cls.PUBLIC_DEVICE_ADDRESS) + + @classmethod + @property + def NIL(cls): + return cls(b"\x00\x00\x00\x00\x00\x00", cls.PUBLIC_DEVICE_ADDRESS) + @staticmethod def address_type_name(address_type): return name_or_number(Address.ADDRESS_TYPE_NAMES, address_type) @@ -1935,6 +1945,17 @@ class HCI_Accept_Connection_Request_Command(HCI_Command): ''' +# ----------------------------------------------------------------------------- +@HCI_Command.command([ + ('bd_addr', Address.parse_address), + ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}) +]) +class HCI_Reject_Connection_Request_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.1.9 Reject Connection Request Command + ''' + + # ----------------------------------------------------------------------------- @HCI_Command.command([ ('bd_addr', Address.parse_address), diff --git a/bumble/host.py b/bumble/host.py index 01c25a4..32b2194 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -347,13 +347,12 @@ class Host(EventEmitter): # Classic only def on_hci_connection_request_event(self, event): - # For now, just accept everything - # TODO: delegate the decision - self.send_command_sync( - HCI_Accept_Connection_Request_Command( - bd_addr = event.bd_addr, - role = 0x01 # Remain the peripheral - ) + # Notify the listeners + self.emit( + 'connection_request', + event.bd_addr, + event.class_of_device, + event.link_type, ) def on_hci_le_connection_complete_event(self, event): diff --git a/tests/device_test.py b/tests/device_test.py index cd72c4c..acf4446 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -158,16 +158,23 @@ async def test_device_connect_parallel(): d1.host.set_packet_sink(Sink(d1_flow())) d2.host.set_packet_sink(Sink(d2_flow())) - [c1, c2] = await asyncio.gather(*[ + [c01, c02, a10, a20, a01] = await asyncio.gather(*[ asyncio.create_task(d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT)), asyncio.create_task(d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)), + asyncio.create_task(d1.accept(peer_address=d0.public_address)), + asyncio.create_task(d2.accept()), + asyncio.create_task(d0.accept(peer_address=d1.public_address)), ]) - assert type(c1) == Connection - assert type(c2) == Connection + assert type(c01) == Connection + assert type(c02) == Connection + assert type(a10) == Connection + assert type(a20) == Connection + assert type(a01) == Connection - assert c1.handle == 0x100 - assert c2.handle == 0x101 + assert c01.handle == a10.handle and c01.handle == 0x100 + assert c02.handle == a20.handle and c02.handle == 0x101 + assert a01 == c01 # ----------------------------------------------------------------------------- From b95888eb3905b60e1b3fc679adb81e3b0065f7bc Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 7 Nov 2022 22:15:54 +0000 Subject: [PATCH 12/13] le: permit legacy scanning even when extended is supported --- bumble/device.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index 13097a1..7494eb2 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -962,6 +962,7 @@ class Device(CompositeEventEmitter): async def start_scanning( self, + legacy=False, active=True, scan_interval=DEVICE_DEFAULT_SCAN_INTERVAL, # Scan interval in ms scan_window=DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms @@ -981,7 +982,7 @@ class Device(CompositeEventEmitter): self.advertisement_accumulator = {} # Enable scanning - if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE): + if not legacy and self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE): # Set the scanning parameters scan_type = HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Extended_Scan_Parameters_Command.PASSIVE_SCANNING scanning_filter_policy = HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY # TODO: support other types From ca16410a6d8bb557035a9944438c8287d0cc57ff Mon Sep 17 00:00:00 2001 From: Abel Lucas Date: Mon, 7 Nov 2022 22:17:01 +0000 Subject: [PATCH 13/13] device: add option to check for the address type when using `find_connection_by_bd_addr` --- bumble/device.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index 7494eb2..b12fad5 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -777,9 +777,11 @@ class Device(CompositeEventEmitter): if connection := self.connections.get(connection_handle): return connection - def find_connection_by_bd_addr(self, bd_addr, transport=None): + def find_connection_by_bd_addr(self, bd_addr, transport=None, check_address_type=False): for connection in self.connections.values(): if connection.peer_address.get_bytes() == bd_addr.get_bytes(): + if check_address_type and connection.peer_address.address_type != bd_addr.address_type: + continue if transport is None or connection.transport == transport: return connection