diff --git a/apps/bench.py b/apps/bench.py new file mode 100644 index 00000000..6a7aadda --- /dev/null +++ b/apps/bench.py @@ -0,0 +1,1206 @@ +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import enum +import logging +import os +import struct +import time + +import click + +from bumble.core import ( + BT_BR_EDR_TRANSPORT, + BT_LE_TRANSPORT, + BT_L2CAP_PROTOCOL_ID, + BT_RFCOMM_PROTOCOL_ID, + UUID, + CommandTimeoutError, +) +from bumble.colors import color +from bumble.device import Connection, ConnectionParametersPreferences, Device, Peer +from bumble.gatt import Characteristic, CharacteristicValue, Service +from bumble.hci import ( + HCI_LE_1M_PHY, + HCI_LE_2M_PHY, + HCI_LE_CODED_PHY, + HCI_Constant, + HCI_Error, + HCI_StatusError, +) +from bumble.sdp import ( + SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, + SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, + SDP_PUBLIC_BROWSE_ROOT, + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement, + ServiceAttribute, +) +from bumble.transport import open_transport_or_link +import bumble.rfcomm +import bumble.core + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +DEFAULT_CENTRAL_ADDRESS = 'F0:F0:F0:F0:F0:F0' +DEFAULT_CENTRAL_NAME = 'Speed Central' +DEFAULT_PERIPHERAL_ADDRESS = 'F1:F1:F1:F1:F1:F1' +DEFAULT_PERIPHERAL_NAME = 'Speed Peripheral' + +SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5' +SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53' +SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D' + +DEFAULT_L2CAP_PSM = 1234 +DEFAULT_L2CAP_MAX_CREDITS = 128 +DEFAULT_L2CAP_MTU = 1022 +DEFAULT_L2CAP_MPS = 1024 + +DEFAULT_LINGER_TIME = 1.0 + +DEFAULT_RFCOMM_CHANNEL = 8 + +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- +def parse_packet(packet): + if len(packet) < 1: + print( + color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red') + ) + raise ValueError('packet too short') + + try: + packet_type = PacketType(packet[0]) + except ValueError: + print(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red')) + raise + + return (packet_type, packet[1:]) + + +def parse_packet_sequence(packet_data): + if len(packet_data) < 5: + print( + color( + f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)', + 'red', + ) + ) + raise ValueError('packet too short') + return struct.unpack_from('>bI', packet_data, 0) + + +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 print_connection(connection): + if connection.transport == BT_LE_TRANSPORT: + phy_state = ( + 'PHY=' + f'RX:{le_phy_name(connection.phy.rx_phy)}/' + f'TX:{le_phy_name(connection.phy.tx_phy)}' + ) + + data_length = f'DL={connection.data_length}' + connection_parameters = ( + 'Parameters=' + f'{connection.parameters.connection_interval * 1.25:.2f}/' + f'{connection.parameters.peripheral_latency}/' + f'{connection.parameters.supervision_timeout * 10} ' + ) + + else: + phy_state = '' + data_length = '' + connection_parameters = '' + + mtu = connection.att_mtu + + print( + f'{color("@@@ Connection:", "yellow")} ' + f'{connection_parameters} ' + f'{data_length} ' + f'{phy_state} ' + f'MTU={mtu}' + ) + + +def make_sdp_records(channel): + return { + 0x00010001: [ + ServiceAttribute( + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement.unsigned_integer_32(0x00010001), + ), + ServiceAttribute( + SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, + DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]), + ), + ServiceAttribute( + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))] + ), + ), + ServiceAttribute( + SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [ + DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]), + DataElement.sequence( + [ + DataElement.uuid(BT_RFCOMM_PROTOCOL_ID), + DataElement.unsigned_integer_8(channel), + ] + ), + ] + ), + ), + ] + } + + +class PacketType(enum.IntEnum): + RESET = 0 + SEQUENCE = 1 + ACK = 2 + + +PACKET_FLAG_LAST = 1 + +# ----------------------------------------------------------------------------- +# Sender +# ----------------------------------------------------------------------------- +class Sender: + def __init__(self, packet_io, start_delay, packet_size, packet_count): + self.tx_start_delay = start_delay + self.tx_packet_size = packet_size + self.tx_packet_count = packet_count + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.start_time = 0 + self.bytes_sent = 0 + self.done = asyncio.Event() + + def reset(self): + pass + + async def run(self): + print(color('--- Waiting for I/O to be ready...', 'blue')) + await self.packet_io.ready.wait() + print(color('--- Go!', 'blue')) + + if self.tx_start_delay: + print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) + await asyncio.sleep(self.tx_start_delay) # FIXME + + print(color('=== Sending RESET', 'magenta')) + await self.packet_io.send_packet(bytes([PacketType.RESET])) + self.start_time = time.time() + for tx_i in range(self.tx_packet_count): + packet_flags = PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0 + packet = struct.pack( + '>bbI', + PacketType.SEQUENCE, + packet_flags, + tx_i, + ) + bytes(self.tx_packet_size - 6) + print(color(f'Sending packet {tx_i}: {len(packet)} bytes', 'yellow')) + self.bytes_sent += len(packet) + await self.packet_io.send_packet(packet) + + await self.done.wait() + print(color('=== Done!', 'magenta')) + + def on_packet_received(self, packet): + try: + packet_type, _ = parse_packet(packet) + except ValueError: + return + + if packet_type == PacketType.ACK: + elapsed = time.time() - self.start_time + average_tx_speed = self.bytes_sent / elapsed + print( + color( + f'@@@ Received ACK. Speed: average={average_tx_speed:.4f}' + f' ({self.bytes_sent} bytes in {elapsed:.2f} seconds)', + 'green', + ) + ) + self.done.set() + + +# ----------------------------------------------------------------------------- +# Receiver +# ----------------------------------------------------------------------------- +class Receiver: + def __init__(self, packet_io): + self.reset() + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + + def reset(self): + self.expected_packet_index = 0 + self.start_timestamp = 0.0 + self.last_timestamp = 0.0 + self.bytes_received = 0 + + def on_packet_received(self, packet): + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + now = time.time() + + if packet_type == PacketType.RESET: + print(color('=== Received RESET', 'magenta')) + self.reset() + self.start_timestamp = now + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + print( + f'<<< Received packet {packet_index}: ' + f'flags=0x{packet_flags:02X}, {len(packet)} bytes' + ) + + if packet_index != self.expected_packet_index: + print( + color( + f'!!! Unexpected packet, expected {self.expected_packet_index} ' + f'but received {packet_index}' + ) + ) + + elapsed_since_start = now - self.start_timestamp + elapsed_since_last = now - self.last_timestamp + self.bytes_received += len(packet) + instant_rx_speed = len(packet) / elapsed_since_last + average_rx_speed = self.bytes_received / elapsed_since_start + print( + color( + f'Speed: instant={instant_rx_speed:.4f}, average={average_rx_speed:.4f}', + 'yellow', + ) + ) + + self.last_timestamp = now + self.expected_packet_index = packet_index + 1 + + if packet_flags & PACKET_FLAG_LAST: + asyncio.create_task( + self.packet_io.send_packet( + struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) + ) + ) + print(color('@@@ Received last packet', 'green')) + self.done.set() + + async def run(self): + await self.done.wait() + print(color('=== Done!', 'magenta')) + + +# ----------------------------------------------------------------------------- +# Ping +# ----------------------------------------------------------------------------- +class Ping: + def __init__(self, packet_io, start_delay, packet_size, packet_count): + self.tx_start_delay = start_delay + self.tx_packet_size = packet_size + self.tx_packet_count = packet_count + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + self.current_packet_index = 0 + self.ping_sent_time = 0.0 + self.latencies = [] + + def reset(self): + pass + + async def run(self): + print(color('--- Waiting for I/O to be ready...', 'blue')) + await self.packet_io.ready.wait() + print(color('--- Go!', 'blue')) + + if self.tx_start_delay: + print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) + await asyncio.sleep(self.tx_start_delay) # FIXME + + print(color('=== Sending RESET', 'magenta')) + await self.packet_io.send_packet(bytes([PacketType.RESET])) + + await self.send_next_ping() + + await self.done.wait() + average_latency = sum(self.latencies) / len(self.latencies) + print(color(f'@@@ Average latency: {average_latency:.2f}')) + print(color('=== Done!', 'magenta')) + + async def send_next_ping(self): + packet = struct.pack( + '>bbI', + PacketType.SEQUENCE, + PACKET_FLAG_LAST + if self.current_packet_index == self.tx_packet_count - 1 + else 0, + self.current_packet_index, + ) + bytes(self.tx_packet_size - 6) + print(color(f'Sending packet {self.current_packet_index}', 'yellow')) + self.ping_sent_time = time.time() + await self.packet_io.send_packet(packet) + + def on_packet_received(self, packet): + elapsed = time.time() - self.ping_sent_time + + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + + if packet_type == PacketType.ACK: + latency = elapsed * 1000 + self.latencies.append(latency) + print( + color( + f'@@@ Received ACK [{packet_index}], latency={latency:.2f}ms', + 'green', + ) + ) + + if packet_index == self.current_packet_index: + self.current_packet_index += 1 + else: + print( + color( + f'!!! Unexpected packet, expected {self.current_packet_index} ' + f'but received {packet_index}' + ) + ) + + if packet_flags & PACKET_FLAG_LAST: + self.done.set() + return + + asyncio.create_task(self.send_next_ping()) + + +# ----------------------------------------------------------------------------- +# Pong +# ----------------------------------------------------------------------------- +class Pong: + def __init__(self, packet_io): + self.reset() + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + + def reset(self): + self.expected_packet_index = 0 + + def on_packet_received(self, packet): + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + if packet_type == PacketType.RESET: + print(color('=== Received RESET', 'magenta')) + self.reset() + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + print( + color( + f'<<< Received packet {packet_index}: ' + f'flags=0x{packet_flags:02X}, {len(packet)} bytes', + 'green', + ) + ) + + if packet_index != self.expected_packet_index: + print( + color( + f'!!! Unexpected packet, expected {self.expected_packet_index} ' + f'but received {packet_index}' + ) + ) + + self.expected_packet_index = packet_index + 1 + + asyncio.create_task( + self.packet_io.send_packet( + struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) + ) + ) + + if packet_flags & PACKET_FLAG_LAST: + self.done.set() + + async def run(self): + await self.done.wait() + print(color('=== Done!', 'magenta')) + + +# ----------------------------------------------------------------------------- +# GattClient +# ----------------------------------------------------------------------------- +class GattClient: + def __init__(self, _device, att_mtu=None): + self.att_mtu = att_mtu + self.speed_rx = None + self.speed_tx = None + self.packet_listener = None + self.ready = asyncio.Event() + + async def on_connection(self, connection): + peer = Peer(connection) + + if self.att_mtu: + print(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue')) + await peer.request_mtu(self.att_mtu) + + print(color('*** Discovering services...', 'blue')) + await peer.discover_services() + + speed_services = peer.get_services_by_uuid(SPEED_SERVICE_UUID) + if not speed_services: + print(color('!!! Speed Service not found', 'red')) + return + speed_service = speed_services[0] + print(color('*** Discovering characteristics...', 'blue')) + await speed_service.discover_characteristics() + + speed_txs = speed_service.get_characteristics_by_uuid(SPEED_TX_UUID) + if not speed_txs: + print(color('!!! Speed TX not found', 'red')) + return + self.speed_tx = speed_txs[0] + + speed_rxs = speed_service.get_characteristics_by_uuid(SPEED_RX_UUID) + if not speed_rxs: + print(color('!!! Speed RX not found', 'red')) + return + self.speed_rx = speed_rxs[0] + + print(color('*** Subscribing to RX', 'blue')) + await self.speed_rx.subscribe(self.on_packet_received) + + print(color('*** Discovery complete', 'blue')) + + connection.on('disconnection', self.on_disconnection) + self.ready.set() + + def on_disconnection(self, _): + self.ready.clear() + + def on_packet_received(self, packet): + if self.packet_listener: + self.packet_listener.on_packet_received(packet) + + async def send_packet(self, packet): + await self.speed_tx.write_value(packet) + + +# ----------------------------------------------------------------------------- +# GattServer +# ----------------------------------------------------------------------------- +class GattServer: + def __init__(self, device): + self.device = device + self.packet_listener = None + self.ready = asyncio.Event() + + # Setup the GATT service + self.speed_tx = Characteristic( + SPEED_TX_UUID, + Characteristic.WRITE, + Characteristic.WRITEABLE, + CharacteristicValue(write=self.on_tx_write), + ) + self.speed_rx = Characteristic(SPEED_RX_UUID, Characteristic.NOTIFY, 0) + + speed_service = Service( + SPEED_SERVICE_UUID, + [self.speed_tx, self.speed_rx], + ) + device.add_services([speed_service]) + + self.speed_rx.on('subscription', self.on_rx_subscription) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + self.ready.clear() + + def on_rx_subscription(self, _connection, notify_enabled, _indicate_enabled): + if notify_enabled: + print(color('*** RX subscription', 'blue')) + self.ready.set() + else: + print(color('*** RX un-subscription', 'blue')) + self.ready.clear() + + def on_tx_write(self, _, value): + if self.packet_listener: + self.packet_listener.on_packet_received(value) + + async def send_packet(self, packet): + await self.device.notify_subscribers(self.speed_rx, packet) + + +# ----------------------------------------------------------------------------- +# StreamedPacketIO +# ----------------------------------------------------------------------------- +class StreamedPacketIO: + def __init__(self): + self.packet_listener = None + self.io_sink = None + self.rx_packet = b'' + self.rx_packet_header = b'' + self.rx_packet_need = 0 + + def on_packet(self, packet): + while packet: + if self.rx_packet_need: + chunk = packet[: self.rx_packet_need] + self.rx_packet += chunk + packet = packet[len(chunk) :] + self.rx_packet_need -= len(chunk) + if not self.rx_packet_need: + # Packet completed + if self.packet_listener: + self.packet_listener.on_packet_received(self.rx_packet) + + self.rx_packet = b'' + self.rx_packet_header = b'' + else: + # Expect the next packet + header_bytes_needed = 2 - len(self.rx_packet_header) + header_bytes = packet[:header_bytes_needed] + self.rx_packet_header += header_bytes + if len(self.rx_packet_header) != 2: + return + packet = packet[len(header_bytes) :] + self.rx_packet_need = struct.unpack('>H', self.rx_packet_header)[0] + + async def send_packet(self, packet): + if not self.io_sink: + print(color('!!! No sink, dropping packet', 'red')) + return + + # pylint: disable-next=not-callable + self.io_sink(struct.pack('>H', len(packet)) + packet) + + +# ----------------------------------------------------------------------------- +# L2capClient +# ----------------------------------------------------------------------------- +class L2capClient(StreamedPacketIO): + def __init__( + self, + _device, + psm=DEFAULT_L2CAP_PSM, + max_credits=DEFAULT_L2CAP_MAX_CREDITS, + mtu=DEFAULT_L2CAP_MTU, + mps=DEFAULT_L2CAP_MPS, + ): + super().__init__() + self.psm = psm + self.max_credits = max_credits + self.mtu = mtu + self.mps = mps + self.ready = asyncio.Event() + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + # Connect a new L2CAP channel + print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow')) + try: + l2cap_channel = await connection.open_l2cap_channel( + psm=self.psm, + max_credits=self.max_credits, + mtu=self.mtu, + mps=self.mps, + ) + print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) + except Exception as error: + print(color(f'!!! Connection failed: {error}', 'red')) + return + + l2cap_channel.sink = self.on_packet + l2cap_channel.on('close', self.on_l2cap_close) + self.io_sink = l2cap_channel.write + + self.ready.set() + + def on_disconnection(self, _): + pass + + def on_l2cap_close(self): + print(color('*** L2CAP channel closed', 'red')) + + +# ----------------------------------------------------------------------------- +# L2capServer +# ----------------------------------------------------------------------------- +class L2capServer(StreamedPacketIO): + def __init__( + self, + device, + psm=DEFAULT_L2CAP_PSM, + max_credits=DEFAULT_L2CAP_MAX_CREDITS, + mtu=DEFAULT_L2CAP_MTU, + mps=DEFAULT_L2CAP_MPS, + ): + super().__init__() + self.l2cap_channel = None + self.ready = asyncio.Event() + + # Listen for incoming L2CAP CoC connections + device.register_l2cap_channel_server( + psm=psm, + server=self.on_l2cap_channel, + max_credits=max_credits, + mtu=mtu, + mps=mps, + ) + print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow')) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + pass + + def on_l2cap_channel(self, l2cap_channel): + print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) + + self.io_sink = l2cap_channel.write + l2cap_channel.on('close', self.on_l2cap_close) + l2cap_channel.sink = self.on_packet + + self.ready.set() + + def on_l2cap_close(self): + print(color('*** L2CAP channel closed', 'red')) + self.l2cap_channel = None + + +# ----------------------------------------------------------------------------- +# RfcommClient +# ----------------------------------------------------------------------------- +class RfcommClient(StreamedPacketIO): + def __init__(self, device): + super().__init__() + self.device = device + self.ready = asyncio.Event() + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + # Create a client and start it + print(color('*** Starting RFCOMM client...', 'blue')) + rfcomm_client = bumble.rfcomm.Client(self.device, connection) + rfcomm_mux = await rfcomm_client.start() + print(color('*** Started', 'blue')) + + channel = DEFAULT_RFCOMM_CHANNEL + print(color(f'### Opening session for channel {channel}...', 'yellow')) + try: + rfcomm_session = await rfcomm_mux.open_dlc(channel) + print(color('### Session open', 'yellow'), rfcomm_session) + except bumble.core.ConnectionError as error: + print(color(f'!!! Session open failed: {error}', 'red')) + await rfcomm_mux.disconnect() + return + + rfcomm_session.sink = self.on_packet + self.io_sink = rfcomm_session.write + + self.ready.set() + + def on_disconnection(self, _): + pass + + +# ----------------------------------------------------------------------------- +# RfcommServer +# ----------------------------------------------------------------------------- +class RfcommServer(StreamedPacketIO): + def __init__(self, device): + super().__init__() + self.ready = asyncio.Event() + + # Create and register a server + rfcomm_server = bumble.rfcomm.Server(device) + + # Listen for incoming DLC connections + channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL) + + # Setup the SDP to advertise this channel + device.sdp_service_records = make_sdp_records(channel_number) + + print( + color( + f'### Listening for RFComm connection on channel {channel_number}', + 'yellow', + ) + ) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + pass + + def on_dlc(self, dlc): + print(color('*** DLC connected:', 'blue'), dlc) + dlc.sink = self.on_packet + self.io_sink = dlc.write + + +# ----------------------------------------------------------------------------- +# Central +# ----------------------------------------------------------------------------- +class Central(Connection.Listener): + def __init__( + self, + transport, + peripheral_address, + classic, + role_factory, + mode_factory, + connection_interval, + phy, + ): + super().__init__() + self.transport = transport + self.peripheral_address = peripheral_address + self.classic = classic + self.role_factory = role_factory + self.mode_factory = mode_factory + self.device = None + self.connection = None + + if phy: + self.phy = { + '1m': HCI_LE_1M_PHY, + '2m': HCI_LE_2M_PHY, + 'coded': HCI_LE_CODED_PHY, + }[phy] + else: + self.phy = None + + if connection_interval: + connection_parameter_preferences = ConnectionParametersPreferences() + connection_parameter_preferences.connection_interval_min = ( + connection_interval + ) + connection_parameter_preferences.connection_interval_max = ( + connection_interval + ) + + # Preferences for the 1M PHY are always set. + self.connection_parameter_preferences = { + HCI_LE_1M_PHY: connection_parameter_preferences, + } + + if self.phy not in (None, HCI_LE_1M_PHY): + # Add an connections parameters entry for this PHY. + self.connection_parameter_preferences[ + self.phy + ] = connection_parameter_preferences + else: + self.connection_parameter_preferences = None + + async def run(self): + print(color('>>> Connecting to HCI...', 'green')) + async with await open_transport_or_link(self.transport) as ( + hci_source, + hci_sink, + ): + print(color('>>> Connected', 'green')) + + central_address = DEFAULT_CENTRAL_ADDRESS + self.device = Device.with_hci( + DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink + ) + mode = self.mode_factory(self.device) + role = self.role_factory(mode) + self.device.classic_enabled = self.classic + + await self.device.power_on() + + print(color(f'### Connecting to {self.peripheral_address}...', 'cyan')) + try: + self.connection = await self.device.connect( + self.peripheral_address, + connection_parameters_preferences=self.connection_parameter_preferences, + transport=BT_BR_EDR_TRANSPORT if self.classic else BT_LE_TRANSPORT, + ) + except CommandTimeoutError: + print(color('!!! Connection timed out', 'red')) + return + except bumble.core.ConnectionError as error: + print(color(f'!!! Connection error: {error}', 'red')) + return + except HCI_StatusError as error: + print(color(f'!!! Connection failed: {error.error_name}')) + return + print(color('### Connected', 'cyan')) + self.connection.listener = self + print_connection(self.connection) + + await mode.on_connection(self.connection) + + # Set the PHY if requested + if self.phy is not None: + try: + await self.connection.set_phy( + tx_phys=[self.phy], rx_phys=[self.phy] + ) + except HCI_Error as error: + print( + color( + f'!!! Unable to set the PHY: {error.error_name}', 'yellow' + ) + ) + + await role.run() + await asyncio.sleep(DEFAULT_LINGER_TIME) + + def on_disconnection(self, reason): + print(color(f'!!! Disconnection: reason={reason}', 'red')) + self.connection = None + + def on_connection_parameters_update(self): + print_connection(self.connection) + + def on_connection_phy_update(self): + print_connection(self.connection) + + def on_connection_att_mtu_update(self): + print_connection(self.connection) + + def on_connection_data_length_change(self): + print_connection(self.connection) + + +# ----------------------------------------------------------------------------- +# Peripheral +# ----------------------------------------------------------------------------- +class Peripheral(Device.Listener, Connection.Listener): + def __init__(self, transport, classic, role_factory, mode_factory): + self.transport = transport + self.classic = classic + self.role_factory = role_factory + self.role = None + self.mode_factory = mode_factory + self.mode = None + self.device = None + self.connection = None + self.connected = asyncio.Event() + + async def run(self): + print(color('>>> Connecting to HCI...', 'green')) + async with await open_transport_or_link(self.transport) as ( + hci_source, + hci_sink, + ): + print(color('>>> Connected', 'green')) + + peripheral_address = DEFAULT_PERIPHERAL_ADDRESS + self.device = Device.with_hci( + DEFAULT_PERIPHERAL_NAME, peripheral_address, hci_source, hci_sink + ) + self.device.listener = self + self.mode = self.mode_factory(self.device) + self.role = self.role_factory(self.mode) + self.device.classic_enabled = self.classic + + await self.device.power_on() + + if self.classic: + await self.device.set_discoverable(True) + await self.device.set_connectable(True) + else: + await self.device.start_advertising(auto_restart=True) + + if self.classic: + print( + color( + '### Waiting for connection on' + f' {self.device.public_address}...', + 'cyan', + ) + ) + else: + print( + color( + f'### Waiting for connection on {peripheral_address}...', + 'cyan', + ) + ) + await self.connected.wait() + print(color('### Connected', 'cyan')) + + await self.mode.on_connection(self.connection) + await self.role.run() + await asyncio.sleep(DEFAULT_LINGER_TIME) + + def on_connection(self, connection): + connection.listener = self + self.connection = connection + self.connected.set() + + def on_disconnection(self, reason): + print(color(f'!!! Disconnection: reason={reason}', 'red')) + self.connection = None + self.role.reset() + + def on_connection_parameters_update(self): + print_connection(self.connection) + + def on_connection_phy_update(self): + print_connection(self.connection) + + def on_connection_att_mtu_update(self): + print_connection(self.connection) + + def on_connection_data_length_change(self): + print_connection(self.connection) + + +# ----------------------------------------------------------------------------- +def create_mode_factory(ctx, default_mode): + mode = ctx.obj['mode'] + if mode is None: + mode = default_mode + + def create_mode(device): + if mode == 'gatt-client': + return GattClient(device, att_mtu=ctx.obj['att_mtu']) + + if mode == 'gatt-server': + return GattServer(device) + + if mode == 'l2cap-client': + return L2capClient(device) + + if mode == 'l2cap-server': + return L2capServer(device) + + if mode == 'rfcomm-client': + return RfcommClient(device) + + if mode == 'rfcomm-server': + return RfcommServer(device) + + raise ValueError('invalid mode') + + return create_mode + + +# ----------------------------------------------------------------------------- +def create_role_factory(ctx, default_role): + role = ctx.obj['role'] + if role is None: + role = default_role + + def create_role(packet_io): + if role == 'sender': + return Sender( + packet_io, + start_delay=ctx.obj['start_delay'], + packet_size=ctx.obj['packet_size'], + packet_count=ctx.obj['packet_count'], + ) + + if role == 'receiver': + return Receiver(packet_io) + + if role == 'ping': + return Ping( + packet_io, + start_delay=ctx.obj['start_delay'], + packet_size=ctx.obj['packet_size'], + packet_count=ctx.obj['packet_count'], + ) + + if role == 'pong': + return Pong(packet_io) + + raise ValueError('invalid role') + + return create_role + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +@click.group() +@click.option('--device-config', metavar='FILENAME', help='Device configuration file') +@click.option('--role', type=click.Choice(['sender', 'receiver', 'ping', 'pong'])) +@click.option( + '--mode', + type=click.Choice( + [ + 'gatt-client', + 'gatt-server', + 'l2cap-client', + 'l2cap-server', + 'rfcomm-client', + 'rfcomm-server', + ] + ), +) +@click.option( + '--att-mtu', + metavar='MTU', + type=click.IntRange(23, 517), + help='GATT MTU (gatt-client mode)', +) +@click.option( + '--packet-size', + '-s', + metavar='SIZE', + type=click.IntRange(8, 4096), + default=500, + help='Packet size (server role)', +) +@click.option( + '--packet-count', + '-c', + metavar='COUNT', + type=int, + default=10, + help='Packet count (server role)', +) +@click.option( + '--start-delay', + '-sd', + metavar='SECONDS', + type=int, + default=1, + help='Start delay (server role)', +) +@click.pass_context +def bench( + ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay +): + ctx.ensure_object(dict) + ctx.obj['device_config'] = device_config + ctx.obj['role'] = role + ctx.obj['mode'] = mode + ctx.obj['att_mtu'] = att_mtu + ctx.obj['packet_size'] = packet_size + ctx.obj['packet_count'] = packet_count + ctx.obj['start_delay'] = start_delay + + ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server') + + +@bench.command() +@click.argument('transport') +@click.option( + '--peripheral', + 'peripheral_address', + metavar='ADDRESS_OR_NAME', + default=DEFAULT_PERIPHERAL_ADDRESS, + help='Address or name to connect to', +) +@click.option( + '--connection-interval', + '--ci', + metavar='CONNECTION_INTERVAL', + type=int, + help='Connection interval (in ms)', +) +@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use') +@click.pass_context +def central(ctx, transport, peripheral_address, connection_interval, phy): + """Run as a central (initiates the connection)""" + role_factory = create_role_factory(ctx, 'sender') + mode_factory = create_mode_factory(ctx, 'gatt-client') + classic = ctx.obj['classic'] + + asyncio.run( + Central( + transport, + peripheral_address, + classic, + role_factory, + mode_factory, + connection_interval, + phy, + ).run() + ) + + +@bench.command() +@click.argument('transport') +@click.pass_context +def peripheral(ctx, transport): + """Run as a peripheral (waits for a connection)""" + role_factory = create_role_factory(ctx, 'receiver') + mode_factory = create_mode_factory(ctx, 'gatt-server') + + asyncio.run( + Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run() + ) + + +def main(): + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + bench() + + +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/apps/controller_info.py b/apps/controller_info.py index 9c9345ee..47079837 100644 --- a/apps/controller_info.py +++ b/apps/controller_info.py @@ -30,6 +30,8 @@ from bumble.hci import ( HCI_VERSION_NAMES, LMP_VERSION_NAMES, HCI_Command, + HCI_Command_Complete_Event, + HCI_Command_Status_Event, HCI_READ_BD_ADDR_COMMAND, HCI_Read_BD_ADDR_Command, HCI_READ_LOCAL_NAME_COMMAND, @@ -45,11 +47,20 @@ from bumble.host import Host from bumble.transport import open_transport_or_link +# ----------------------------------------------------------------------------- +def command_succeeded(response): + if isinstance(response, HCI_Command_Status_Event): + return response.status == HCI_SUCCESS + if isinstance(response, HCI_Command_Complete_Event): + return response.return_parameters.status == HCI_SUCCESS + return False + + # ----------------------------------------------------------------------------- async def get_classic_info(host): if host.supports_command(HCI_READ_BD_ADDR_COMMAND): response = await host.send_command(HCI_Read_BD_ADDR_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print() print( color('Classic Address:', 'yellow'), response.return_parameters.bd_addr @@ -57,7 +68,7 @@ async def get_classic_info(host): if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND): response = await host.send_command(HCI_Read_Local_Name_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print() print( color('Local Name:', 'yellow'), @@ -73,7 +84,7 @@ async def get_le_info(host): response = await host.send_command( HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command() ) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('LE Number Of Supported Advertising Sets:', 'yellow'), response.return_parameters.num_supported_advertising_sets, @@ -84,7 +95,7 @@ async def get_le_info(host): response = await host.send_command( HCI_LE_Read_Maximum_Advertising_Data_Length_Command() ) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('LE Maximum Advertising Data Length:', 'yellow'), response.return_parameters.max_advertising_data_length, @@ -93,7 +104,7 @@ async def get_le_info(host): if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND): response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('Maximum Data Length:', 'yellow'), ( diff --git a/bumble/device.py b/bumble/device.py index 512bb1d3..f30d2787 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -50,6 +50,7 @@ from .hci import ( HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND, HCI_LE_RAND_COMMAND, HCI_LE_READ_PHY_COMMAND, + HCI_LE_SET_PHY_COMMAND, HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, @@ -1242,6 +1243,13 @@ class Device(CompositeEventEmitter): # Done self.powered_on = True + async def power_off(self) -> None: + if self.powered_on: + await self.host.reset() + self.powered_on = False + + # TODO: more cleanup + def supports_le_feature(self, feature): return self.host.supports_le_feature(feature) @@ -1666,7 +1674,7 @@ class Device(CompositeEventEmitter): ) ) if not phys: - raise ValueError('least one supported PHY needed') + raise ValueError('at least one supported PHY needed') phy_count = len(phys) initiating_phys = phy_list_to_bits(phys) @@ -1807,7 +1815,7 @@ class Device(CompositeEventEmitter): try: return await self.abort_on('flush', pending_connection) - except ConnectionError as error: + except core.ConnectionError as error: raise core.TimeoutError() from error finally: self.remove_listener('connection', on_connection) @@ -2041,21 +2049,31 @@ class Device(CompositeEventEmitter): async def set_connection_phy( self, connection, tx_phys=None, rx_phys=None, phy_options=None ): + if not self.host.supports_command(HCI_LE_SET_PHY_COMMAND): + logger.warning('ignoring request, command not supported') + return + 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( + result = 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 if phy_options is None else int(phy_options), - ), - check_result=True, + ) ) + if result.status != HCI_COMMAND_STATUS_PENDING: + logger.warning( + 'HCI_LE_Set_PHY_Command failed: ' + f'{HCI_Constant.error_name(result.status)}' + ) + raise HCI_StatusError(result) + 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 @@ -2494,7 +2512,7 @@ class Device(CompositeEventEmitter): self.advertising = False # Notify listeners - error = ConnectionError( + error = core.ConnectionError( error_code, transport, peer_address, @@ -2567,7 +2585,7 @@ class Device(CompositeEventEmitter): @with_connection_from_handle def on_disconnection_failure(self, connection, error_code): logger.debug(f'*** Disconnection failed: {error_code}') - error = ConnectionError( + error = core.ConnectionError( error_code, connection.transport, connection.peer_address, diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index d82f2732..3a5953a3 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -691,7 +691,7 @@ class Server(EventEmitter): length=entry_size, attribute_data_list=b''.join(attribute_data_list) ) else: - logging.warning(f"not found {request}") + logging.debug(f"not found {request}") self.send_response(connection, response) diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 2610adce..ef7fdab2 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -796,6 +796,11 @@ class Channel(EventEmitter): self.disconnection_result = asyncio.get_running_loop().create_future() return await self.disconnection_result + def abort(self): + if self.state == self.OPEN: + self.change_state(self.CLOSED) + self.emit('close') + def send_configure_request(self): options = L2CAP_Control_Frame.encode_configuration_options( [ @@ -1105,6 +1110,10 @@ class LeConnectionOrientedChannel(EventEmitter): self.disconnection_result = asyncio.get_running_loop().create_future() return await self.disconnection_result + def abort(self): + if self.state == self.CONNECTED: + self.change_state(self.DISCONNECTED) + def on_pdu(self, pdu): if self.sink is None: logger.warning('received pdu without a sink') @@ -1492,8 +1501,12 @@ class ChannelManager: def on_disconnection(self, connection_handle, _reason): logger.debug(f'disconnection from {connection_handle}, cleaning up channels') if connection_handle in self.channels: + for _, channel in self.channels[connection_handle].items(): + channel.abort() del self.channels[connection_handle] if connection_handle in self.le_coc_channels: + for _, channel in self.le_coc_channels[connection_handle].items(): + channel.abort() del self.le_coc_channels[connection_handle] if connection_handle in self.identifiers: del self.identifiers[connection_handle] diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py index a6b02ba6..dbb2795e 100644 --- a/bumble/rfcomm.py +++ b/bumble/rfcomm.py @@ -852,17 +852,27 @@ class Server(EventEmitter): # Register ourselves with the L2CAP channel manager device.register_l2cap_server(RFCOMM_PSM, self.on_connection) - def listen(self, acceptor): - # Find a free channel number - for channel in range( - RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1 - ): - if channel not in self.acceptors: - self.acceptors[channel] = acceptor - return channel + def listen(self, acceptor, channel=0): + if channel: + if channel in self.acceptors: + # Busy + return 0 + else: + # Find a free channel number + for candidate in range( + RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, + RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1, + ): + if candidate not in self.acceptors: + channel = candidate + break - # All channels used... - return 0 + if channel == 0: + # All channels used... + return 0 + + self.acceptors[channel] = acceptor + return channel def on_connection(self, l2cap_channel): logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 8cabd0cc..0ddc982b 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -43,7 +43,7 @@ nav: - Apps & Tools: - Overview: apps_and_tools/index.md - Console: apps_and_tools/console.md - - Link Relay: apps_and_tools/link_relay.md + - Bench: apps_and_tools/bench.md - HCI Bridge: apps_and_tools/hci_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md - Show: apps_and_tools/show.md @@ -51,6 +51,7 @@ nav: - Pair: apps_and_tools/pair.md - Unbond: apps_and_tools/unbond.md - USB Probe: apps_and_tools/usb_probe.md + - Link Relay: apps_and_tools/link_relay.md - Hardware: - Overview: hardware/index.md - Platforms: @@ -62,7 +63,7 @@ nav: - Examples: - Overview: examples/index.md -copyright: Copyright 2021-2022 Google LLC +copyright: Copyright 2021-2023 Google LLC theme: name: 'material' diff --git a/docs/mkdocs/src/apps_and_tools/bench.md b/docs/mkdocs/src/apps_and_tools/bench.md new file mode 100644 index 00000000..13ad0f9c --- /dev/null +++ b/docs/mkdocs/src/apps_and_tools/bench.md @@ -0,0 +1,147 @@ +BENCH TOOL +========== + +The "bench" tool implements a number of different ways of measuring the +throughput and/or latency between two devices. + +# General Usage + +``` +Usage: bench.py [OPTIONS] COMMAND [ARGS]... + +Options: + --device-config FILENAME Device configuration file + --role [sender|receiver|ping|pong] + --mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server] + --att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517] + -s, --packet-size SIZE Packet size (server role) [8<=x<=4096] + -c, --packet-count COUNT Packet count (server role) + -sd, --start-delay SECONDS Start delay (server role) + --help Show this message and exit. + +Commands: + central Run as a central (initiates the connection) + peripheral Run as a peripheral (waits for a connection) +``` + +## Options for the ``central`` Command +``` +Usage: bumble-bench central [OPTIONS] TRANSPORT + + Run as a central (initiates the connection) + +Options: + --peripheral ADDRESS_OR_NAME Address or name to connect to + --connection-interval, --ci CONNECTION_INTERVAL + Connection interval (in ms) + --phy [1m|2m|coded] PHY to use + --help Show this message and exit. +``` + + +To test once device against another, one of the two devices must be running +the ``peripheral`` command and the other the ``central`` command. The device +running the ``peripheral`` command will accept connections from the device +running the ``central`` command. +When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm`` client), +the default addresses configured in the tool should be sufficient. But when using +Bluetooth Classic, the address of the Peripheral must be specified on the Central +using the ``--peripheral`` option. The address will be printed by the Peripheral when +it starts. + +Independently of whether the device is the Central or Peripheral, each device selects a +``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral +must be compatible. + +Device 1 mode | Device 2 mode +------------------|------------------ +``gatt-client`` | ``gatt-server`` +``l2cap-client`` | ``l2cap-server`` +``rfcomm-client`` | ``rfcomm-server`` + +Device 1 role | Device 2 role +--------------|-------------- +``sender`` | ``receiver`` +``ping`` | ``pong`` + + +# Examples + +In the following examples, we have two USB Bluetooth controllers, one on `usb:0` and +the other on `usb:1`, and two consoles/terminals. We will run a command in each. + +!!! example "GATT Throughput" + Using the default mode and role for the Central and Peripheral. + + In the first console/terminal: + ``` + $ bumble-bench peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench central usb:1 + ``` + + In this default configuration, the Central runs a Sender, as a GATT client, + connecting to the Peripheral running a Receiver, as a GATT server. + +!!! example "L2CAP Throughput" + In the first console/terminal: + ``` + $ bumble-bench --mode l2cap-server peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode l2cap-client central usb:1 + ``` + +!!! example "RFComm Throughput" + In the first console/terminal: + ``` + $ bumble-bench --mode rfcomm-server peripheral usb:0 + ``` + + NOTE: the BT address of the Peripheral will be printed out, use it with the + ``--peripheral`` option for the Central. + + In this example, we use a larger packet size and packet count than the default. + + In the second console/terminal: + ``` + $ bumble-bench --mode rfcomm-client --packet-size 2000 --packet-count 100 central --peripheral 00:16:A4:5A:40:F2 usb:1 + ``` + +!!! example "Ping/Pong Latency" + In the first console/terminal: + ``` + $ bumble-bench --role pong peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --role ping central usb:1 + ``` + +!!! example "Reversed modes with GATT and custom connection interval" + In the first console/terminal: + ``` + $ bumble-bench --mode gatt-client peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode gatt-server central --ci 10 usb:1 + ``` + +!!! example "Reversed modes with L2CAP and custom PHY" + In the first console/terminal: + ``` + $ bumble-bench --mode l2cap-client peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode l2cap-server central --phy 2m usb:1 + ``` diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md index f5887386..fe7af564 100644 --- a/docs/mkdocs/src/apps_and_tools/index.md +++ b/docs/mkdocs/src/apps_and_tools/index.md @@ -5,6 +5,7 @@ Included in the project are a few apps and tools, built on top of the core libra These include: * [Console](console.md) - an interactive text-based console + * [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic) * [Pair](pair.md) - Pair/bond two devices (LE and Classic) * [Unbond](unbond.md) - Remove a previously established bond * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets diff --git a/docs/mkdocs/src/index.md b/docs/mkdocs/src/index.md index fb1e155b..c81f7ff4 100644 --- a/docs/mkdocs/src/index.md +++ b/docs/mkdocs/src/index.md @@ -8,8 +8,7 @@ The project initially only supported BLE (Bluetooth Low Energy), but support for eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic. !!! warning - This project is still very much experimental and in an alpha state where a lot of things are still missing or broken, and what's there changes frequently. - Also, there are still a few hardcoded values/parameters in some of the examples and apps which need to be changed (those will eventually be command line arguments, as appropriate) + This project is still in an early state of development where some things are still missing or broken, and what's implemented may change and evolve frequently. Overview -------- diff --git a/setup.cfg b/setup.cfg index ef3dbdf0..90fa9986 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ console_scripts = bumble-unbond = bumble.apps.unbond:main bumble-usb-probe = bumble.apps.usb_probe:main bumble-link-relay = bumble.apps.link_relay.link_relay:main + bumble-bench = bumble.apps.bench:main [options.package_data] * = py.typed, *.pyi