Merge pull request #147 from google/gbg/btbench

add benchmark tool and doc
This commit is contained in:
Gilles Boccon-Gibod
2023-03-22 21:13:24 -07:00
committed by GitHub
12 changed files with 1461 additions and 28 deletions

1207
apps/bench.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,8 @@ from bumble.hci import (
HCI_VERSION_NAMES, HCI_VERSION_NAMES,
LMP_VERSION_NAMES, LMP_VERSION_NAMES,
HCI_Command, HCI_Command,
HCI_Command_Complete_Event,
HCI_Command_Status_Event,
HCI_READ_BD_ADDR_COMMAND, HCI_READ_BD_ADDR_COMMAND,
HCI_Read_BD_ADDR_Command, HCI_Read_BD_ADDR_Command,
HCI_READ_LOCAL_NAME_COMMAND, HCI_READ_LOCAL_NAME_COMMAND,
@@ -45,11 +47,20 @@ from bumble.host import Host
from bumble.transport import open_transport_or_link 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): async def get_classic_info(host):
if host.supports_command(HCI_READ_BD_ADDR_COMMAND): if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
response = await host.send_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()
print( print(
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr 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): if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
response = await host.send_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()
print( print(
color('Local Name:', 'yellow'), color('Local Name:', 'yellow'),
@@ -73,7 +84,7 @@ async def get_le_info(host):
response = await host.send_command( response = await host.send_command(
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command() HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
) )
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print( print(
color('LE Number Of Supported Advertising Sets:', 'yellow'), color('LE Number Of Supported Advertising Sets:', 'yellow'),
response.return_parameters.num_supported_advertising_sets, response.return_parameters.num_supported_advertising_sets,
@@ -84,7 +95,7 @@ async def get_le_info(host):
response = await host.send_command( response = await host.send_command(
HCI_LE_Read_Maximum_Advertising_Data_Length_Command() HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
) )
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print( print(
color('LE Maximum Advertising Data Length:', 'yellow'), color('LE Maximum Advertising Data Length:', 'yellow'),
response.return_parameters.max_advertising_data_length, 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): if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
response = await host.send_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( print(
color('Maximum Data Length:', 'yellow'), color('Maximum Data Length:', 'yellow'),
( (

View File

@@ -50,6 +50,7 @@ from .hci import (
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND, HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
HCI_LE_RAND_COMMAND, HCI_LE_RAND_COMMAND,
HCI_LE_READ_PHY_COMMAND, HCI_LE_READ_PHY_COMMAND,
HCI_LE_SET_PHY_COMMAND,
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
@@ -1245,6 +1246,11 @@ class Device(CompositeEventEmitter):
# Done # Done
self.powered_on = True self.powered_on = True
async def power_off(self) -> None:
if self.powered_on:
await self.host.flush()
self.powered_on = False
def supports_le_feature(self, feature): def supports_le_feature(self, feature):
return self.host.supports_le_feature(feature) return self.host.supports_le_feature(feature)
@@ -1669,7 +1675,7 @@ class Device(CompositeEventEmitter):
) )
) )
if not phys: if not phys:
raise ValueError('least one supported PHY needed') raise ValueError('at least one supported PHY needed')
phy_count = len(phys) phy_count = len(phys)
initiating_phys = phy_list_to_bits(phys) initiating_phys = phy_list_to_bits(phys)
@@ -1810,7 +1816,7 @@ class Device(CompositeEventEmitter):
try: try:
return await self.abort_on('flush', pending_connection) return await self.abort_on('flush', pending_connection)
except ConnectionError as error: except core.ConnectionError as error:
raise core.TimeoutError() from error raise core.TimeoutError() from error
finally: finally:
self.remove_listener('connection', on_connection) self.remove_listener('connection', on_connection)
@@ -2044,21 +2050,31 @@ class Device(CompositeEventEmitter):
async def set_connection_phy( async def set_connection_phy(
self, connection, tx_phys=None, rx_phys=None, phy_options=None 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) | ( all_phys_bits = (1 if tx_phys is None else 0) | (
(1 if rx_phys is None else 0) << 1 (1 if rx_phys is None else 0) << 1
) )
return await self.send_command( result = await self.send_command(
HCI_LE_Set_PHY_Command( HCI_LE_Set_PHY_Command(
connection_handle=connection.handle, connection_handle=connection.handle,
all_phys=all_phys_bits, all_phys=all_phys_bits,
tx_phys=phy_list_to_bits(tx_phys), tx_phys=phy_list_to_bits(tx_phys),
rx_phys=phy_list_to_bits(rx_phys), rx_phys=phy_list_to_bits(rx_phys),
phy_options=0 if phy_options is None else int(phy_options), 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): async def set_default_phy(self, tx_phys=None, rx_phys=None):
all_phys_bits = (1 if tx_phys is None else 0) | ( all_phys_bits = (1 if tx_phys is None else 0) | (
(1 if rx_phys is None else 0) << 1 (1 if rx_phys is None else 0) << 1
@@ -2497,7 +2513,7 @@ class Device(CompositeEventEmitter):
self.advertising = False self.advertising = False
# Notify listeners # Notify listeners
error = ConnectionError( error = core.ConnectionError(
error_code, error_code,
transport, transport,
peer_address, peer_address,
@@ -2570,7 +2586,7 @@ class Device(CompositeEventEmitter):
@with_connection_from_handle @with_connection_from_handle
def on_disconnection_failure(self, connection, error_code): def on_disconnection_failure(self, connection, error_code):
logger.debug(f'*** Disconnection failed: {error_code}') logger.debug(f'*** Disconnection failed: {error_code}')
error = ConnectionError( error = core.ConnectionError(
error_code, error_code,
connection.transport, connection.transport,
connection.peer_address, connection.peer_address,

View File

@@ -691,7 +691,7 @@ class Server(EventEmitter):
length=entry_size, attribute_data_list=b''.join(attribute_data_list) length=entry_size, attribute_data_list=b''.join(attribute_data_list)
) )
else: else:
logging.warning(f"not found {request}") logging.debug(f"not found {request}")
self.send_response(connection, response) self.send_response(connection, response)

View File

@@ -796,6 +796,11 @@ class Channel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future() self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result 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): def send_configure_request(self):
options = L2CAP_Control_Frame.encode_configuration_options( options = L2CAP_Control_Frame.encode_configuration_options(
[ [
@@ -1105,6 +1110,10 @@ class LeConnectionOrientedChannel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future() self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result return await self.disconnection_result
def abort(self):
if self.state == self.CONNECTED:
self.change_state(self.DISCONNECTED)
def on_pdu(self, pdu): def on_pdu(self, pdu):
if self.sink is None: if self.sink is None:
logger.warning('received pdu without a sink') logger.warning('received pdu without a sink')
@@ -1492,8 +1501,12 @@ class ChannelManager:
def on_disconnection(self, connection_handle, _reason): def on_disconnection(self, connection_handle, _reason):
logger.debug(f'disconnection from {connection_handle}, cleaning up channels') logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
if connection_handle in self.channels: if connection_handle in self.channels:
for _, channel in self.channels[connection_handle].items():
channel.abort()
del self.channels[connection_handle] del self.channels[connection_handle]
if connection_handle in self.le_coc_channels: 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] del self.le_coc_channels[connection_handle]
if connection_handle in self.identifiers: if connection_handle in self.identifiers:
del self.identifiers[connection_handle] del self.identifiers[connection_handle]

View File

@@ -852,17 +852,27 @@ class Server(EventEmitter):
# Register ourselves with the L2CAP channel manager # Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection) device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
def listen(self, acceptor): def listen(self, acceptor, channel=0):
# Find a free channel number if channel:
for channel in range( if channel in self.acceptors:
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1 # Busy
): return 0
if channel not in self.acceptors: else:
self.acceptors[channel] = acceptor # Find a free channel number
return channel 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... if channel == 0:
return 0 # All channels used...
return 0
self.acceptors[channel] = acceptor
return channel
def on_connection(self, l2cap_channel): def on_connection(self, l2cap_channel):
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')

View File

@@ -20,7 +20,7 @@ import logging
import traceback import traceback
import collections import collections
import sys import sys
from typing import Awaitable, TypeVar from typing import Awaitable, Set, TypeVar
from functools import wraps from functools import wraps
from pyee import EventEmitter from pyee import EventEmitter
@@ -157,6 +157,9 @@ class AsyncRunner:
# Shared default queue # Shared default queue
default_queue = WorkQueue() default_queue = WorkQueue()
# Shared set of running tasks
running_tasks: Set[Awaitable] = set()
@staticmethod @staticmethod
def run_in_task(queue=None): def run_in_task(queue=None):
""" """
@@ -187,6 +190,19 @@ class AsyncRunner:
return decorator return decorator
@staticmethod
def spawn(coroutine):
"""
Spawn a task to run a coroutine in a "fire and forget" mode.
Using this method instead of just calling `asyncio.create_task(coroutine)`
is necessary when you don't keep a reference to the task, because `asyncio`
only keeps weak references to alive tasks.
"""
task = asyncio.create_task(coroutine)
AsyncRunner.running_tasks.add(task)
task.add_done_callback(AsyncRunner.running_tasks.remove)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class FlowControlAsyncPipe: class FlowControlAsyncPipe:

View File

@@ -43,7 +43,7 @@ nav:
- Apps & Tools: - Apps & Tools:
- Overview: apps_and_tools/index.md - Overview: apps_and_tools/index.md
- Console: apps_and_tools/console.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 - HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md - Show: apps_and_tools/show.md
@@ -51,6 +51,7 @@ nav:
- Pair: apps_and_tools/pair.md - Pair: apps_and_tools/pair.md
- Unbond: apps_and_tools/unbond.md - Unbond: apps_and_tools/unbond.md
- USB Probe: apps_and_tools/usb_probe.md - USB Probe: apps_and_tools/usb_probe.md
- Link Relay: apps_and_tools/link_relay.md
- Hardware: - Hardware:
- Overview: hardware/index.md - Overview: hardware/index.md
- Platforms: - Platforms:
@@ -62,7 +63,7 @@ nav:
- Examples: - Examples:
- Overview: examples/index.md - Overview: examples/index.md
copyright: Copyright 2021-2022 Google LLC copyright: Copyright 2021-2023 Google LLC
theme: theme:
name: 'material' name: 'material'

View File

@@ -0,0 +1,158 @@
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``utils),
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
```
!!! example "Reversed roles with L2CAP"
In the first console/terminal:
```
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode l2cap-server --role receiver central usb:1
```

View File

@@ -5,6 +5,7 @@ Included in the project are a few apps and tools, built on top of the core libra
These include: These include:
* [Console](console.md) - an interactive text-based console * [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) * [Pair](pair.md) - Pair/bond two devices (LE and Classic)
* [Unbond](unbond.md) - Remove a previously established bond * [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 * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets

View File

@@ -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. eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic.
!!! warning !!! 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. 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.
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)
Overview Overview
-------- --------

View File

@@ -57,6 +57,7 @@ console_scripts =
bumble-unbond = bumble.apps.unbond:main bumble-unbond = bumble.apps.unbond:main
bumble-usb-probe = bumble.apps.usb_probe:main bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main
[options.package_data] [options.package_data]
* = py.typed, *.pyi * = py.typed, *.pyi