| # Copyright 2024 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 statistics |
| import struct |
| import time |
| |
| import click |
| |
| import bumble.logging |
| from bumble.colors import color |
| from bumble.hci import ( |
| HCI_READ_LOOPBACK_MODE_COMMAND, |
| HCI_WRITE_LOOPBACK_MODE_COMMAND, |
| Address, |
| HCI_Read_Loopback_Mode_Command, |
| HCI_SynchronousDataPacket, |
| HCI_Write_Loopback_Mode_Command, |
| LoopbackMode, |
| ) |
| from bumble.host import Host |
| from bumble.transport import open_transport |
| |
| |
| class Loopback: |
| """Send and receive ACL data packets in local loopback mode""" |
| |
| def __init__( |
| self, |
| packet_size: int, |
| packet_count: int, |
| connection_type: str, |
| mode: str, |
| interval: int, |
| transport: str, |
| ): |
| self.transport = transport |
| self.packet_size = packet_size |
| self.packet_count = packet_count |
| self.connection_handle: int | None = None |
| self.connection_type = connection_type |
| self.connection_event = asyncio.Event() |
| self.mode = mode |
| self.interval = interval |
| self.done = asyncio.Event() |
| self.expected_counter = 0 |
| self.bytes_received = 0 |
| self.start_timestamp = 0.0 |
| self.last_timestamp = 0.0 |
| self.send_timestamps: list[float] = [] |
| self.rtts: list[float] = [] |
| |
| def on_connection(self, connection_handle: int, *args): |
| """Retrieve connection handle from new connection event""" |
| if not self.connection_event.is_set(): |
| # The first connection handle is of type ACL, |
| # subsequent connections are of type SCO |
| if self.connection_type == "sco" and self.connection_handle is None: |
| self.connection_handle = connection_handle |
| return |
| |
| self.connection_handle = connection_handle |
| self.connection_event.set() |
| |
| def on_sco_connection( |
| self, |
| address: Address, |
| connection_handle: int, |
| link_type, |
| rx_packet_length: int, |
| tx_packet_length: int, |
| air_mode: int, |
| ) -> None: |
| self.on_connection(connection_handle) |
| |
| def on_packet(self, connection_handle: int, packet: bytes): |
| """Calculate packet receive speed""" |
| now = time.time() |
| (counter,) = struct.unpack_from("H", packet, 0) |
| rtt = now - self.send_timestamps[counter] |
| self.rtts.append(rtt) |
| print(f'<<< Received packet {counter}: {len(packet)} bytes, RTT={rtt:.4f}') |
| assert connection_handle == self.connection_handle |
| assert counter == self.expected_counter |
| self.expected_counter += 1 |
| if counter == 0: |
| self.start_timestamp = now |
| else: |
| 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 |
| if self.mode == 'throughput': |
| print( |
| color( |
| f'@@@ RX speed: instant={instant_rx_speed:.4f},' |
| f' average={average_rx_speed:.4f},', |
| 'cyan', |
| ) |
| ) |
| |
| self.last_timestamp = now |
| |
| if self.expected_counter == self.packet_count: |
| print(color('@@@ Received last packet', 'green')) |
| self.done.set() |
| |
| def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes): |
| self.on_packet(connection_handle, pdu) |
| |
| def on_sco_packet(self, connection_handle: int, packet) -> None: |
| self.on_packet(connection_handle, packet) |
| |
| async def send_acl_packet(self, host: Host, packet: bytes) -> None: |
| assert self.connection_handle |
| host.send_l2cap_pdu(self.connection_handle, 0, packet) |
| |
| async def send_sco_packet(self, host: Host, packet: bytes) -> None: |
| assert self.connection_handle |
| host.send_hci_packet( |
| HCI_SynchronousDataPacket( |
| connection_handle=self.connection_handle, |
| packet_status=HCI_SynchronousDataPacket.Status.CORRECTLY_RECEIVED_DATA, |
| data_total_length=len(packet), |
| data=packet, |
| ) |
| ) |
| |
| async def send_loop(self, host: Host, sender) -> None: |
| for counter in range(0, self.packet_count): |
| print( |
| color( |
| f'>>> Sending {self.connection_type.upper()} ' |
| f'packet {counter}: {self.packet_size} bytes', |
| 'yellow', |
| ) |
| ) |
| self.send_timestamps.append(time.time()) |
| await sender(host, struct.pack("H", counter) + bytes(self.packet_size - 2)) |
| await asyncio.sleep(self.interval / 1000 if self.mode == "rtt" else 0) |
| |
| async def run(self) -> None: |
| """Run a loopback throughput test""" |
| print(color('>>> Connecting to HCI...', 'green')) |
| async with await open_transport(self.transport) as ( |
| hci_source, |
| hci_sink, |
| ): |
| print(color('>>> Connected', 'green')) |
| |
| host = Host(hci_source, hci_sink) |
| await host.reset() |
| |
| # make sure data can fit in one l2cap pdu |
| l2cap_header_size = 4 |
| |
| packet_queue = ( |
| host.acl_packet_queue |
| if host.acl_packet_queue |
| else host.le_acl_packet_queue |
| ) |
| if packet_queue is None: |
| print(color('!!! No packet queue', 'red')) |
| return |
| max_packet_size = packet_queue.max_packet_size - l2cap_header_size |
| if self.packet_size > max_packet_size: |
| print( |
| color( |
| f'!!! Packet size ({self.packet_size}) larger than max supported' |
| f' size ({max_packet_size})', |
| 'red', |
| ) |
| ) |
| return |
| |
| if not host.supports_command( |
| HCI_WRITE_LOOPBACK_MODE_COMMAND |
| ) or not host.supports_command(HCI_READ_LOOPBACK_MODE_COMMAND): |
| print(color('!!! Loopback mode not supported', 'red')) |
| return |
| |
| # set event callbacks |
| host.on('classic_connection', self.on_connection) |
| host.on('le_connection', self.on_connection) |
| host.on('sco_connection', self.on_sco_connection) |
| host.on('l2cap_pdu', self.on_l2cap_pdu) |
| host.on('sco_packet', self.on_sco_packet) |
| |
| loopback_mode = LoopbackMode.LOCAL |
| |
| print(color('### Setting loopback mode', 'blue')) |
| await host.send_sync_command( |
| HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL), |
| ) |
| |
| print(color('### Checking loopback mode', 'blue')) |
| response = await host.send_sync_command(HCI_Read_Loopback_Mode_Command()) |
| if response.loopback_mode != loopback_mode: |
| print(color('!!! Loopback mode mismatch', 'red')) |
| return |
| |
| await self.connection_event.wait() |
| assert self.connection_handle is not None |
| print(color('### Connected', 'cyan')) |
| |
| print(color('=== Start sending', 'magenta')) |
| start_time = time.time() |
| if self.connection_type == "acl": |
| sender = self.send_acl_packet |
| elif self.connection_type == "sco": |
| sender = self.send_sco_packet |
| else: |
| raise ValueError(f'Unknown connection type: {self.connection_type}') |
| await self.send_loop(host, sender) |
| |
| await self.done.wait() |
| print(color('=== Done!', 'magenta')) |
| |
| bytes_sent = self.packet_size * self.packet_count |
| elapsed = time.time() - start_time |
| average_tx_speed = bytes_sent / elapsed |
| if self.mode == 'throughput': |
| print( |
| color( |
| f'@@@ TX speed: average={average_tx_speed:.4f} ' |
| f'({bytes_sent} bytes in {elapsed:.2f} seconds)', |
| 'green', |
| ) |
| ) |
| if self.mode == 'rtt': |
| print( |
| color( |
| f'RTTs: min={min(self.rtts):.4f}, ' |
| f'max={max(self.rtts):.4f}, ' |
| f'avg={statistics.mean(self.rtts):.4f}', |
| 'blue', |
| ) |
| ) |
| |
| |
| # ----------------------------------------------------------------------------- |
| @click.command() |
| @click.option( |
| '--packet-size', |
| '-s', |
| metavar='SIZE', |
| type=click.IntRange(8, 4096), |
| default=500, |
| help='Packet size', |
| ) |
| @click.option( |
| '--packet-count', |
| '-c', |
| metavar='COUNT', |
| type=click.IntRange(1, 65535), |
| default=10, |
| help='Packet count', |
| ) |
| @click.option( |
| '--connection-type', |
| '-t', |
| metavar='TYPE', |
| type=click.Choice(['acl', 'sco']), |
| default='acl', |
| help='Connection type', |
| ) |
| @click.option( |
| '--mode', |
| '-m', |
| metavar='MODE', |
| type=click.Choice(['throughput', 'rtt']), |
| default='throughput', |
| help='Test mode', |
| ) |
| @click.option( |
| '--interval', |
| type=int, |
| default=100, |
| help='Inter-packet interval (ms) [RTT mode only]', |
| ) |
| @click.argument('transport') |
| def main(packet_size, packet_count, connection_type, mode, interval, transport): |
| bumble.logging.setup_basic_logging() |
| |
| if connection_type == "sco" and packet_size > 255: |
| print("ERROR: the maximum packet size for SCO is 255") |
| return |
| |
| async def run(): |
| loopback = Loopback( |
| packet_size, packet_count, connection_type, mode, interval, transport |
| ) |
| await loopback.run() |
| |
| asyncio.run(run()) |
| |
| |
| # ----------------------------------------------------------------------------- |
| if __name__ == '__main__': |
| main() |