From d10dda7e107cb45816e777e9acac8a9746b83f08 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 3 Oct 2022 16:50:42 -0700 Subject: [PATCH] wip --- apps/console.py | 263 ++++++-- apps/scan.py | 58 +- bumble/controller.py | 36 +- bumble/core.py | 10 +- bumble/device.py | 569 ++++++++++++------ bumble/hci.py | 262 +++++--- bumble/host.py | 81 ++- bumble/l2cap.py | 26 +- .../mkdocs/src/transports/android_emulator.md | 5 +- tests/hci_test.py | 61 +- 10 files changed, 936 insertions(+), 435 deletions(-) diff --git a/apps/console.py b/apps/console.py index 48a94819..c72d0226 100644 --- a/apps/console.py +++ b/apps/console.py @@ -28,11 +28,16 @@ import click from collections import OrderedDict import colors -from bumble.core import UUID, AdvertisingData -from bumble.device import Device, Connection, Peer +from bumble.core import UUID, AdvertisingData, TimeoutError, BT_LE_TRANSPORT +from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer from bumble.utils import AsyncRunner from bumble.transport import open_transport_or_link from bumble.gatt import Characteristic +from bumble.hci import ( + HCI_LE_1M_PHY, + HCI_LE_2M_PHY, + HCI_LE_CODED_PHY, +) from prompt_toolkit import Application from prompt_toolkit.history import FileHistory @@ -43,6 +48,7 @@ from prompt_toolkit.styles import Style from prompt_toolkit.filters import Condition from prompt_toolkit.widgets import TextArea, Frame from prompt_toolkit.widgets.toolbars import FormattedTextToolbar +from prompt_toolkit.data_structures import Point from prompt_toolkit.layout import ( Layout, HSplit, @@ -51,17 +57,20 @@ from prompt_toolkit.layout import ( Float, FormattedTextControl, FloatContainer, - ConditionalContainer + ConditionalContainer, + Dimension ) # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- -BUMBLE_USER_DIR = os.path.expanduser('~/.bumble') -DEFAULT_PROMPT_HEIGHT = 20 -DEFAULT_RSSI_BAR_WIDTH = 20 -DISPLAY_MIN_RSSI = -100 -DISPLAY_MAX_RSSI = -30 +BUMBLE_USER_DIR = os.path.expanduser('~/.bumble') +DEFAULT_RSSI_BAR_WIDTH = 20 +DEFAULT_CONNECTION_TIMEOUT = 30.0 +DISPLAY_MIN_RSSI = -100 +DISPLAY_MAX_RSSI = -30 +RSSI_MONITOR_INTERVAL = 5.0 # Seconds + # ----------------------------------------------------------------------------- # Globals @@ -69,16 +78,57 @@ DISPLAY_MAX_RSSI = -30 App = None +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- + +def le_phy_name(phy_id): + return { + HCI_LE_1M_PHY: '1M', + HCI_LE_2M_PHY: '2M', + HCI_LE_CODED_PHY: 'CODED' + }.get(phy_id, HCI_Constant.le_phy_name(phy_id)) + + +def rssi_bar(rssi): + blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] + bar_width = (rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI) + bar_width = min(max(bar_width, 0), 1) + bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8) + bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8] + return f'{rssi:4} {bar_blocks}' + + +def parse_phys(phys): + if phys.lower() == '*': + return None + else: + phy_list = [] + elements = phys.lower().split(',') + for element in elements: + if element == '1m': + phy_list.append(HCI_LE_1M_PHY) + elif element == '2m': + phy_list.append(HCI_LE_2M_PHY) + elif element == 'coded': + phy_list.append(HCI_LE_CODED_PHY) + else: + raise ValueError('invalid PHY name') + return phy_list + + # ----------------------------------------------------------------------------- # Console App # ----------------------------------------------------------------------------- class ConsoleApp: def __init__(self): - self.known_addresses = set() + self.known_addresses = set() self.known_attributes = [] - self.device = None - self.connected_peer = None - self.top_tab = 'scan' + self.device = None + self.connected_peer = None + self.top_tab = 'scan' + self.monitor_rssi = False + self.connection_rssi = None style = Style.from_dict({ 'output-field': 'bg:#000044 #ffffff', @@ -106,6 +156,10 @@ class ConsoleApp: 'on': None, 'off': None }, + 'rssi': { + 'on': None, + 'off': None + }, 'show': { 'scan': None, 'services': None, @@ -120,10 +174,17 @@ class ConsoleApp: 'services': None, 'attributes': None }, + 'request-mtu': None, 'read': LiveCompleter(self.known_attributes), 'write': LiveCompleter(self.known_attributes), 'subscribe': LiveCompleter(self.known_attributes), 'unsubscribe': LiveCompleter(self.known_attributes), + 'set-phy': { + '1m': None, + '2m': None, + 'coded': None + }, + 'set-default-phy': None, 'quit': None, 'exit': None }) @@ -139,14 +200,16 @@ class ConsoleApp: self.input_field.accept_handler = self.accept_input - self.output_height = 7 + self.output_height = Dimension(min=7, max=7, weight=1) self.output_lines = [] - self.output = FormattedTextControl() + self.output = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.output_lines) - 1))) + self.output_max_lines = 20 self.scan_results_text = FormattedTextControl() self.services_text = FormattedTextControl() self.attributes_text = FormattedTextControl() - self.log_text = FormattedTextControl() - self.log_height = 20 + self.log_text = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1))) + self.log_height = Dimension(min=7, weight=4) + self.log_max_lines = 100 self.log_lines = [] container = HSplit([ @@ -163,11 +226,10 @@ class ConsoleApp: filter=Condition(lambda: self.top_tab == 'attributes') ), ConditionalContainer( - Frame(Window(self.log_text), title='Log'), + Frame(Window(self.log_text, height=self.log_height), title='Log'), filter=Condition(lambda: self.top_tab == 'log') ), - Frame(Window(self.output), height=self.output_height), - # HorizontalLine(), + Frame(Window(self.output, height=self.output_height)), FormattedTextToolbar(text=self.get_status_bar_text, style='reverse'), self.input_field ]) @@ -199,6 +261,8 @@ class ConsoleApp: ) async def run_async(self, device_config, transport): + rssi_monitoring_task = asyncio.create_task(self.rssi_monitor_loop()) + async with await open_transport_or_link(transport) as (hci_source, hci_sink): if device_config: self.device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink) @@ -210,6 +274,8 @@ class ConsoleApp: # Run the UI await self.ui.run_async() + rssi_monitoring_task.cancel() + def add_known_address(self, address): self.known_addresses.add(address) @@ -224,22 +290,33 @@ class ConsoleApp: connection_state = 'NONE' encryption_state = '' + att_mtu = '' + rssi = '' if self.connection_rssi is None else rssi_bar(self.connection_rssi) if self.device: if self.device.is_connecting: connection_state = 'CONNECTING' elif self.connected_peer: connection = self.connected_peer.connection - connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.connection_latency}/{connection.parameters.supervision_timeout}' - connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}' + connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.peripheral_latency}/{connection.parameters.supervision_timeout}' + if connection.transport == BT_LE_TRANSPORT: + phy_state = f' RX={le_phy_name(connection.phy.rx_phy)}/TX={le_phy_name(connection.phy.tx_phy)}' + else: + phy_state = '' + connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}{phy_state}' encryption_state = 'ENCRYPTED' if connection.is_encrypted else 'NOT ENCRYPTED' + att_mtu = f'ATT_MTU: {connection.att_mtu}' return [ ('ansigreen', f' SCAN: {scanning} '), ('', ' '), ('ansiblue', f' CONNECTION: {connection_state} '), ('', ' '), - ('ansimagenta', f' {encryption_state} ') + ('ansimagenta', f' {encryption_state} '), + ('', ' '), + ('ansicyan', f' {att_mtu} '), + ('', ' '), + ('ansiyellow', f' {rssi} ') ] def show_error(self, title, details = None): @@ -286,7 +363,7 @@ class ConsoleApp: def append_to_output(self, line, invalidate=True): if type(line) is str: line = [('', line)] - self.output_lines = self.output_lines[-(self.output_height - 3):] + self.output_lines = self.output_lines[-self.output_max_lines:] self.output_lines.append(line) formatted_text = [] for line in self.output_lines: @@ -298,7 +375,7 @@ class ConsoleApp: def append_to_log(self, lines, invalidate=True): self.log_lines.extend(lines.split('\n')) - self.log_lines = self.log_lines[-(self.log_height - 3):] + self.log_lines = self.log_lines[-self.log_max_lines:] self.log_text.text = ANSI('\n'.join(self.log_lines)) if invalidate: self.ui.invalidate() @@ -351,6 +428,12 @@ class ConsoleApp: if characteristic.handle == attribute_handle: return characteristic + async def rssi_monitor_loop(self): + while True: + if self.monitor_rssi and self.connected_peer: + self.connection_rssi = await self.connected_peer.connection.get_rssi() + await asyncio.sleep(RSSI_MONITOR_INTERVAL) + async def command(self, command): try: (keyword, *params) = command.strip().split(' ') @@ -379,39 +462,73 @@ class ConsoleApp: else: self.show_error('unsupported arguments for scan command') + async def do_rssi(self, params): + if len(params) == 0: + # Toggle monitoring + self.monitor_rssi = not self.monitor_rssi + elif params[0] == 'on': + self.monitor_rssi = True + elif params[0] == 'off': + self.monitor_rssi = False + else: + self.show_error('unsupported arguments for rssi command') + async def do_connect(self, params): - if len(params) != 1: - self.show_error('invalid syntax', 'expected connect
') + if len(params) != 1 and len(params) != 2: + self.show_error('invalid syntax', 'expected connect
[phys]') return + if len(params) == 1: + phys = None + else: + phys = parse_phys(params[1]) + if phys is None: + connection_parameters_preferences = None + else: + connection_parameters_preferences = { + phy: ConnectionParametersPreferences() + for phy in phys + } + self.append_to_output('connecting...') - await self.device.connect(params[0]) - self.top_tab = 'services' + + try: + await self.device.connect( + params[0], + connection_parameters_preferences=connection_parameters_preferences, + timeout=DEFAULT_CONNECTION_TIMEOUT + ) + self.top_tab = 'services' + except TimeoutError: + self.show_error('connection timed out') async def do_disconnect(self, params): - if not self.connected_peer: - self.show_error('not connected') - return + if self.device.connecting: + await self.device.cancel_connection() + else: + if not self.connected_peer: + self.show_error('not connected') + return - await self.connected_peer.connection.disconnect() + await self.connected_peer.connection.disconnect() async def do_update_parameters(self, params): if len(params) != 1 or len(params[0].split('/')) != 3: - self.show_error('invalid syntax', 'expected update-parameters -//') + self.show_error('invalid syntax', 'expected update-parameters -//') return if not self.connected_peer: self.show_error('not connected') return - connection_intervals, connection_latency, supervision_timeout = params[0].split('/') + connection_intervals, max_latency, supervision_timeout = params[0].split('/') connection_interval_min, connection_interval_max = [int(x) for x in connection_intervals.split('-')] - connection_latency = int(connection_latency) + max_latency = int(max_latency) supervision_timeout = int(supervision_timeout) await self.connected_peer.connection.update_parameters( connection_interval_min, connection_interval_max, - connection_latency, + max_latency, supervision_timeout ) @@ -442,6 +559,25 @@ class ConsoleApp: self.top_tab = params[0] self.ui.invalidate() + async def do_get_phy(self, params): + if not self.connected_peer: + self.show_error('not connected') + return + + phy = await self.connected_peer.connection.get_phy() + self.append_to_output(f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, TX={HCI_Constant.le_phy_name(phy[1])}') + + async def do_request_mtu(self, params): + if len(params) != 1: + self.show_error('invalid syntax', 'expected request-mtu ') + return + + if not self.connected_peer: + self.show_error('not connected') + return + + await self.connected_peer.request_mtu(int(params[0])) + async def do_discover(self, params): if not params: self.show_error('invalid syntax', 'expected discover services|attributes') @@ -454,14 +590,14 @@ class ConsoleApp: await self.discover_attributes() async def do_read(self, params): - if not self.connected_peer: - self.show_error('not connected') - return - if len(params) != 1: self.show_error('invalid syntax', 'expected read ') return + if not self.connected_peer: + self.show_error('not connected') + return + characteristic = self.find_characteristic(params[0]) if characteristic is None: self.show_error('no such characteristic') @@ -530,6 +666,42 @@ class ConsoleApp: await characteristic.unsubscribe() + async def do_set_phy(self, params): + if len(params) != 1: + self.show_error('invalid syntax', 'expected set-phy |/') + return + + if not self.connected_peer: + self.show_error('not connected') + return + + if '/' in params[0]: + tx_phys, rx_phys = params[0].split('/') + else: + tx_phys = params[0] + rx_phys = tx_phys + + await self.connected_peer.connection.set_phy( + tx_phys=parse_phys(tx_phys), + rx_phys=parse_phys(rx_phys) + ) + + async def do_set_default_phy(self, params): + if len(params) != 1: + self.show_error('invalid syntax', 'expected set-default-phy |/') + return + + if '/' in params[0]: + tx_phys, rx_phys = params[0].split('/') + else: + tx_phys = params[0] + rx_phys = tx_phys + + await self.device.set_default_phy( + tx_phys=parse_phys(tx_phys), + rx_phys=parse_phys(rx_phys) + ) + async def do_exit(self, params): self.ui.exit() @@ -548,12 +720,14 @@ class DeviceListener(Device.Listener, Connection.Listener): @AsyncRunner.run_in_task() async def on_connection(self, connection): self.app.connected_peer = Peer(connection) + self.app.connection_rssi = None self.app.append_to_output(f'connected to {self.app.connected_peer}') connection.listener = self def on_disconnection(self, reason): self.app.append_to_output(f'disconnected from {self.app.connected_peer}, reason: {HCI_Constant.error_name(reason)}') self.app.connected_peer = None + self.app.connection_rssi = None def on_connection_parameters_update(self): self.app.append_to_output(f'connection parameters update: {self.app.connected_peer.connection.parameters}') @@ -576,7 +750,7 @@ class DeviceListener(Device.Listener, Connection.Listener): if entry: entry.ad_data = advertisement.data entry.rssi = advertisement.rssi - entry.connectable = advertisement.connectable + entry.connectable = advertisement.is_connectable else: self.app.add_known_address(str(advertisement.address)) self.scan_results[entry_key] = ScanResult(advertisement.address, advertisement.address.address_type, advertisement.data, advertisement.rssi, advertisement.is_connectable) @@ -616,12 +790,7 @@ class ScanResult: name = '' # RSSI bar - blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] - bar_width = (self.rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI) - bar_width = min(max(bar_width, 0), 1) - bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8) - bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8] - bar_string = f'{self.rssi} {bar_blocks}' + bar_string = rssi_bar(self.rssi) bar_padding = ' ' * (DEFAULT_RSSI_BAR_WIDTH + 5 - len(bar_string)) return f'{address_color(str(self.address))} [{type_color(address_type_string)}] {bar_string} {bar_padding} {name}' diff --git a/apps/scan.py b/apps/scan.py index b7e2715c..d6c10923 100644 --- a/apps/scan.py +++ b/apps/scan.py @@ -25,8 +25,8 @@ from bumble.device import Device from bumble.transport import open_transport_or_link from bumble.keys import JsonKeyStore from bumble.smp import AddressResolver -from bumble.hci import HCI_LE_Advertising_Report_Event -from bumble.core import AdvertisingData +from bumble.device import Advertisement +from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY # ----------------------------------------------------------------------------- @@ -48,16 +48,19 @@ class AdvertisementPrinter: self.min_rssi = min_rssi self.resolver = resolver - def print_advertisement(self, address, address_color, ad_data, rssi): - if self.min_rssi is not None and rssi < self.min_rssi: + def print_advertisement(self, advertisement): + address = advertisement.address + address_color = 'yellow' if advertisement.is_connectable else 'red' + + if self.min_rssi is not None and advertisement.rssi < self.min_rssi: return address_qualifier = '' resolution_qualifier = '' - if self.resolver and address.is_resolvable: - resolved = self.resolver.resolve(address) + if self.resolver and advertisement.address.is_resolvable: + resolved = self.resolver.resolve(advertisement.address) if resolved is not None: - resolution_qualifier = f'(resolved from {address})' + resolution_qualifier = f'(resolved from {advertisement.address})' address = resolved address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type] @@ -74,18 +77,30 @@ class AdvertisementPrinter: type_color = 'blue' address_qualifier = '(non-resolvable)' - rssi_bar = make_rssi_bar(rssi) separator = '\n ' - print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}RSSI:{rssi:4} {rssi_bar}{separator}{ad_data.to_string(separator)}\n') + rssi_bar = make_rssi_bar(advertisement.rssi) + if not advertisement.is_legacy: + phy_info = ( + f'PHY: {HCI_Constant.le_phy_name(advertisement.primary_phy)}/' + f'{HCI_Constant.le_phy_name(advertisement.secondary_phy)} ' + f'{separator}' + ) + else: + phy_info = '' + + print( + f'>>> {color(address, address_color)} ' + f'[{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}' + f'{phy_info}' + f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}' + f'{advertisement.data.to_string(separator)}\n') def on_advertisement(self, advertisement): - address_color = 'yellow' if advertisement.is_connectable else 'red' - self.print_advertisement(advertisement.address, address_color, advertisement.data, advertisement.rssi) + self.print_advertisement(advertisement) def on_advertising_report(self, report): - print(f'{color("EVENT", "green")}: {HCI_LE_Advertising_Report_Event.event_type_name(report.event_type)}') - data = AdvertisingData.from_bytes(report.data) - self.print_advertisement(report.address, 'yellow', data, report.rssi) + print(f'{color("EVENT", "green")}: {report.event_type_string()}') + self.print_advertisement(Advertisement.from_advertising_report(report)) # ----------------------------------------------------------------------------- @@ -94,6 +109,7 @@ async def scan( passive, scan_interval, scan_window, + phy, filter_duplicates, raw, keystore_file, @@ -126,11 +142,18 @@ async def scan( device.on('advertisement', printer.on_advertisement) await device.power_on() + + if phy is None: + scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY] + else: + scanning_phys = [{'1m': HCI_LE_1M_PHY, 'coded': HCI_LE_CODED_PHY}[phy]] + await device.start_scanning( active=(not passive), scan_interval=scan_interval, scan_window=scan_window, - filter_duplicates=filter_duplicates + filter_duplicates=filter_duplicates, + scanning_phys=scanning_phys ) await hci_source.wait_for_termination() @@ -142,14 +165,15 @@ async def scan( @click.option('--passive', is_flag=True, default=False, help='Perform passive scanning') @click.option('--scan-interval', type=int, default=60, help='Scan interval') @click.option('--scan-window', type=int, default=60, help='Scan window') +@click.option('--phy', type=click.Choice(['1m', 'coded']), help='Only scan on the specified PHY') @click.option('--filter-duplicates', type=bool, default=True, help='Filter duplicates at the controller level') @click.option('--raw', is_flag=True, default=False, help='Listen for raw advertising reports instead of processed ones') @click.option('--keystore-file', help='Keystore file to use when resolving addresses') @click.option('--device-config', help='Device config file for the scanning device') @click.argument('transport') -def main(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport): +def main(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport): logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper()) - asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport)) + asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport)) # ----------------------------------------------------------------------------- diff --git a/bumble/controller.py b/bumble/controller.py index 9ca9b59c..dc21f881 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -259,15 +259,15 @@ class Controller: # Then say that the connection has completed self.send_hci_packet(HCI_LE_Connection_Complete_Event( - status = HCI_SUCCESS, - connection_handle = connection.handle, - role = connection.role, - peer_address_type = peer_address_type, - peer_address = peer_address, - conn_interval = 10, # FIXME - conn_latency = 0, # FIXME - supervision_timeout = 10, # FIXME - master_clock_accuracy = 7 # FIXME + status = HCI_SUCCESS, + connection_handle = connection.handle, + role = connection.role, + peer_address_type = peer_address_type, + peer_address = peer_address, + connection_interval = 10, # FIXME + peripheral_latency = 0, # FIXME + supervision_timeout = 10, # FIXME + central_clock_accuracy = 7 # FIXME )) def on_link_central_disconnected(self, peer_address, reason): @@ -313,15 +313,15 @@ class Controller: # Say that the connection has completed self.send_hci_packet(HCI_LE_Connection_Complete_Event( - status = status, - connection_handle = connection.handle if connection else 0, - role = BT_CENTRAL_ROLE, - peer_address_type = le_create_connection_command.peer_address_type, - peer_address = le_create_connection_command.peer_address, - conn_interval = le_create_connection_command.conn_interval_min, - conn_latency = le_create_connection_command.conn_latency, - supervision_timeout = le_create_connection_command.supervision_timeout, - master_clock_accuracy = 0 + status = status, + connection_handle = connection.handle if connection else 0, + role = BT_CENTRAL_ROLE, + peer_address_type = le_create_connection_command.peer_address_type, + peer_address = le_create_connection_command.peer_address, + connection_interval = le_create_connection_command.connection_interval_min, + peripheral_latency = le_create_connection_command.max_latency, + supervision_timeout = le_create_connection_command.supervision_timeout, + central_clock_accuracy = 0 )) def on_link_peripheral_disconnection_complete(self, disconnection_command, status): diff --git a/bumble/core.py b/bumble/core.py index 746a6010..f024c12d 100644 --- a/bumble/core.py +++ b/bumble/core.py @@ -831,13 +831,17 @@ class AdvertisingData: # Connection Parameters # ----------------------------------------------------------------------------- class ConnectionParameters: - def __init__(self, connection_interval, connection_latency, supervision_timeout): + def __init__(self, connection_interval, peripheral_latency, supervision_timeout): self.connection_interval = connection_interval - self.connection_latency = connection_latency + self.peripheral_latency = peripheral_latency self.supervision_timeout = supervision_timeout def __str__(self): - return f'ConnectionParameters(connection_interval={self.connection_interval}, connection_latency={self.connection_latency}, supervision_timeout={self.supervision_timeout}' + return ( + f'ConnectionParameters(connection_interval={self.connection_interval}, ' + f'peripheral_latency={self.peripheral_latency}, ' + f'supervision_timeout={self.supervision_timeout}' + ) # ----------------------------------------------------------------------------- diff --git a/bumble/device.py b/bumble/device.py index c0e0c1e0..5facc26f 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -19,6 +19,7 @@ import json import asyncio import logging from contextlib import asynccontextmanager, AsyncExitStack +from dataclasses import dataclass from .hci import * from .host import Host @@ -41,20 +42,30 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- -DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00' -DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms -DEVICE_DEFAULT_ADVERTISING_DATA = '' -DEVICE_DEFAULT_NAME = 'Bumble' -DEVICE_DEFAULT_INQUIRY_LENGTH = 8 # 10.24 seconds -DEVICE_DEFAULT_CLASS_OF_DEVICE = 0 -DEVICE_DEFAULT_SCAN_RESPONSE_DATA = b'' -DEVICE_DEFAULT_DATA_LENGTH = (27, 328, 27, 328) -DEVICE_DEFAULT_SCAN_INTERVAL = 60 # ms -DEVICE_DEFAULT_SCAN_WINDOW = 60 # ms -DEVICE_MIN_SCAN_INTERVAL = 25 -DEVICE_MAX_SCAN_INTERVAL = 10240 -DEVICE_MIN_SCAN_WINDOW = 25 -DEVICE_MAX_SCAN_WINDOW = 10240 +DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00' +DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms +DEVICE_DEFAULT_ADVERTISING_DATA = '' +DEVICE_DEFAULT_NAME = 'Bumble' +DEVICE_DEFAULT_INQUIRY_LENGTH = 8 # 10.24 seconds +DEVICE_DEFAULT_CLASS_OF_DEVICE = 0 +DEVICE_DEFAULT_SCAN_RESPONSE_DATA = b'' +DEVICE_DEFAULT_DATA_LENGTH = (27, 328, 27, 328) +DEVICE_DEFAULT_SCAN_INTERVAL = 60 # ms +DEVICE_DEFAULT_SCAN_WINDOW = 60 # ms +DEVICE_DEFAULT_CONNECT_TIMEOUT = None # No timeout +DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL = 60 # ms +DEVICE_DEFAULT_CONNECT_SCAN_WINDOW = 60 # ms +DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN = 15 # ms +DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX = 30 # ms +DEVICE_DEFAULT_CONNECTION_MAX_LATENCY = 0 +DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT = 720 # ms +DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH = 0 # ms +DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH = 0 # ms + +DEVICE_MIN_SCAN_INTERVAL = 25 +DEVICE_MAX_SCAN_INTERVAL = 10240 +DEVICE_MIN_SCAN_WINDOW = 25 +DEVICE_MAX_SCAN_WINDOW = 10240 # ----------------------------------------------------------------------------- # Classes @@ -83,6 +94,8 @@ class Advertisement: is_directed = False, is_scannable = False, is_scan_response = False, + is_complete = True, + is_truncated = False, primary_phy = 0, secondary_phy = 0, tx_power = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE, @@ -97,6 +110,8 @@ class Advertisement: self.is_directed = is_directed self.is_scannable = is_scannable self.is_scan_response = is_scan_response + self.is_complete = is_complete + self.is_truncated = is_truncated self.primary_phy = primary_phy self.secondary_phy = secondary_phy self.tx_power = tx_power @@ -139,6 +154,8 @@ class ExtendedAdvertisement(Advertisement): is_directed = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.DIRECTED_ADVERTISING) != 0, is_scannable = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCANNABLE_ADVERTISING) != 0, is_scan_response = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCAN_RESPONSE) != 0, + is_complete = (report.event_type >> 5 & 3) == HCI_LE_Extended_Advertising_Report_Event.DATA_COMPLETE, + is_truncated = (report.event_type >> 5 & 3) == HCI_LE_Extended_Advertising_Report_Event.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME, primary_phy = report.primary_phy, secondary_phy = report.secondary_phy, tx_power = report.tx_power, @@ -150,46 +167,36 @@ class ExtendedAdvertisement(Advertisement): # ----------------------------------------------------------------------------- class AdvertisementDataAccumulator: def __init__(self, passive=False): - self.passive = passive - self.last_event_type = None - self.advertisement = None - self.data = b'' + self.passive = passive + self.last_advertisement = None + self.last_data = b'' def update(self, report): - if isinstance(report, HCI_LE_Advertising_Report_Event.Report): - if report.event_type == HCI_LE_Advertising_Report_Event.SCAN_RSP: - if self.last_event_type in { - HCI_LE_Advertising_Report_Event.ADV_IND, - HCI_LE_Advertising_Report_Event.ADV_SCAN_IND - }: - # This is the response to a scannable advertisement - self.advertisement = Advertisement.from_advertising_report(report) - self.advertisement.data = AdvertisingData.from_bytes(self.data + report.data) - self.advertisement.is_connectable = (self.last_event_type == HCI_LE_Advertising_Report_Event.ADV_IND) - self.advertisement.is_scannable = True - else: - # Unexpected scan response - self.advertisement = None + advertisement = Advertisement.from_advertising_report(report) + result = None - # Reset the data - self.data = b'' - else: - self.data = report.data + if advertisement.is_scan_response: + if self.last_advertisement is not None and not self.last_advertisement.is_scan_response: + # This is the response to a scannable advertisement + result = Advertisement.from_advertising_report(report) + result.is_connectable = self.last_advertisement.is_connectable + result.is_scannable = True + result.data = AdvertisingData.from_bytes(self.last_data + report.data) + self.last_data = b'' + else: + if ( + self.passive or + (not advertisement.is_scannable) or + (self.last_advertisement is not None and not self.last_advertisement.is_scan_response) + ): + # Don't wait for a scan response + result = Advertisement.from_advertising_report(report) - if self.passive or report.event_type in { - HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND, - HCI_LE_Advertising_Report_Event.ADV_NONCONN_IND - } or self.last_event_type not in { - None, - HCI_LE_Advertising_Report_Event.SCAN_RSP - }: - # Don't wait for a scan response - self.advertisement = Advertisement.from_advertising_report(report) - else: - # Wait for a scan response - self.advertisement = None + self.last_data = report.data - self.last_event_type = report.event_type + self.last_advertisement = advertisement + + return result # ----------------------------------------------------------------------------- @@ -206,7 +213,9 @@ class Peer: return self.gatt_client.services async def request_mtu(self, mtu): - return await self.gatt_client.request_mtu(mtu) + mtu = await self.gatt_client.request_mtu(mtu) + self.connection.att_mtu = mtu + self.connection.emit('connection_att_mtu_update') async def discover_service(self, uuid): return await self.gatt_client.discover_service(uuid) @@ -275,11 +284,23 @@ class Peer: async def __aexit__(self, exc_type, exc_value, traceback): pass - def __str__(self): return f'{self.connection.peer_address} as {self.connection.role_name}' +# ----------------------------------------------------------------------------- +@dataclass +class ConnectionParametersPreferences: + connection_interval_min: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN + connection_interval_max: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX + max_latency: int = DEVICE_DEFAULT_CONNECTION_MAX_LATENCY + supervision_timeout: int = DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT + min_ce_length: int = DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH + max_ce_length: int = DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH + +DEVICE_DEFAULT_CONNECTION_PARAMETER_PREFERENCES = ConnectionParametersPreferences() + + # ----------------------------------------------------------------------------- class Connection(CompositeEventEmitter): @composite_listener @@ -308,7 +329,17 @@ class Connection(CompositeEventEmitter): def on_connection_encryption_key_refresh(self): pass - def __init__(self, device, handle, transport, peer_address, peer_resolvable_address, role, parameters): + def __init__( + self, + device, + handle, + transport, + peer_address, + peer_resolvable_address, + role, + parameters, + phy + ): super().__init__() self.device = device self.handle = handle @@ -320,7 +351,7 @@ class Connection(CompositeEventEmitter): self.parameters = parameters self.encryption = 0 self.authenticated = False - self.phy = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY) + self.phy = phy self.att_mtu = ATT_DEFAULT_MTU self.data_length = DEVICE_DEFAULT_DATA_LENGTH self.gatt_client = None # Per-connection client @@ -373,19 +404,28 @@ class Connection(CompositeEventEmitter): async def update_parameters( self, - conn_interval_min, - conn_interval_max, - conn_latency, + connection_interval_min, + connection_interval_max, + max_latency, supervision_timeout ): return await self.device.update_connection_parameters( self, - conn_interval_min, - conn_interval_max, - conn_latency, + connection_interval_min, + connection_interval_max, + max_latency, supervision_timeout ) + async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None): + return await self.device.set_connection_phy(self, tx_phys, rx_phys, phy_options) + + async def get_rssi(self): + return await self.device.get_connection_rssi(self) + + async def get_phy(self): + return await self.device.get_connection_phy(self) + # [Classic only] async def request_remote_name(self): return await self.device.request_remote_name(self) @@ -549,25 +589,26 @@ class Device(CompositeEventEmitter): def __init__(self, name = None, address = None, config = None, host = None, generic_access_service = True): super().__init__() - self._host = None - self.powered_on = False - self.advertising = False - self.auto_restart_advertising = False - self.command_timeout = 10 # seconds - self.gatt_server = gatt_server.Server(self) - self.sdp_server = sdp.Server(self) - self.l2cap_channel_manager = l2cap.ChannelManager( - [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]) - self.advertisement_data = {} - self.scanning = False - self.scanning_is_passive = False - self.discovering = False - self.connecting = False - self.disconnecting = False - self.connections = {} # Connections, by connection handle - self.classic_enabled = False - self.inquiry_response = None - self.address_resolver = None + self._host = None + self.powered_on = False + self.advertising = False + self.auto_restart_advertising = False + self.command_timeout = 10 # seconds + self.gatt_server = gatt_server.Server(self) + self.sdp_server = sdp.Server(self) + self.l2cap_channel_manager = l2cap.ChannelManager( + [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS] + ) + self.advertisement_accumulators = {} # Accumulators, by address + self.scanning = False + self.scanning_is_passive = False + self.discovering = False + self.connecting = False + self.disconnecting = False + self.connections = {} # Connections, by connection handle + self.classic_enabled = False + self.inquiry_response = None + self.address_resolver = None # Use the initial config or a default self.public_address = Address('00:00:00:00:00:00') @@ -676,9 +717,12 @@ class Device(CompositeEventEmitter): def send_l2cap_pdu(self, connection_handle, cid, pdu): self.host.send_l2cap_pdu(connection_handle, cid, pdu) - async def send_command(self, command): + async def send_command(self, command, check_result=False): try: - return await asyncio.wait_for(self.host.send_command(command), self.command_timeout) + return await asyncio.wait_for( + self.host.send_command(command, check_result), + self.command_timeout + ) except asyncio.TimeoutError: logger.warning('!!! Command timed out') @@ -701,10 +745,10 @@ class Device(CompositeEventEmitter): # Set the controller address await self.send_command(HCI_LE_Set_Random_Address_Command( random_address = self.random_address - )) + ), check_result=True) # Load the address resolving list - if self.keystore: + if self.keystore and self.host.supports_command(HCI_LE_CLEAR_RESOLVING_LIST_COMMAND): await self.send_command(HCI_LE_Clear_Resolving_List_Command()) resolving_keys = await self.keystore.get_resolving_keys() @@ -754,6 +798,19 @@ class Device(CompositeEventEmitter): def supports_le_feature(self, feature): return self.host.supports_le_feature(feature) + def supports_le_phy(self, phy): + if phy == HCI_LE_1M_PHY: + return True + + feature_map = { + HCI_LE_2M_PHY: HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE, + HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE + } + if phy not in feature_map: + raise ValueError('invalid PHY') + + return self.host.supports_le_feature(feature_map[phy]) + async def start_advertising(self, auto_restart=False): self.auto_restart_advertising = auto_restart @@ -764,12 +821,12 @@ class Device(CompositeEventEmitter): # Set/update the advertising data await self.send_command(HCI_LE_Set_Advertising_Data_Command( advertising_data = self.advertising_data - )) + ), check_result=True) # Set/update the scan response data await self.send_command(HCI_LE_Set_Scan_Response_Data_Command( scan_response_data = self.scan_response_data - )) + ), check_result=True) # Set the advertising parameters await self.send_command(HCI_LE_Set_Advertising_Parameters_Command( @@ -782,12 +839,12 @@ class Device(CompositeEventEmitter): peer_address = Address('00:00:00:00:00:00'), advertising_channel_map = 7, advertising_filter_policy = 0 - )) + ), check_result=True) # Enable advertising await self.send_command(HCI_LE_Set_Advertising_Enable_Command( advertising_enable = 1 - )) + ), check_result=True) self.advertising = True @@ -796,7 +853,7 @@ class Device(CompositeEventEmitter): if self.advertising: await self.send_command(HCI_LE_Set_Advertising_Enable_Command( advertising_enable = 0 - )) + ), check_result=True) self.advertising = False @@ -810,7 +867,8 @@ class Device(CompositeEventEmitter): scan_interval=DEVICE_DEFAULT_SCAN_INTERVAL, # Scan interval in ms scan_window=DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms own_address_type=Address.RANDOM_DEVICE_ADDRESS, - filter_duplicates=False + filter_duplicates=False, + scanning_phys=(HCI_LE_1M_PHY, HCI_LE_CODED_PHY) ): # Check that the arguments are legal if scan_interval < scan_window: @@ -820,25 +878,36 @@ class Device(CompositeEventEmitter): if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW: raise ValueError('scan_interval out of range') + # Reset the accumulators + self.advertisement_accumulator = {} + + # Enable scanning if 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 - scanning_phys = 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_1M_PHY - scanning_phy_count = 1 - if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE): - scanning_phys |= 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_CODED_PHY + scanning_phy_count = 0 + scanning_phys_bits = 0 + if HCI_LE_1M_PHY in scanning_phys: + scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT scanning_phy_count += 1 + if HCI_LE_CODED_PHY in scanning_phys: + if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE): + scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT + scanning_phy_count += 1 + + if scanning_phy_count == 0: + raise ValueError('at least one scanning PHY must be enabled') await self.send_command(HCI_LE_Set_Extended_Scan_Parameters_Command( own_address_type = own_address_type, scanning_filter_policy = scanning_filter_policy, - scanning_phys = scanning_phys, + scanning_phys = scanning_phys_bits, scan_types = [scan_type] * scanning_phy_count, scan_intervals = [int(scan_window / 0.625)] * scanning_phy_count, scan_windows = [int(scan_window / 0.625)] * scanning_phy_count - )) + ), check_result=True) # Enable scanning await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command( @@ -846,7 +915,7 @@ class Device(CompositeEventEmitter): filter_duplicates = 1 if filter_duplicates else 0, duration = 0, # TODO allow other values period = 0 # TODO allow other values - )) + ), check_result=True) else: # Set the scanning parameters scan_type = HCI_LE_Set_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Scan_Parameters_Command.PASSIVE_SCANNING @@ -856,22 +925,32 @@ class Device(CompositeEventEmitter): le_scan_window = int(scan_window / 0.625), own_address_type = own_address_type, scanning_filter_policy = HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY - )) + ), check_result=True) # Enable scanning await self.send_command(HCI_LE_Set_Scan_Enable_Command( le_scan_enable = 1, filter_duplicates = 1 if filter_duplicates else 0 - )) + ), check_result=True) self.scanning_is_passive = not active self.scanning = True async def stop_scanning(self): - await self.send_command(HCI_LE_Set_Scan_Enable_Command( - le_scan_enable = 0, - filter_duplicates = 0 - )) + # Disable scanning + if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE): + await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command( + enable = 0, + filter_duplicates = 0, + duration = 0, + period = 0 + ), check_result=True) + else: + await self.send_command(HCI_LE_Set_Scan_Enable_Command( + le_scan_enable = 0, + filter_duplicates = 0 + ), check_result=True) + self.scanning = False @property @@ -880,21 +959,22 @@ class Device(CompositeEventEmitter): @host_event_handler def on_advertising_report(self, report): - if not (accumulator := self.advertisement_data.get(report.address)): + if not (accumulator := self.advertisement_accumulators.get(report.address)): accumulator = AdvertisementDataAccumulator(passive=self.scanning_is_passive) - self.advertisement_data[report.address] = accumulator - accumulator.update(report) - if accumulator.advertisement is not None: - self.emit('advertisement', accumulator.advertisement) + self.advertisement_accumulators[report.address] = accumulator + if advertisement := accumulator.update(report): + self.emit('advertisement', advertisement) async def start_discovery(self): - await self.host.send_command(HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE)) + await self.send_command(HCI_Write_Inquiry_Mode_Command( + inquiry_mode=HCI_EXTENDED_INQUIRY_MODE + ), check_result=True) response = await self.send_command(HCI_Inquiry_Command( lap = HCI_GENERAL_INQUIRY_LAP, inquiry_length = DEVICE_DEFAULT_INQUIRY_LENGTH, num_responses = 0 # Unlimited number of responses. - )) + ), check_result=True) if response.status != HCI_Command_Status_Event.PENDING: self.discovering = False raise HCI_StatusError(response) @@ -902,7 +982,7 @@ class Device(CompositeEventEmitter): self.discovering = True async def stop_discovery(self): - await self.send_command(HCI_Inquiry_Cancel_Command()) + await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True) self.discovering = False @host_event_handler @@ -939,11 +1019,12 @@ class Device(CompositeEventEmitter): ) # Update the controller - await self.host.send_command( + await self.send_command( HCI_Write_Extended_Inquiry_Response_Command( fec_required = 0, extended_inquiry_response = self.inquiry_response - ) + ), + check_result=True ) await self.set_scan_enable( inquiry_scan_enabled = self.discoverable, @@ -958,12 +1039,26 @@ class Device(CompositeEventEmitter): page_scan_enabled = self.connectable ) - async def connect(self, peer_address, transport=BT_LE_TRANSPORT): + async def connect( + self, + peer_address, + transport=BT_LE_TRANSPORT, + connection_parameters_preferences=None, + timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT + ): ''' Request a connection to a peer. 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 + * map: each entry has a PHY as key and a ConnectionParametersPreferences object as value ''' + # Check parameters + if transport not in {BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT}: + raise ValueError('invalid transport') + # Adjust the transport automatically if we need to if transport == BT_LE_TRANSPORT and not self.le_enabled: transport = BT_BR_EDR_TRANSPORT @@ -980,49 +1075,117 @@ class Device(CompositeEventEmitter): 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, transport) + peer_address = await self.find_peer_by_name(peer_address, transport) # TODO: timeout # Create a future so that we can wait for the connection's result pending_connection = asyncio.get_running_loop().create_future() self.on('connection', pending_connection.set_result) self.on('connection_failure', pending_connection.set_exception) - # Tell the controller to connect - if transport == BT_LE_TRANSPORT: - # TODO: use real values, not fixed ones - result = await self.send_command(HCI_LE_Create_Connection_Command( - le_scan_interval = 96, - le_scan_window = 96, - initiator_filter_policy = 0, - peer_address_type = peer_address.address_type, - peer_address = peer_address, - own_address_type = Address.RANDOM_DEVICE_ADDRESS, - conn_interval_min = 12, - conn_interval_max = 24, - conn_latency = 0, - supervision_timeout = 72, - minimum_ce_length = 0, - maximum_ce_length = 0 - )) - else: - # TODO: use real values, not fixed ones - result = await self.send_command(HCI_Create_Connection_Command( - bd_addr = peer_address, - packet_type = 0xCC18, # FIXME: change - page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE, - clock_offset = 0x0000, - allow_role_switch = 0x01, - reserved = 0 - )) - try: + # Tell the controller to connect + if transport == BT_LE_TRANSPORT: + if connection_parameters_preferences is None: + if connection_parameters_preferences is None: + connection_parameters_preferences = { + HCI_LE_1M_PHY: DEVICE_DEFAULT_CONNECTION_PARAMETER_PREFERENCES, + HCI_LE_2M_PHY: DEVICE_DEFAULT_CONNECTION_PARAMETER_PREFERENCES, + HCI_LE_CODED_PHY: DEVICE_DEFAULT_CONNECTION_PARAMETER_PREFERENCES + } + + if self.host.supports_command(HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND): + # Only keep supported PHYs + phys = sorted(list(set(filter(self.supports_le_phy, connection_parameters_preferences.keys())))) + if not phys: + raise ValueError('least one supported PHY needed') + + phy_count = len(phys) + initiating_phys = phy_list_to_bits(phys) + + connection_interval_mins = [ + int(connection_parameters_preferences[phy].connection_interval_min / 1.25) for phy in phys + ] + connection_interval_maxs = [ + int(connection_parameters_preferences[phy].connection_interval_max / 1.25) for phy in phys + ] + max_latencies = [ + connection_parameters_preferences[phy].max_latency for phy in phys + ] + supervision_timeouts = [ + int(connection_parameters_preferences[phy].supervision_timeout / 10) for phy in phys + ] + min_ce_lengths = [ + int(connection_parameters_preferences[phy].min_ce_length / 0.625) for phy in phys + ] + max_ce_lengths = [ + int(connection_parameters_preferences[phy].max_ce_length / 0.625) for phy in phys + ] + + result = await self.send_command(HCI_LE_Extended_Create_Connection_Command( + initiator_filter_policy = 0, + own_address_type = Address.RANDOM_DEVICE_ADDRESS, + peer_address_type = peer_address.address_type, + peer_address = peer_address, + initiating_phys = initiating_phys, + scan_intervals = (int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),) * phy_count, + scan_windows = (int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),) * phy_count, + connection_interval_mins = connection_interval_mins, + connection_interval_maxs = connection_interval_maxs, + max_latencies = max_latencies, + supervision_timeouts = supervision_timeouts, + min_ce_lengths = min_ce_lengths, + max_ce_lengths = max_ce_lengths + )) + else: + if HCI_LE_1M_PHY not in connection_parameters_preferences: + raise ValueError('1M PHY preferences required') + + prefs = connection_parameters_preferences[HCI_LE_1M_PHY] + result = await self.send_command(HCI_LE_Create_Connection_Command( + le_scan_interval = int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625), + le_scan_window = int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625), + initiator_filter_policy = 0, + peer_address_type = peer_address.address_type, + peer_address = peer_address, + own_address_type = Address.RANDOM_DEVICE_ADDRESS, + connection_interval_min = int(prefs.connection_interval_min / 1.25), + connection_interval_max = int(prefs.connection_interval_max / 1.25), + max_latency = prefs.max_latency, + supervision_timeout = int(prefs.supervision_timeout / 10), + min_ce_length = int(prefs.min_ce_length / 0.625), + max_ce_length = int(prefs.max_ce_length / 0.625), + )) + else: + # TODO: allow passing other settings + result = await self.send_command(HCI_Create_Connection_Command( + bd_addr = peer_address, + packet_type = 0xCC18, # FIXME: change + page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE, + clock_offset = 0x0000, + allow_role_switch = 0x01, + reserved = 0 + )) + if result.status != HCI_Command_Status_Event.PENDING: raise HCI_StatusError(result) # Wait for the connection process to complete self.connecting = True - return await pending_connection + if timeout is None: + return await pending_connection + else: + try: + return await asyncio.wait_for(asyncio.shield(pending_connection), timeout) + except asyncio.TimeoutError: + if transport == BT_LE_TRANSPORT: + await self.send_command(HCI_LE_Create_Connection_Cancel_Command()) + else: + await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address)) + try: + return await pending_connection + except ConnectionError: + raise TimeoutError() finally: self.remove_listener('connection', pending_connection.set_result) self.remove_listener('connection_failure', pending_connection.set_exception) @@ -1047,7 +1210,7 @@ class Device(CompositeEventEmitter): async def cancel_connection(self): if not self.is_connecting: return - await self.send_command(HCI_LE_Create_Connection_Cancel_Command()) + await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True) async def disconnect(self, connection, reason): # Create a future so that we can wait for the disconnection's result @@ -1056,7 +1219,9 @@ class Device(CompositeEventEmitter): connection.on('disconnection_failure', pending_disconnection.set_exception) # Request a disconnection - result = await self.send_command(HCI_Disconnect_Command(connection_handle = connection.handle, reason = reason)) + result = await self.send_command(HCI_Disconnect_Command( + connection_handle = connection.handle, reason = reason + )) try: if result.status != HCI_Command_Status_Event.PENDING: @@ -1073,26 +1238,66 @@ class Device(CompositeEventEmitter): async def update_connection_parameters( self, connection, - conn_interval_min, - conn_interval_max, - conn_latency, + connection_interval_min, + connection_interval_max, + max_latency, supervision_timeout, - minimum_ce_length = 0, - maximum_ce_length = 0 + min_ce_length = 0, + max_ce_length = 0 ): ''' NOTE: the name of the parameters may look odd, but it just follows the names used in the Bluetooth spec. ''' await self.send_command(HCI_LE_Connection_Update_Command( - connection_handle = connection.handle, - conn_interval_min = conn_interval_min, - conn_interval_max = conn_interval_max, - conn_latency = conn_latency, - supervision_timeout = supervision_timeout, - minimum_ce_length = minimum_ce_length, - maximum_ce_length = maximum_ce_length - )) - # TODO: check result + connection_handle = connection.handle, + connection_interval_min = connection_interval_min, + connection_interval_max = connection_interval_max, + max_latency = max_latency, + supervision_timeout = supervision_timeout, + min_ce_length = min_ce_length, + max_ce_length = max_ce_length + ), check_result=True) + + async def get_connection_rssi(self, connection): + result = await self.send_command(HCI_Read_RSSI_Command(handle = connection.handle), check_result=True) + return result.return_parameters.rssi + + async def get_connection_phy(self, connection): + result = await self.send_command( + HCI_LE_Read_PHY_Command(connection_handle = connection.handle), + check_result=True + ) + return (result.return_parameters.tx_phy, result.return_parameters.rx_phy) + + async def set_connection_phy( + self, + connection, + tx_phys=None, + rx_phys=None, + phy_options=None + ): + all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1) + + return await self.send_command( + HCI_LE_Set_PHY_Command( + connection_handle = connection.handle, + all_phys = all_phys_bits, + tx_phys = phy_list_to_bits(tx_phys), + rx_phys = phy_list_to_bits(rx_phys), + phy_options = 0 # TODO: parse from function argument + ) + ) + + async def set_default_phy(self, tx_phys=None, rx_phys=None): + all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1) + + return await self.send_command( + HCI_LE_Set_Default_PHY_Command( + all_phys = all_phys_bits, + tx_phys = phy_list_to_bits(tx_phys), + rx_phys = phy_list_to_bits(rx_phys) + ), check_result=True + ) async def find_peer_by_name(self, name, transport=BT_LE_TRANSPORT): """ @@ -1116,8 +1321,7 @@ class Device(CompositeEventEmitter): event_name = 'advertisement' handler = self.on( event_name, - lambda address, ad_data, rssi, connectable: - on_peer_found(address, ad_data) + lambda advertisement: on_peer_found(advertisement.address, advertisement.data) ) was_scanning = self.scanning @@ -1371,26 +1575,41 @@ class Device(CompositeEventEmitter): peer_resolvable_address = peer_address peer_address = resolved_address - # Create a new connection - connection = Connection( - self, - connection_handle, - transport, - peer_address, - peer_resolvable_address, - role, - connection_parameters - ) - self.connections[connection_handle] = connection - # We are no longer advertising self.advertising = False - # Emit an event to notify listeners of the new connection - self.emit('connection', connection) + # Create and notify of the new connection asynchronously + async def new_connection(): + # Figure out which PHY we're connected with + if self.host.supports_command(HCI_LE_READ_PHY_COMMAND): + result = await self.send_command( + HCI_LE_Read_PHY_Command(connection_handle=connection_handle), + check_result=True + ) + phy = ConnectionPHY(result.return_parameters.tx_phy, result.return_parameters.rx_phy) + else: + phy = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY) + + # Create a new connection + connection = Connection( + self, + connection_handle, + transport, + peer_address, + peer_resolvable_address, + role, + connection_parameters, + phy + ) + self.connections[connection_handle] = connection + + # Emit an event to notify listeners of the new connection + self.emit('connection', connection) + + asyncio.create_task(new_connection()) @host_event_handler - def on_connection_failure(self, error_code): + def on_connection_failure(self, connection_handle, error_code): logger.debug(f'*** Connection failed: {error_code}') error = ConnectionError( error_code, diff --git a/bumble/hci.py b/bumble/hci.py index 98406562..5f6af92c 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -62,6 +62,18 @@ def map_class_of_device(class_of_device): return f'[{class_of_device:06X}] Services({",".join(DeviceClass.service_class_labels(service_classes))}),Class({DeviceClass.major_device_class_name(major_device_class)}|{DeviceClass.minor_device_class_name(major_device_class, minor_device_class)})' +def phy_list_to_bits(phys): + if phys is None: + return 0 + else: + phy_bits = 0 + for phy in phys: + if phy not in HCI_LE_PHY_TYPE_TO_BIT: + raise ValueError('invalid PHY') + phy_bits |= (1 << HCI_LE_PHY_TYPE_TO_BIT[phy]) + return phy_bits + + # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- @@ -674,6 +686,18 @@ HCI_LE_PHY_NAMES = { HCI_LE_CODED_PHY: 'LE Coded' } +HCI_LE_1M_PHY_BIT = 0 +HCI_LE_2M_PHY_BIT = 1 +HCI_LE_CODED_PHY_BIT = 2 + +HCI_LE_PHY_BIT_NAMES = ['LE_1M_PHY', 'LE_2M_PHY', 'LE_CODED_PHY'] + +HCI_LE_PHY_TYPE_TO_BIT = { + HCI_LE_1M_PHY: HCI_LE_1M_PHY_BIT, + HCI_LE_2M_PHY: HCI_LE_2M_PHY_BIT, + HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT +} + # Connection Parameters HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25 HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25 @@ -1884,6 +1908,22 @@ class HCI_Disconnect_Command(HCI_Command): ''' +# ----------------------------------------------------------------------------- +@HCI_Command.command( + fields=[ + ('bd_addr', Address.parse_address) + ], + return_parameters_fields=[ + ('status', STATUS_SPEC), + ('bd_addr', Address.parse_address) + ] +) +class HCI_Create_Connection_Cancel_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.1.7 Create Connection Cancel Command + ''' + + # ----------------------------------------------------------------------------- @HCI_Command.command([ ('bd_addr', Address.parse_address), @@ -2300,7 +2340,7 @@ class HCI_Read_Local_Name_Command(HCI_Command): # ----------------------------------------------------------------------------- @HCI_Command.command([ - ('conn_accept_timeout', 2) + ('connection_accept_timeout', 2) ]) class HCI_Write_Connection_Accept_Timeout_Command(HCI_Command): ''' @@ -2693,6 +2733,23 @@ class HCI_Read_Local_Supported_Codecs_Command(HCI_Command): ''' +# ----------------------------------------------------------------------------- +@HCI_Command.command( + fields=[ + ('handle', 2) + ], + return_parameters_fields=[ + ('status', STATUS_SPEC), + ('handle', 2), + ('rssi', -1) + ] +) +class HCI_Read_RSSI_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.5.4 Read RSSI Command + ''' + + # ----------------------------------------------------------------------------- @HCI_Command.command( fields=[ @@ -2872,12 +2929,12 @@ class HCI_LE_Set_Scan_Enable_Command(HCI_Command): ('peer_address_type', Address.ADDRESS_TYPE_SPEC), ('peer_address', Address.parse_address_preceded_by_type), ('own_address_type', Address.ADDRESS_TYPE_SPEC), - ('conn_interval_min', 2), - ('conn_interval_max', 2), - ('conn_latency', 2), + ('connection_interval_min', 2), + ('connection_interval_max', 2), + ('max_latency', 2), ('supervision_timeout', 2), - ('minimum_ce_length', 2), - ('maximum_ce_length', 2) + ('min_ce_length', 2), + ('max_ce_length', 2) ]) class HCI_LE_Create_Connection_Command(HCI_Command): ''' @@ -2933,13 +2990,13 @@ class HCI_LE_Remove_Device_From_Filter_Accept_List_Command(HCI_Command): # ----------------------------------------------------------------------------- @HCI_Command.command([ - ('connection_handle', 2), - ('conn_interval_min', 2), - ('conn_interval_max', 2), - ('conn_latency', 2), - ('supervision_timeout', 2), - ('minimum_ce_length', 2), - ('maximum_ce_length', 2) + ('connection_handle', 2), + ('connection_interval_min', 2), + ('connection_interval_max', 2), + ('max_latency', 2), + ('supervision_timeout', 2), + ('min_ce_length', 2), + ('max_ce_length', 2) ]) class HCI_LE_Connection_Update_Command(HCI_Command): ''' @@ -3005,10 +3062,10 @@ class HCI_LE_Read_Supported_States_Command(HCI_Command): ('connection_handle', 2), ('interval_min', 2), ('interval_max', 2), - ('latency', 2), + ('max_latency', 2), ('timeout', 2), - ('minimum_ce_length', 2), - ('maximum_ce_length', 2) + ('min_ce_length', 2), + ('max_ce_length', 2) ]) class HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(HCI_Command): ''' @@ -3089,18 +3146,6 @@ class HCI_LE_Clear_Resolving_List_Command(HCI_Command): ''' -# ----------------------------------------------------------------------------- -@HCI_Command.command([ - ('all_phys', 1), - ('tx_phys', 1), - ('rx_phys', 1) -]) -class HCI_LE_Set_Default_PHY_Command(HCI_Command): - ''' - See Bluetooth spec @ 7.8.48 LE Set Default PHY Command - ''' - - # ----------------------------------------------------------------------------- @HCI_Command.command([ ('address_resolution_enable', 1) @@ -3152,18 +3197,38 @@ class HCI_LE_Read_PHY_Command(HCI_Command): ''' +# ----------------------------------------------------------------------------- +@HCI_Command.command([ + ('all_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_Default_PHY_Command.ANY_PHY_BIT_NAMES)}), + ('tx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}), + ('rx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}) +]) +class HCI_LE_Set_Default_PHY_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.8.48 LE Set Default PHY Command + ''' + ANY_TX_PHY_BIT = 0 + ANY_RX_PHY_BIT = 1 + + ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX'] + + # ----------------------------------------------------------------------------- @HCI_Command.command([ ('connection_handle', 2), - ('all_phys', 1), - ('tx_phys', 1), - ('rx_phys', 1), + ('all_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_PHY_Command.ANY_PHY_BIT_NAMES)}), + ('tx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}), + ('rx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}), ('phy_options', 2) ]) class HCI_LE_Set_PHY_Command(HCI_Command): ''' See Bluetooth spec @ 7.8.49 LE Set PHY Command ''' + ANY_TX_PHY_BIT = 0 + ANY_RX_PHY_BIT = 1 + + ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX'] # ----------------------------------------------------------------------------- @@ -3422,11 +3487,6 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command): EXTENDED_UNFILTERED_POLICY = 0x02 EXTENDED_FILTERED_POLICY = 0x03 - LE_1M_PHY = 0x00 - LE_CODED_PHY = 0x02 - - SCANNING_PHY_NAMES = ['LE_1M_PHY', '', 'LE_CODED_PHY'] - @classmethod def from_parameters(cls, parameters): own_address_type = parameters[0] @@ -3474,7 +3534,7 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command): self.parameters += struct.pack('> 5) & 3]) - - if event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED): - legacy_pdu_type = HCI_LE_Extended_Advertising_Report_Event.LEGACY_PDU_TYPE_MAP.get(event_type & 0x0F) - if legacy_pdu_type is not None: - legacy_info_string = f'({HCI_LE_Advertising_Report_Event.event_type_name(legacy_pdu_type)})' - else: - legacy_info_string = '' - else: - legacy_info_string = '' - - return f'0x{event_type:04X} [{",".join(event_type_flags)}]{legacy_info_string}' - return super().to_string(prefix, { - 'event_type': event_type_string, + 'event_type': HCI_LE_Extended_Advertising_Report_Event.event_type_string, 'address_type': Address.address_type_name, 'data': lambda x: str(AdvertisingData.from_bytes(x)) }) + @staticmethod + def event_type_string(event_type): + event_type_flags = bit_flags_to_strings( + event_type & 0x1F, + HCI_LE_Extended_Advertising_Report_Event.EVENT_TYPE_FLAG_NAMES, + ) + event_type_flags.append(('COMPLETE', 'INCOMPLETE+', 'INCOMPLETE#', '?')[(event_type >> 5) & 3]) + + if event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED): + legacy_pdu_type = HCI_LE_Extended_Advertising_Report_Event.LEGACY_PDU_TYPE_MAP.get(event_type & 0x0F) + if legacy_pdu_type is not None: + legacy_info_string = f'({HCI_LE_Advertising_Report_Event.event_type_name(legacy_pdu_type)})' + else: + legacy_info_string = '' + else: + legacy_info_string = '' + + return f'0x{event_type:04X} [{",".join(event_type_flags)}]{legacy_info_string}' + @classmethod def from_parameters(cls, parameters): num_reports = parameters[1] @@ -4546,7 +4614,7 @@ class HCI_Page_Scan_Repetition_Mode_Change_Event(HCI_Event): # ----------------------------------------------------------------------------- @HCI_Event.registered -class HCI_Inquiry_Result_With_Rssi_Event(HCI_Event): +class HCI_Inquiry_Result_With_RSSI_Event(HCI_Event): ''' See Bluetooth spec @ 7.7.33 Inquiry Result with RSSI Event ''' @@ -4566,11 +4634,11 @@ class HCI_Inquiry_Result_With_Rssi_Event(HCI_Event): responses = [] offset = 1 for _ in range(num_responses): - response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_Rssi_Event.RESPONSE_FIELDS) + response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_RSSI_Event.RESPONSE_FIELDS) offset += 14 responses.append(response) - return HCI_Inquiry_Result_With_Rssi_Event(responses) + return HCI_Inquiry_Result_With_RSSI_Event(responses) def __init__(self, responses): self.responses = responses[:] diff --git a/bumble/host.py b/bumble/host.py index 67ad759c..35efad49 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -76,7 +76,7 @@ class Host(EventEmitter): self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS self.acl_packet_queue = collections.deque() self.acl_packets_in_flight = 0 - self.local_version = HCI_VERSION_BLUETOOTH_CORE_4_0 + self.local_version = None self.local_supported_commands = bytes(64) self.local_le_features = 0 self.command_semaphore = asyncio.Semaphore(1) @@ -91,32 +91,23 @@ class Host(EventEmitter): self.set_packet_sink(controller_sink) async def reset(self): - await self.send_command(HCI_Reset_Command()) + await self.send_command(HCI_Reset_Command(), check_result=True) self.ready = True - response = await self.send_command(HCI_Read_Local_Supported_Commands_Command()) - if response.return_parameters.status == HCI_SUCCESS: - self.local_supported_commands = response.return_parameters.supported_commands - else: - logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}') + response = await self.send_command(HCI_Read_Local_Supported_Commands_Command(), check_result=True) + self.local_supported_commands = response.return_parameters.supported_commands if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND): - response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command()) - if response.return_parameters.status == HCI_SUCCESS: - self.local_le_features = struct.unpack(' CONTROLLER", "blue")}: {command}') # Wait until we can send (only one pending command at a time) @@ -186,11 +171,22 @@ class Host(EventEmitter): try: self.send_hci_packet(command) response = await self.pending_response - # TODO: check error values + + # Check the return parameters if required + if check_result: + if type(response.return_parameters) is int: + status = response.return_parameters + else: + status = response.return_parameters.status + + if status != HCI_SUCCESS: + logger.warning(f'{command.name} failed ({HCI_Constant.error_name(status)})') + raise HCI_Error(status) + return response except Exception as error: logger.warning(f'{color("!!! Exception while sending HCI packet:", "red")} {error}') - # raise error + raise error finally: self.pending_command = None self.pending_response = None @@ -370,8 +366,8 @@ class Host(EventEmitter): # Notify the client connection_parameters = ConnectionParameters( - event.conn_interval, - event.conn_latency, + event.connection_interval, + event.peripheral_latency, event.supervision_timeout ) self.emit( @@ -387,7 +383,7 @@ class Host(EventEmitter): logger.debug(f'### CONNECTION FAILED: {event.status}') # Notify the listeners - self.emit('connection_failure', event.status) + self.emit('connection_failure', event.connection_handle, event.status) def on_hci_le_enhanced_connection_complete_event(self, event): # Just use the same implementation as for the non-enhanced event for now @@ -435,7 +431,7 @@ class Host(EventEmitter): logger.debug(f'### DISCONNECTION FAILED: {event.status}') # Notify the listeners - self.emit('disconnection_failure', event.status) + self.emit('disconnection_failure', event.connection_handle, event.status) def on_hci_le_connection_update_complete_event(self, event): if (connection := self.connections.get(event.connection_handle)) is None: @@ -445,8 +441,8 @@ class Host(EventEmitter): # Notify the client if event.status == HCI_SUCCESS: connection_parameters = ConnectionParameters( - event.conn_interval, - event.conn_latency, + event.connection_interval, + event.peripheral_latency, event.supervision_timeout ) self.emit('connection_parameters_update', connection.handle, connection_parameters) @@ -470,8 +466,7 @@ class Host(EventEmitter): self.emit('advertising_report', report) def on_hci_le_extended_advertising_report_event(self, event): - # TODO - pass + self.on_hci_le_advertising_report_event(event) def on_hci_le_remote_connection_parameter_request_event(self, event): if event.connection_handle not in self.connections: @@ -487,8 +482,8 @@ class Host(EventEmitter): interval_max = event.interval_max, latency = event.latency, timeout = event.timeout, - minimum_ce_length = 0, - maximum_ce_length = 0 + min_ce_length = 0, + max_ce_length = 0 ) ) diff --git a/bumble/l2cap.py b/bumble/l2cap.py index e39dff19..927454e8 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -462,10 +462,10 @@ class L2CAP_Information_Response(L2CAP_Control_Frame): # ----------------------------------------------------------------------------- @L2CAP_Control_Frame.subclass([ - ('interval_min', 2), - ('interval_max', 2), - ('slave_latency', 2), - ('timeout_multiplier', 2) + ('interval_min', 2), + ('interval_max', 2), + ('latency', 2), + ('timeout', 2) ]) class L2CAP_Connection_Parameter_Update_Request(L2CAP_Control_Frame): ''' @@ -857,10 +857,10 @@ class ChannelManager: identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256 self.identifiers[connection.handle] = identifier return identifier - + def register_fixed_channel(self, cid, handler): self.fixed_channels[cid] = handler - + def deregister_fixed_channel(self, cid): if cid in self.fixed_channels: del self.fixed_channels[cid] @@ -1057,13 +1057,13 @@ class ChannelManager: ) ) self.host.send_command_sync(HCI_LE_Connection_Update_Command( - connection_handle = connection.handle, - conn_interval_min = request.interval_min, - conn_interval_max = request.interval_max, - conn_latency = request.slave_latency, - supervision_timeout = request.timeout_multiplier, - minimum_ce_length = 0, - maximum_ce_length = 0 + connection_handle = connection.handle, + connection_interval_min = request.interval_min, + connection_interval_max = request.interval_max, + max_latency = request.latency, + supervision_timeout = request.timeout, + min_ce_length = 0, + max_ce_length = 0 )) else: self.send_control_frame( diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md index d9033942..e43c82c8 100644 --- a/docs/mkdocs/src/transports/android_emulator.md +++ b/docs/mkdocs/src/transports/android_emulator.md @@ -5,8 +5,9 @@ The Android emulator transport either connects, as a host, to a "Root Canal" vir ("host" mode), or attaches a virtual controller to the Android Bluetooth host stack ("controller" mode). ## Moniker -The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=][mode=]`. -Both the `mode=` and `mode=` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator) +The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=][:]`, where +the `mode` parameter can specify running as a host or a controller, and `:` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator. +Both the `mode=` and `:` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator). !!! example Example `android-emulator` diff --git a/tests/hci_test.py b/tests/hci_test.py index 63318476..805f1584 100644 --- a/tests/hci_test.py +++ b/tests/hci_test.py @@ -49,10 +49,10 @@ def test_HCI_LE_Connection_Complete_Event(): role = 1, peer_address_type = 1, peer_address = address, - conn_interval = 3, - conn_latency = 4, + connection_interval = 3, + peripheral_latency = 4, supervision_timeout = 5, - master_clock_accuracy = 6 + central_clock_accuracy = 6 ) basic_check(event) @@ -60,8 +60,8 @@ def test_HCI_LE_Connection_Complete_Event(): # ----------------------------------------------------------------------------- def test_HCI_LE_Advertising_Report_Event(): address = Address('00:11:22:33:44:55') - report = HCI_Object( - HCI_LE_Advertising_Report_Event.REPORT_FIELDS, + report = HCI_LE_Advertising_Report_Event.Report( + HCI_LE_Advertising_Report_Event.Report.FIELDS, event_type = HCI_LE_Advertising_Report_Event.ADV_IND, address_type = Address.PUBLIC_DEVICE_ADDRESS, address = address, @@ -87,8 +87,8 @@ def test_HCI_LE_Connection_Update_Complete_Event(): event = HCI_LE_Connection_Update_Complete_Event( status = HCI_SUCCESS, connection_handle = 0x007, - conn_interval = 10, - conn_latency = 3, + connection_interval = 10, + peripheral_latency = 3, supervision_timeout = 5 ) basic_check(event) @@ -283,12 +283,32 @@ def test_HCI_LE_Create_Connection_Command(): peer_address_type = 1, peer_address = Address('00:11:22:33:44:55'), own_address_type = 2, - conn_interval_min = 7, - conn_interval_max = 8, - conn_latency = 9, + connection_interval_min = 7, + connection_interval_max = 8, + max_latency = 9, supervision_timeout = 10, - minimum_ce_length = 11, - maximum_ce_length = 12 + min_ce_length = 11, + max_ce_length = 12 + ) + basic_check(command) + + +# ----------------------------------------------------------------------------- +def test_HCI_LE_Extended_Create_Connection_Command(): + command = HCI_LE_Extended_Create_Connection_Command( + initiator_filter_policy = 0, + own_address_type = 0, + peer_address_type = 1, + peer_address = Address('00:11:22:33:44:55'), + initiating_phys = 3, + scan_intervals = (10, 11), + scan_windows = (12, 13), + connection_interval_mins = (14, 15), + connection_interval_maxs = (16, 17), + max_latencies = (18, 19), + supervision_timeouts = (20, 21), + min_ce_lengths = (100, 101), + max_ce_lengths = (102, 103) ) basic_check(command) @@ -314,13 +334,13 @@ def test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command(): # ----------------------------------------------------------------------------- def test_HCI_LE_Connection_Update_Command(): command = HCI_LE_Connection_Update_Command( - connection_handle = 0x0002, - conn_interval_min = 10, - conn_interval_max = 20, - conn_latency = 7, - supervision_timeout = 3, - minimum_ce_length = 100, - maximum_ce_length = 200 + connection_handle = 0x0002, + connection_interval_min = 10, + connection_interval_max = 20, + max_latency = 7, + supervision_timeout = 3, + min_ce_length = 100, + max_ce_length = 200 ) basic_check(command) @@ -348,7 +368,7 @@ def test_HCI_LE_Set_Extended_Scan_Parameters_Command(): command = HCI_LE_Set_Extended_Scan_Parameters_Command( own_address_type=Address.RANDOM_DEVICE_ADDRESS, scanning_filter_policy=HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_FILTERED_POLICY, - scanning_phys=(1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_1M_PHY | 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_CODED_PHY | 1 << 4), + scanning_phys=(1 << HCI_LE_1M_PHY_BIT | 1 << HCI_LE_CODED_PHY_BIT | 1 << 4), scan_types=[ HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING, HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING, @@ -408,6 +428,7 @@ def run_test_commands(): test_HCI_LE_Set_Scan_Parameters_Command() test_HCI_LE_Set_Scan_Enable_Command() test_HCI_LE_Create_Connection_Command() + test_HCI_LE_Extended_Create_Connection_Command() test_HCI_LE_Add_Device_To_Filter_Accept_List_Command() test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command() test_HCI_LE_Connection_Update_Command()