Merge pull request #608 from google/gbg/bench-android-enhancements

add startupDelay and connectionPriority params to BtBench snippets
This commit is contained in:
Gilles Boccon-Gibod
2025-02-06 10:37:55 -05:00
committed by GitHub
22 changed files with 1227 additions and 343 deletions

View File

@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import dataclasses
import enum import enum
import logging import logging
import os import os
@@ -97,34 +98,6 @@ DEFAULT_RFCOMM_MTU = 2048
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utils # Utils
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def parse_packet(packet):
if len(packet) < 1:
logging.info(
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:
logging.info(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:
logging.info(
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): def le_phy_name(phy_id):
return {HCI_LE_1M_PHY: '1M', HCI_LE_2M_PHY: '2M', HCI_LE_CODED_PHY: 'CODED'}.get( 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) phy_id, HCI_Constant.le_phy_name(phy_id)
@@ -225,13 +198,135 @@ async def switch_roles(connection, role):
logging.info(f'{color("### Role switch failed:", "red")} {error}') logging.info(f'{color("### Role switch failed:", "red")} {error}')
class PacketType(enum.IntEnum): # -----------------------------------------------------------------------------
RESET = 0 # Packet
SEQUENCE = 1 # -----------------------------------------------------------------------------
ACK = 2 @dataclasses.dataclass
class Packet:
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
ACK = 2
class PacketFlags(enum.IntFlag):
LAST = 1
packet_type: PacketType
flags: PacketFlags = PacketFlags(0)
sequence: int = 0
timestamp: int = 0
payload: bytes = b""
@classmethod
def from_bytes(cls, data: bytes):
if len(data) < 1:
logging.warning(
color(f'!!! Packet too short (got {len(data)} bytes, need >= 1)', 'red')
)
raise ValueError('packet too short')
try:
packet_type = cls.PacketType(data[0])
except ValueError:
logging.warning(color(f'!!! Invalid packet type 0x{data[0]:02X}', 'red'))
raise
if packet_type == cls.PacketType.RESET:
return cls(packet_type)
flags = cls.PacketFlags(data[1])
(sequence,) = struct.unpack_from("<I", data, 2)
if packet_type == cls.PacketType.ACK:
if len(data) < 6:
logging.warning(
color(
f'!!! Packet too short (got {len(data)} bytes, need >= 6)',
'red',
)
)
return cls(packet_type, flags, sequence)
if len(data) < 10:
logging.warning(
color(
f'!!! Packet too short (got {len(data)} bytes, need >= 10)', 'red'
)
)
raise ValueError('packet too short')
(timestamp,) = struct.unpack_from("<I", data, 6)
return cls(packet_type, flags, sequence, timestamp, data[10:])
def __bytes__(self):
if self.packet_type == self.PacketType.RESET:
return bytes([self.packet_type])
if self.packet_type == self.PacketType.ACK:
return struct.pack("<BBI", self.packet_type, self.flags, self.sequence)
return (
struct.pack(
"<BBII", self.packet_type, self.flags, self.sequence, self.timestamp
)
+ self.payload
)
PACKET_FLAG_LAST = 1 # -----------------------------------------------------------------------------
# Jitter Stats
# -----------------------------------------------------------------------------
class JitterStats:
def __init__(self):
self.reset()
def reset(self):
self.packets = []
self.receive_times = []
self.jitter = []
def on_packet_received(self, packet):
now = time.time()
self.packets.append(packet)
self.receive_times.append(now)
if packet.timestamp and len(self.packets) > 1:
expected_time = (
self.receive_times[0]
+ (packet.timestamp - self.packets[0].timestamp) / 1000000
)
jitter = now - expected_time
else:
jitter = 0.0
self.jitter.append(jitter)
return jitter
def show_stats(self):
if len(self.jitter) < 3:
return
average = sum(self.jitter) / len(self.jitter)
adjusted = [jitter - average for jitter in self.jitter]
log_stats('Jitter (signed)', adjusted, 3)
log_stats('Jitter (absolute)', [abs(jitter) for jitter in adjusted], 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(adjusted)
interval_max = max(adjusted)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count) for i in range(bin_count)
]
for jitter in adjusted:
for i in reversed(range(bin_count)):
if jitter >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -281,19 +376,37 @@ class Sender:
await asyncio.sleep(self.tx_start_delay) await asyncio.sleep(self.tx_start_delay)
logging.info(color('=== Sending RESET', 'magenta')) logging.info(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET])) await self.packet_io.send_packet(
bytes(Packet(packet_type=Packet.PacketType.RESET))
)
self.start_time = time.time() self.start_time = time.time()
self.bytes_sent = 0 self.bytes_sent = 0
for tx_i in range(self.tx_packet_count): for tx_i in range(self.tx_packet_count):
packet_flags = ( if self.pace > 0:
PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0 # Wait until it is time to send the next packet
target_time = self.start_time + (tx_i * self.pace / 1000)
now = time.time()
if now < target_time:
await asyncio.sleep(target_time - now)
else:
await self.packet_io.drain()
packet = bytes(
Packet(
packet_type=Packet.PacketType.SEQUENCE,
flags=(
Packet.PacketFlags.LAST
if tx_i == self.tx_packet_count - 1
else 0
),
sequence=tx_i,
timestamp=int((time.time() - self.start_time) * 1000000),
payload=bytes(
self.tx_packet_size - 10 - self.packet_io.overhead_size
),
)
) )
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
packet_flags,
tx_i,
) + bytes(self.tx_packet_size - 6 - self.packet_io.overhead_size)
logging.info( logging.info(
color( color(
f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow' f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow'
@@ -302,14 +415,6 @@ class Sender:
self.bytes_sent += len(packet) self.bytes_sent += len(packet)
await self.packet_io.send_packet(packet) await self.packet_io.send_packet(packet)
if self.pace is None:
continue
if self.pace > 0:
await asyncio.sleep(self.pace / 1000)
else:
await self.packet_io.drain()
await self.done.wait() await self.done.wait()
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else '' run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
@@ -321,13 +426,13 @@ class Sender:
if self.repeat: if self.repeat:
logging.info(color('--- End of runs', 'blue')) logging.info(color('--- End of runs', 'blue'))
def on_packet_received(self, packet): def on_packet_received(self, data):
try: try:
packet_type, _ = parse_packet(packet) packet = Packet.from_bytes(data)
except ValueError: except ValueError:
return return
if packet_type == PacketType.ACK: if packet.packet_type == Packet.PacketType.ACK:
elapsed = time.time() - self.start_time elapsed = time.time() - self.start_time
average_tx_speed = self.bytes_sent / elapsed average_tx_speed = self.bytes_sent / elapsed
self.stats.append(average_tx_speed) self.stats.append(average_tx_speed)
@@ -350,52 +455,53 @@ class Receiver:
last_timestamp: float last_timestamp: float
def __init__(self, packet_io, linger): def __init__(self, packet_io, linger):
self.reset() self.jitter_stats = JitterStats()
self.packet_io = packet_io self.packet_io = packet_io
self.packet_io.packet_listener = self self.packet_io.packet_listener = self
self.linger = linger self.linger = linger
self.done = asyncio.Event() self.done = asyncio.Event()
self.reset()
def reset(self): def reset(self):
self.expected_packet_index = 0 self.expected_packet_index = 0
self.measurements = [(time.time(), 0)] self.measurements = [(time.time(), 0)]
self.total_bytes_received = 0 self.total_bytes_received = 0
self.jitter_stats.reset()
def on_packet_received(self, packet): def on_packet_received(self, data):
try: try:
packet_type, packet_data = parse_packet(packet) packet = Packet.from_bytes(data)
except ValueError: except ValueError:
logging.exception("invalid packet")
return return
if packet_type == PacketType.RESET: if packet.packet_type == Packet.PacketType.RESET:
logging.info(color('=== Received RESET', 'magenta')) logging.info(color('=== Received RESET', 'magenta'))
self.reset() self.reset()
return return
try: jitter = self.jitter_stats.on_packet_received(packet)
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
logging.info( logging.info(
f'<<< Received packet {packet_index}: ' f'<<< Received packet {packet.sequence}: '
f'flags=0x{packet_flags:02X}, ' f'flags={packet.flags}, '
f'{len(packet) + self.packet_io.overhead_size} bytes' f'jitter={jitter:.4f}, '
f'{len(data) + self.packet_io.overhead_size} bytes',
) )
if packet_index != self.expected_packet_index: if packet.sequence != self.expected_packet_index:
logging.info( logging.info(
color( color(
f'!!! Unexpected packet, expected {self.expected_packet_index} ' f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}' f'but received {packet.sequence}'
) )
) )
now = time.time() now = time.time()
elapsed_since_start = now - self.measurements[0][0] elapsed_since_start = now - self.measurements[0][0]
elapsed_since_last = now - self.measurements[-1][0] elapsed_since_last = now - self.measurements[-1][0]
self.measurements.append((now, len(packet))) self.measurements.append((now, len(data)))
self.total_bytes_received += len(packet) self.total_bytes_received += len(data)
instant_rx_speed = len(packet) / elapsed_since_last instant_rx_speed = len(data) / elapsed_since_last
average_rx_speed = self.total_bytes_received / elapsed_since_start average_rx_speed = self.total_bytes_received / elapsed_since_start
window = self.measurements[-64:] window = self.measurements[-64:]
windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / ( windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / (
@@ -411,15 +517,17 @@ class Receiver:
) )
) )
self.expected_packet_index = packet_index + 1 self.expected_packet_index = packet.sequence + 1
if packet_flags & PACKET_FLAG_LAST: if packet.flags & Packet.PacketFlags.LAST:
AsyncRunner.spawn( AsyncRunner.spawn(
self.packet_io.send_packet( self.packet_io.send_packet(
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
) )
) )
logging.info(color('@@@ Received last packet', 'green')) logging.info(color('@@@ Received last packet', 'green'))
self.jitter_stats.show_stats()
if not self.linger: if not self.linger:
self.done.set() self.done.set()
@@ -479,25 +587,32 @@ class Ping:
await asyncio.sleep(self.tx_start_delay) await asyncio.sleep(self.tx_start_delay)
logging.info(color('=== Sending RESET', 'magenta')) logging.info(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET])) await self.packet_io.send_packet(bytes(Packet(Packet.PacketType.RESET)))
packet_interval = self.pace / 1000
start_time = time.time() start_time = time.time()
self.next_expected_packet_index = 0 self.next_expected_packet_index = 0
for i in range(self.tx_packet_count): for i in range(self.tx_packet_count):
target_time = start_time + (i * packet_interval) target_time = start_time + (i * self.pace / 1000)
now = time.time() now = time.time()
if now < target_time: if now < target_time:
await asyncio.sleep(target_time - now) await asyncio.sleep(target_time - now)
now = time.time()
packet = struct.pack( packet = bytes(
'>bbI', Packet(
PacketType.SEQUENCE, packet_type=Packet.PacketType.SEQUENCE,
(PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0), flags=(
i, Packet.PacketFlags.LAST
) + bytes(self.tx_packet_size - 6) if i == self.tx_packet_count - 1
else 0
),
sequence=i,
timestamp=int((now - start_time) * 1000000),
payload=bytes(self.tx_packet_size - 10),
)
)
logging.info(color(f'Sending packet {i}', 'yellow')) logging.info(color(f'Sending packet {i}', 'yellow'))
self.ping_times.append(time.time()) self.ping_times.append(now)
await self.packet_io.send_packet(packet) await self.packet_io.send_packet(packet)
await self.done.wait() await self.done.wait()
@@ -531,40 +646,35 @@ class Ping:
if self.repeat: if self.repeat:
logging.info(color('--- End of runs', 'blue')) logging.info(color('--- End of runs', 'blue'))
def on_packet_received(self, packet): def on_packet_received(self, data):
try: try:
packet_type, packet_data = parse_packet(packet) packet = Packet.from_bytes(data)
except ValueError: except ValueError:
return return
try: if packet.packet_type == Packet.PacketType.ACK:
packet_flags, packet_index = parse_packet_sequence(packet_data) elapsed = time.time() - self.ping_times[packet.sequence]
except ValueError:
return
if packet_type == PacketType.ACK:
elapsed = time.time() - self.ping_times[packet_index]
rtt = elapsed * 1000 rtt = elapsed * 1000
self.rtts.append(rtt) self.rtts.append(rtt)
logging.info( logging.info(
color( color(
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms', f'<<< Received ACK [{packet.sequence}], RTT={rtt:.2f}ms',
'green', 'green',
) )
) )
if packet_index == self.next_expected_packet_index: if packet.sequence == self.next_expected_packet_index:
self.next_expected_packet_index += 1 self.next_expected_packet_index += 1
else: else:
logging.info( logging.info(
color( color(
f'!!! Unexpected packet, ' f'!!! Unexpected packet, '
f'expected {self.next_expected_packet_index} ' f'expected {self.next_expected_packet_index} '
f'but received {packet_index}' f'but received {packet.sequence}'
) )
) )
if packet_flags & PACKET_FLAG_LAST: if packet.flags & Packet.PacketFlags.LAST:
self.done.set() self.done.set()
return return
@@ -576,89 +686,56 @@ class Pong:
expected_packet_index: int expected_packet_index: int
def __init__(self, packet_io, linger): def __init__(self, packet_io, linger):
self.reset() self.jitter_stats = JitterStats()
self.packet_io = packet_io self.packet_io = packet_io
self.packet_io.packet_listener = self self.packet_io.packet_listener = self
self.linger = linger self.linger = linger
self.done = asyncio.Event() self.done = asyncio.Event()
self.reset()
def reset(self): def reset(self):
self.expected_packet_index = 0 self.expected_packet_index = 0
self.receive_times = [] self.jitter_stats.reset()
def on_packet_received(self, packet):
self.receive_times.append(time.time())
def on_packet_received(self, data):
try: try:
packet_type, packet_data = parse_packet(packet) packet = Packet.from_bytes(data)
except ValueError: except ValueError:
return return
if packet_type == PacketType.RESET: if packet.packet_type == Packet.PacketType.RESET:
logging.info(color('=== Received RESET', 'magenta')) logging.info(color('=== Received RESET', 'magenta'))
self.reset() self.reset()
return return
try: jitter = self.jitter_stats.on_packet_received(packet)
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
interval = (
self.receive_times[-1] - self.receive_times[-2]
if len(self.receive_times) >= 2
else 0
)
logging.info( logging.info(
color( color(
f'<<< Received packet {packet_index}: ' f'<<< Received packet {packet.sequence}: '
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, ' f'flags={packet.flags}, {len(data)} bytes, '
f'interval={interval:.4f}', f'jitter={jitter:.4f}',
'green', 'green',
) )
) )
if packet_index != self.expected_packet_index: if packet.sequence != self.expected_packet_index:
logging.info( logging.info(
color( color(
f'!!! Unexpected packet, expected {self.expected_packet_index} ' f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}' f'but received {packet.sequence}'
) )
) )
self.expected_packet_index = packet_index + 1 self.expected_packet_index = packet.sequence + 1
AsyncRunner.spawn( AsyncRunner.spawn(
self.packet_io.send_packet( self.packet_io.send_packet(
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
) )
) )
if packet_flags & PACKET_FLAG_LAST: if packet.flags & Packet.PacketFlags.LAST:
if len(self.receive_times) >= 3: self.jitter_stats.show_stats()
# Show basic stats
intervals = [
self.receive_times[i + 1] - self.receive_times[i]
for i in range(len(self.receive_times) - 1)
]
log_stats('Packet intervals', intervals, 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(intervals)
interval_max = max(intervals)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count)
for i in range(bin_count)
]
for interval in intervals:
for i in reversed(range(bin_count)):
if interval >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
if not self.linger: if not self.linger:
self.done.set() self.done.set()
@@ -1471,7 +1548,7 @@ def create_mode_factory(ctx, default_mode):
def create_scenario_factory(ctx, default_scenario): def create_scenario_factory(ctx, default_scenario):
scenario = ctx.obj['scenario'] scenario = ctx.obj['scenario']
if scenario is None: if scenario is None:
scenarion = default_scenario scenario = default_scenario
def create_scenario(packet_io): def create_scenario(packet_io):
if scenario == 'send': if scenario == 'send':
@@ -1530,6 +1607,7 @@ def create_scenario_factory(ctx, default_scenario):
'--att-mtu', '--att-mtu',
metavar='MTU', metavar='MTU',
type=click.IntRange(23, 517), type=click.IntRange(23, 517),
default=517,
help='GATT MTU (gatt-client mode)', help='GATT MTU (gatt-client mode)',
) )
@click.option( @click.option(
@@ -1605,7 +1683,7 @@ def create_scenario_factory(ctx, default_scenario):
'--packet-size', '--packet-size',
'-s', '-s',
metavar='SIZE', metavar='SIZE',
type=click.IntRange(8, 8192), type=click.IntRange(10, 8192),
default=500, default=500,
help='Packet size (send or ping scenario)', help='Packet size (send or ping scenario)',
) )

View File

@@ -2475,7 +2475,7 @@ class Device(CompositeEventEmitter):
if self.random_address != hci.Address.ANY_RANDOM: if self.random_address != hci.Address.ANY_RANDOM:
logger.debug( logger.debug(
color( color(
f'LE Random hci.Address: {self.random_address}', f'LE Random Address: {self.random_address}',
'yellow', 'yellow',
) )
) )

View File

@@ -28,7 +28,7 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
def test_l2cap_client_ping(self): def test_l2cap_client_ping(self):
runner = self.dut.bench.runL2capClient( runner = self.dut.bench.runL2capClient(
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100 "ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100, "HIGH"
) )
print("### Initial status:", runner) print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"]) final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
@@ -36,12 +36,34 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
def test_l2cap_client_send(self): def test_l2cap_client_send(self):
runner = self.dut.bench.runL2capClient( runner = self.dut.bench.runL2capClient(
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0 "send",
"F1:F1:F1:F1:F1:F1",
128,
True,
100,
970,
0,
"HIGH",
10000,
) )
print("### Initial status:", runner) print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"]) final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status) print("### Final status:", final_status)
def test_gatt_client_send(self):
runner = self.dut.bench.runGattClient(
"send", "F1:F1:F1:F1:F1:F1", 128, True, 100, 970, 100, "HIGH"
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_gatt_server_receive(self):
runner = self.dut.bench.runGattServer("receive")
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
if __name__ == "__main__": if __name__ == "__main__":
test_runner.main() test_runner.main()

View File

@@ -2,8 +2,8 @@ TestBeds:
- Name: BenchTestBed - Name: BenchTestBed
Controllers: Controllers:
AndroidDevice: AndroidDevice:
- serial: 37211FDJG000DJ - serial: emulator-5554
local_bt_address: 94:45:60:5E:03:B0 local_bt_address: 94:45:60:5E:03:B0
- serial: 23071FDEE001F7 #- serial: 23071FDEE001F7
local_bt_address: DC:E5:5B:E5:51:2C # local_bt_address: DC:E5:5B:E5:51:2C

View File

@@ -10,7 +10,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.github.google.bumble.btbench" applicationId = "com.github.google.bumble.btbench"
minSdk = 30 minSdk = 33
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.google.bumble.btbench"> package="com.github.google.bumble.btbench">
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="33" android:targetSdkVersion="34" />
<!-- Request legacy Bluetooth permissions on older devices. --> <!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />

View File

@@ -0,0 +1,39 @@
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
import android.os.Build
import java.util.logging.Logger
private val Log = Logger.getLogger("btbench.advertiser")
class Advertiser(private val bluetoothAdapter: BluetoothAdapter) : AdvertiseCallback() {
@SuppressLint("MissingPermission")
fun start() {
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
advertiseSettingsBuilder.setDiscoverable(true)
val advertiseSettings = advertiseSettingsBuilder.build()
val advertiseData = AdvertiseData.Builder().build()
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
bluetoothAdapter.bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, scanData, this)
}
@SuppressLint("MissingPermission")
fun stop() {
bluetoothAdapter.bluetoothLeAdvertiser.stopAdvertising(this)
}
override fun onStartFailure(errorCode: Int) {
Log.warning("failed to start advertising: $errorCode")
}
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.info("advertising started: $settingsInEffect")
}
}

View File

@@ -22,11 +22,13 @@ import androidx.test.core.app.ApplicationProvider;
import com.google.android.mobly.snippet.Snippet; import com.google.android.mobly.snippet.Snippet;
import com.google.android.mobly.snippet.rpc.Rpc; import com.google.android.mobly.snippet.rpc.Rpc;
import com.google.android.mobly.snippet.rpc.RpcOptional;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException;
import java.security.InvalidParameterException; import java.security.InvalidParameterException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.UUID; import java.util.UUID;
@@ -71,12 +73,15 @@ public class AutomationSnippet implements Snippet {
private final Context mContext; private final Context mContext;
private final ArrayList<Runner> mRunners = new ArrayList<>(); private final ArrayList<Runner> mRunners = new ArrayList<>();
public AutomationSnippet() { public AutomationSnippet() throws IOException {
mContext = ApplicationProvider.getApplicationContext(); mContext = ApplicationProvider.getApplicationContext();
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class); BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
mBluetoothAdapter = bluetoothManager.getAdapter(); mBluetoothAdapter = bluetoothManager.getAdapter();
if (mBluetoothAdapter == null) { if (mBluetoothAdapter == null) {
throw new RuntimeException("bluetooth not supported"); throw new IOException("bluetooth not supported");
}
if (!mBluetoothAdapter.isEnabled()) {
throw new IOException("bluetooth not enabled");
} }
} }
@@ -85,32 +90,46 @@ public class AutomationSnippet implements Snippet {
switch (mode) { switch (mode) {
case "rfcomm-client": case "rfcomm-client":
runnable = new RfcommClient(model, mBluetoothAdapter, runnable = new RfcommClient(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario, (PacketIO packetIO) -> createIoClient(model, scenario,
packetIO)); packetIO));
break; break;
case "rfcomm-server": case "rfcomm-server":
runnable = new RfcommServer(model, mBluetoothAdapter, runnable = new RfcommServer(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario, (PacketIO packetIO) -> createIoClient(model, scenario,
packetIO)); packetIO));
break; break;
case "l2cap-client": case "l2cap-client":
runnable = new L2capClient(model, mBluetoothAdapter, mContext, runnable = new L2capClient(model, mBluetoothAdapter, mContext,
(PacketIO packetIO) -> createIoClient(model, scenario, (PacketIO packetIO) -> createIoClient(model, scenario,
packetIO)); packetIO));
break; break;
case "l2cap-server": case "l2cap-server":
runnable = new L2capServer(model, mBluetoothAdapter, runnable = new L2capServer(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario, (PacketIO packetIO) -> createIoClient(model, scenario,
packetIO)); packetIO));
break;
case "gatt-client":
runnable = new GattClient(model, mBluetoothAdapter, mContext,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break;
case "gatt-server":
runnable = new GattServer(model, mBluetoothAdapter, mContext,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break; break;
default: default:
return null; return null;
} }
model.setMode(mode);
model.setScenario(scenario);
runnable.run(); runnable.run();
Runner runner = new Runner(runnable, mode, scenario, model); Runner runner = new Runner(runnable, mode, scenario, model);
mRunners.add(runner); mRunners.add(runner);
@@ -140,7 +159,21 @@ public class AutomationSnippet implements Snippet {
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
result.put("status", model.getStatus()); result.put("status", model.getStatus());
result.put("running", model.getRunning()); result.put("running", model.getRunning());
result.put("peer_bluetooth_address", model.getPeerBluetoothAddress());
result.put("mode", model.getMode());
result.put("scenario", model.getScenario());
result.put("sender_packet_size", model.getSenderPacketSize());
result.put("sender_packet_count", model.getSenderPacketCount());
result.put("sender_packet_interval", model.getSenderPacketInterval());
result.put("packets_sent", model.getPacketsSent());
result.put("packets_received", model.getPacketsReceived());
result.put("l2cap_psm", model.getL2capPsm()); result.put("l2cap_psm", model.getL2capPsm());
result.put("use_2m_phy", model.getUse2mPhy());
result.put("connection_priority", model.getConnectionPriority());
result.put("mtu", model.getMtu());
result.put("rx_phy", model.getRxPhy());
result.put("tx_phy", model.getTxPhy());
result.put("startup_delay", model.getStartupDelay());
if (model.getStatus().equals("OK")) { if (model.getStatus().equals("OK")) {
JSONObject stats = new JSONObject(); JSONObject stats = new JSONObject();
result.put("stats", stats); result.put("stats", stats);
@@ -167,12 +200,12 @@ public class AutomationSnippet implements Snippet {
@Rpc(description = "Run a scenario in RFComm Client mode") @Rpc(description = "Run a scenario in RFComm Client mode")
public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount, public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount,
int packetSize, int packetInterval) throws JSONException { int packetSize, int packetInterval,
assert (mBluetoothAdapter != null); @RpcOptional Integer startupDelay) throws JSONException {
// We only support "send" and "ping" for this mode for now // We only support "send" and "ping" for this mode for now
if (!(scenario.equals("send") || scenario.equals("ping"))) { if (!(scenario.equals("send") || scenario.equals("ping"))) {
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode"); throw new InvalidParameterException(
"only 'send' and 'ping' are supported for this mode");
} }
AppViewModel model = new AppViewModel(); AppViewModel model = new AppViewModel();
@@ -180,6 +213,9 @@ public class AutomationSnippet implements Snippet {
model.setSenderPacketCount(packetCount); model.setSenderPacketCount(packetCount);
model.setSenderPacketSize(packetSize); model.setSenderPacketSize(packetSize);
model.setSenderPacketInterval(packetInterval); model.setSenderPacketInterval(packetInterval);
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "rfcomm-client", scenario); Runner runner = runScenario(model, "rfcomm-client", scenario);
assert runner != null; assert runner != null;
@@ -187,15 +223,18 @@ public class AutomationSnippet implements Snippet {
} }
@Rpc(description = "Run a scenario in RFComm Server mode") @Rpc(description = "Run a scenario in RFComm Server mode")
public JSONObject runRfcommServer(String scenario) throws JSONException { public JSONObject runRfcommServer(String scenario,
assert (mBluetoothAdapter != null); @RpcOptional Integer startupDelay) throws JSONException {
// We only support "receive" and "pong" for this mode for now // We only support "receive" and "pong" for this mode for now
if (!(scenario.equals("receive") || scenario.equals("pong"))) { if (!(scenario.equals("receive") || scenario.equals("pong"))) {
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode"); throw new InvalidParameterException(
"only 'receive' and 'pong' are supported for this mode");
} }
AppViewModel model = new AppViewModel(); AppViewModel model = new AppViewModel();
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "rfcomm-server", scenario); Runner runner = runScenario(model, "rfcomm-server", scenario);
assert runner != null; assert runner != null;
@@ -205,12 +244,12 @@ public class AutomationSnippet implements Snippet {
@Rpc(description = "Run a scenario in L2CAP Client mode") @Rpc(description = "Run a scenario in L2CAP Client mode")
public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm, public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm,
boolean use_2m_phy, int packetCount, int packetSize, boolean use_2m_phy, int packetCount, int packetSize,
int packetInterval) throws JSONException { int packetInterval, @RpcOptional String connectionPriority,
assert (mBluetoothAdapter != null); @RpcOptional Integer startupDelay) throws JSONException {
// We only support "send" and "ping" for this mode for now // We only support "send" and "ping" for this mode for now
if (!(scenario.equals("send") || scenario.equals("ping"))) { if (!(scenario.equals("send") || scenario.equals("ping"))) {
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode"); throw new InvalidParameterException(
"only 'send' and 'ping' are supported for this mode");
} }
AppViewModel model = new AppViewModel(); AppViewModel model = new AppViewModel();
@@ -220,28 +259,83 @@ public class AutomationSnippet implements Snippet {
model.setSenderPacketCount(packetCount); model.setSenderPacketCount(packetCount);
model.setSenderPacketSize(packetSize); model.setSenderPacketSize(packetSize);
model.setSenderPacketInterval(packetInterval); model.setSenderPacketInterval(packetInterval);
if (connectionPriority != null) {
model.setConnectionPriority(connectionPriority);
}
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "l2cap-client", scenario); Runner runner = runScenario(model, "l2cap-client", scenario);
assert runner != null; assert runner != null;
return runner.toJson(); return runner.toJson();
} }
@Rpc(description = "Run a scenario in L2CAP Server mode") @Rpc(description = "Run a scenario in L2CAP Server mode")
public JSONObject runL2capServer(String scenario) throws JSONException { public JSONObject runL2capServer(String scenario,
assert (mBluetoothAdapter != null); @RpcOptional Integer startupDelay) throws JSONException {
// We only support "receive" and "pong" for this mode for now // We only support "receive" and "pong" for this mode for now
if (!(scenario.equals("receive") || scenario.equals("pong"))) { if (!(scenario.equals("receive") || scenario.equals("pong"))) {
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode"); throw new InvalidParameterException(
"only 'receive' and 'pong' are supported for this mode");
} }
AppViewModel model = new AppViewModel(); AppViewModel model = new AppViewModel();
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "l2cap-server", scenario); Runner runner = runScenario(model, "l2cap-server", scenario);
assert runner != null; assert runner != null;
return runner.toJson(); return runner.toJson();
} }
@Rpc(description = "Run a scenario in GATT Client mode")
public JSONObject runGattClient(String scenario, String peerBluetoothAddress,
boolean use_2m_phy, int packetCount, int packetSize,
int packetInterval, @RpcOptional String connectionPriority,
@RpcOptional Integer startupDelay) throws JSONException {
// We only support "send" and "ping" for this mode for now
if (!(scenario.equals("send") || scenario.equals("ping"))) {
throw new InvalidParameterException(
"only 'send' and 'ping' are supported for this mode");
}
AppViewModel model = new AppViewModel();
model.setPeerBluetoothAddress(peerBluetoothAddress);
model.setUse2mPhy(use_2m_phy);
model.setSenderPacketCount(packetCount);
model.setSenderPacketSize(packetSize);
model.setSenderPacketInterval(packetInterval);
if (connectionPriority != null) {
model.setConnectionPriority(connectionPriority);
}
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "gatt-client", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Run a scenario in GATT Server mode")
public JSONObject runGattServer(String scenario,
@RpcOptional Integer startupDelay) throws JSONException {
// We only support "receive" and "pong" for this mode for now
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
throw new InvalidParameterException(
"only 'receive' and 'pong' are supported for this mode");
}
AppViewModel model = new AppViewModel();
if (startupDelay != null) {
model.setStartupDelay(startupDelay);
}
Runner runner = runScenario(model, "gatt-server", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Stop a Runner") @Rpc(description = "Stop a Runner")
public JSONObject stopRunner(String runnerId) throws JSONException { public JSONObject stopRunner(String runnerId) throws JSONException {
Runner runner = findRunner(runnerId); Runner runner = findRunner(runnerId);
@@ -276,7 +370,7 @@ public class AutomationSnippet implements Snippet {
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
JSONArray runners = new JSONArray(); JSONArray runners = new JSONArray();
result.put("runners", runners); result.put("runners", runners);
for (Runner runner: mRunners) { for (Runner runner : mRunners) {
runners.put(runner.toJson()); runners.put(runner.toJson());
} }

View File

@@ -0,0 +1,109 @@
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Build
import androidx.core.content.ContextCompat
import java.util.logging.Logger
private val Log = Logger.getLogger("btbench.connection")
open class Connection(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
private val context: Context
) : BluetoothGattCallback() {
var remoteDevice: BluetoothDevice? = null
var gatt: BluetoothGatt? = null
@SuppressLint("MissingPermission")
open fun connect() {
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
val address = viewModel.peerBluetoothAddress.take(17)
remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bluetoothAdapter.getRemoteLeDevice(
address,
if (addressIsPublic) {
BluetoothDevice.ADDRESS_TYPE_PUBLIC
} else {
BluetoothDevice.ADDRESS_TYPE_RANDOM
}
)
} else {
bluetoothAdapter.getRemoteDevice(address)
}
gatt = remoteDevice?.connectGatt(
context,
false,
this,
BluetoothDevice.TRANSPORT_LE,
if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
)
}
@SuppressLint("MissingPermission")
open fun disconnect() {
gatt?.disconnect()
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.info("MTU update: mtu=$mtu status=$status")
viewModel.mtu = mtu
}
override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
gatt: BluetoothGatt?, status: Int, newState: Int
) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.warning("onConnectionStateChange status=$status")
}
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
if (viewModel.use2mPhy) {
Log.info("requesting 2M PHY")
gatt.setPreferredPhy(
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_OPTION_NO_PREFERRED
)
}
gatt.readPhy()
// Request an MTU update, even though we don't use GATT, because Android
// won't request a larger link layer maximum data length otherwise.
gatt.requestMtu(517)
// Request a specific connection priority
val connectionPriority = when (viewModel.connectionPriority) {
"BALANCED" -> BluetoothGatt.CONNECTION_PRIORITY_BALANCED
"LOW_POWER" -> BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER
"HIGH" -> BluetoothGatt.CONNECTION_PRIORITY_HIGH
"DCK" -> BluetoothGatt.CONNECTION_PRIORITY_DCK
else -> 0
}
if (!gatt.requestConnectionPriority(connectionPriority)) {
Log.warning("requestConnectionPriority failed")
}
}
}
}

View File

@@ -0,0 +1,23 @@
// 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.
package com.github.google.bumble.btbench
import java.util.UUID
var CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
val BENCH_SERVICE_UUID = UUID.fromString("50DB505C-8AC4-4738-8448-3B1D9CC09CC5")
val BENCH_TX_UUID = UUID.fromString("E789C754-41A1-45F4-A948-A0A1A90DBA53")
val BENCH_RX_UUID = UUID.fromString("016A2CC7-E14B-4819-935F-1F56EAE4098D")

View File

@@ -0,0 +1,224 @@
// 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.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.content.Context
import java.io.IOException
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Semaphore
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.gatt-client")
class GattClientConnection(
viewModel: AppViewModel,
bluetoothAdapter: BluetoothAdapter,
context: Context
) : Connection(viewModel, bluetoothAdapter, context), PacketIO {
override var packetSink: PacketSink? = null
private val discoveryDone: CountDownLatch = CountDownLatch(1)
private val writeSemaphore: Semaphore = Semaphore(1)
var rxCharacteristic: BluetoothGattCharacteristic? = null
var txCharacteristic: BluetoothGattCharacteristic? = null
override fun connect() {
super.connect()
// Check if we're already connected and have discovered the services
if (gatt?.getService(BENCH_SERVICE_UUID) != null) {
Log.fine("already connected")
onServicesDiscovered(gatt, BluetoothGatt.GATT_SUCCESS)
}
}
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
gatt: BluetoothGatt?, status: Int, newState: Int
) {
super.onConnectionStateChange(gatt, status, newState)
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.warning("onConnectionStateChange status=$status")
discoveryDone.countDown()
return
}
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
if (!gatt.discoverServices()) {
Log.warning("discoverServices could not start")
discoveryDone.countDown()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
Log.fine("onServicesDiscovered")
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.warning("failed to discover services: ${status}")
discoveryDone.countDown()
return
}
// Find the service
val service = gatt!!.getService(BENCH_SERVICE_UUID)
if (service == null) {
Log.warning("GATT Service not found")
discoveryDone.countDown()
return
}
// Find the RX and TX characteristics
rxCharacteristic = service.getCharacteristic(BENCH_RX_UUID)
if (rxCharacteristic == null) {
Log.warning("GATT RX Characteristics not found")
discoveryDone.countDown()
return
}
txCharacteristic = service.getCharacteristic(BENCH_TX_UUID)
if (txCharacteristic == null) {
Log.warning("GATT TX Characteristics not found")
discoveryDone.countDown()
return
}
// Subscribe to the RX characteristic
Log.fine("subscribing to RX")
gatt.setCharacteristicNotification(rxCharacteristic, true)
val cccdDescriptor = rxCharacteristic!!.getDescriptor(CCCD_UUID)
gatt.writeDescriptor(cccdDescriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
Log.info("GATT discovery complete")
discoveryDone.countDown()
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
// Now we can write again
writeSemaphore.release()
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.warning("onCharacteristicWrite failed: $status")
return
}
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
if (characteristic.uuid == BENCH_RX_UUID && packetSink != null) {
val packet = Packet.from(value)
packetSink!!.onPacket(packet)
}
}
@SuppressLint("MissingPermission")
override fun sendPacket(packet: Packet) {
if (txCharacteristic == null) {
Log.warning("No TX characteristic, dropping")
return
}
// Wait until we can write
writeSemaphore.acquire()
// Write the data
val data = packet.toBytes()
val clampedData = if (data.size > 512) {
// Clamp the data to the maximum allowed characteristic data size
data.copyOf(512)
} else {
data
}
gatt?.writeCharacteristic(
txCharacteristic!!,
clampedData,
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
)
}
override
fun disconnect() {
super.disconnect()
discoveryDone.countDown()
}
fun waitForDiscoveryCompletion() {
discoveryDone.await()
}
}
class GattClient(
private val viewModel: AppViewModel,
bluetoothAdapter: BluetoothAdapter,
context: Context,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode {
private var connection: GattClientConnection =
GattClientConnection(viewModel, bluetoothAdapter, context)
private var clientThread: Thread? = null
@SuppressLint("MissingPermission")
override fun run() {
viewModel.running = true
clientThread = thread(name = "GattClient") {
connection.connect()
viewModel.aborter = {
connection.disconnect()
}
// Discover the rx and tx characteristics
connection.waitForDiscoveryCompletion()
if (connection.rxCharacteristic == null || connection.txCharacteristic == null) {
connection.disconnect()
viewModel.running = false
return@thread
}
val ioClient = createIoClient(connection)
try {
ioClient.run()
viewModel.status = "OK"
} catch (error: IOException) {
Log.info("run ended abruptly")
viewModel.status = "ABORTED"
viewModel.lastError = "IO_ERROR"
} finally {
connection.disconnect()
viewModel.running = false
}
}
}
override fun waitForCompletion() {
clientThread?.join()
}
}

View File

@@ -0,0 +1,243 @@
// 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.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import androidx.core.content.ContextCompat
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.Semaphore
import java.util.logging.Logger
import kotlin.concurrent.thread
import kotlin.experimental.and
private val Log = Logger.getLogger("btbench.gatt-server")
@SuppressLint("MissingPermission")
class GattServer(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
context: Context,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode, PacketIO, BluetoothGattServerCallback() {
override var packetSink: PacketSink? = null
private val gattServer: BluetoothGattServer
private val rxCharacteristic: BluetoothGattCharacteristic?
private val txCharacteristic: BluetoothGattCharacteristic?
private val notifySemaphore: Semaphore = Semaphore(1)
private val ready: CountDownLatch = CountDownLatch(1)
private var peerDevice: BluetoothDevice? = null
private var clientThread: Thread? = null
private var sinkQueue: LinkedBlockingQueue<Packet>? = null
init {
val bluetoothManager = ContextCompat.getSystemService(context, BluetoothManager::class.java)
gattServer = bluetoothManager!!.openGattServer(context, this)
val benchService = gattServer.getService(BENCH_SERVICE_UUID)
if (benchService == null) {
rxCharacteristic = BluetoothGattCharacteristic(
BENCH_RX_UUID,
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
0
)
txCharacteristic = BluetoothGattCharacteristic(
BENCH_TX_UUID,
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
BluetoothGattCharacteristic.PERMISSION_WRITE
)
val rxCCCD = BluetoothGattDescriptor(
CCCD_UUID,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
rxCharacteristic.addDescriptor(rxCCCD)
val service =
BluetoothGattService(BENCH_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(rxCharacteristic)
service.addCharacteristic(txCharacteristic)
gattServer.addService(service)
} else {
rxCharacteristic = benchService.getCharacteristic(BENCH_RX_UUID)
txCharacteristic = benchService.getCharacteristic(BENCH_TX_UUID)
}
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice?,
requestId: Int,
characteristic: BluetoothGattCharacteristic?,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray?
) {
Log.info("onCharacteristicWriteRequest")
if (characteristic != null && characteristic.uuid == BENCH_TX_UUID) {
if (packetSink == null) {
Log.warning("no sink, dropping")
} else if (offset != 0) {
Log.warning("offset != 0")
} else if (value == null) {
Log.warning("no value")
} else {
// Deliver the packet in a separate thread so that we don't block this
// callback.
sinkQueue?.put(Packet.from(value))
}
}
if (responseNeeded) {
gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
}
}
override fun onNotificationSent(device: BluetoothDevice?, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
notifySemaphore.release()
}
}
override fun onDescriptorWriteRequest(
device: BluetoothDevice?,
requestId: Int,
descriptor: BluetoothGattDescriptor?,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray?
) {
if (descriptor?.uuid == CCCD_UUID && descriptor?.characteristic?.uuid == BENCH_RX_UUID) {
if (offset == 0 && value?.size == 2) {
if (value[0].and(1).toInt() != 0) {
// Subscription
Log.fine("peer subscribed to RX")
peerDevice = device
ready.countDown()
}
}
}
if (responseNeeded) {
gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
}
}
@SuppressLint("MissingPermission")
override fun sendPacket(packet: Packet) {
if (peerDevice == null) {
Log.warning("no peer device, cannot send")
return
}
if (rxCharacteristic == null) {
Log.warning("no RX characteristic, cannot send")
return
}
// Wait until we can notify
notifySemaphore.acquire()
// Send the packet via a notification
val result = gattServer.notifyCharacteristicChanged(
peerDevice!!,
rxCharacteristic,
false,
packet.toBytes()
)
if (result != BluetoothStatusCodes.SUCCESS) {
Log.warning("notifyCharacteristicChanged failed: $result")
notifySemaphore.release()
}
}
override fun run() {
viewModel.running = true
// Start advertising
Log.fine("starting advertiser")
val advertiser = Advertiser(bluetoothAdapter)
advertiser.start()
clientThread = thread(name = "GattServer") {
// Wait for a subscriber
Log.info("waiting for RX subscriber")
viewModel.aborter = {
ready.countDown()
}
ready.await()
if (peerDevice == null) {
Log.warning("server interrupted")
viewModel.running = false
gattServer.close()
return@thread
}
Log.info("RX subscriber accepted")
// Stop advertising
Log.info("stopping advertiser")
advertiser.stop()
sinkQueue = LinkedBlockingQueue()
val sinkWriterThread = thread(name = "SinkWriter") {
while (true) {
try {
val packet = sinkQueue!!.take()
if (packetSink == null) {
Log.warning("no sink, dropping packet")
continue
}
packetSink!!.onPacket(packet)
} catch (error: InterruptedException) {
Log.warning("sink writer interrupted")
break
}
}
}
val ioClient = createIoClient(this)
try {
ioClient.run()
viewModel.status = "OK"
} catch (error: IOException) {
Log.info("run ended abruptly")
viewModel.status = "ABORTED"
viewModel.lastError = "IO_ERROR"
} finally {
sinkWriterThread.interrupt()
sinkWriterThread.join()
gattServer.close()
viewModel.running = false
}
}
}
override fun waitForCompletion() {
clientThread?.join()
Log.info("server thread completed")
}
}

View File

@@ -16,89 +16,25 @@ package com.github.google.bumble.btbench
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothProfile
import android.content.Context import android.content.Context
import android.os.Build
import java.util.logging.Logger import java.util.logging.Logger
private val Log = Logger.getLogger("btbench.l2cap-client") private val Log = Logger.getLogger("btbench.l2cap-client")
class L2capClient( class L2capClient(
private val viewModel: AppViewModel, private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter, bluetoothAdapter: BluetoothAdapter,
private val context: Context, context: Context,
private val createIoClient: (packetIo: PacketIO) -> IoClient private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode { ) : Mode {
private var connection: Connection = Connection(viewModel, bluetoothAdapter, context)
private var socketClient: SocketClient? = null private var socketClient: SocketClient? = null
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun run() { override fun run() {
viewModel.running = true viewModel.running = true
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P") connection.connect()
val address = viewModel.peerBluetoothAddress.take(17) val socket = connection.remoteDevice!!.createInsecureL2capChannel(viewModel.l2capPsm)
val remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bluetoothAdapter.getRemoteLeDevice(
address,
if (addressIsPublic) {
BluetoothDevice.ADDRESS_TYPE_PUBLIC
} else {
BluetoothDevice.ADDRESS_TYPE_RANDOM
}
)
} else {
bluetoothAdapter.getRemoteDevice(address)
}
val gatt = remoteDevice.connectGatt(
context,
false,
object : BluetoothGattCallback() {
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.info("MTU update: mtu=$mtu status=$status")
viewModel.mtu = mtu
}
override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
override fun onConnectionStateChange(
gatt: BluetoothGatt?, status: Int, newState: Int
) {
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
if (viewModel.use2mPhy) {
Log.info("requesting 2M PHY")
gatt.setPreferredPhy(
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_OPTION_NO_PREFERRED
)
}
gatt.readPhy()
// Request an MTU update, even though we don't use GATT, because Android
// won't request a larger link layer maximum data length otherwise.
gatt.requestMtu(517)
}
}
},
BluetoothDevice.TRANSPORT_LE,
if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
)
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
socketClient = SocketClient(viewModel, socket, createIoClient) socketClient = SocketClient(viewModel, socket, createIoClient)
socketClient!!.run() socketClient!!.run()
} }

View File

@@ -37,34 +37,15 @@ class L2capServer(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun run() { override fun run() {
// Advertise so that the peer can find us and connect. // Advertise so that the peer can find us and connect.
val callback = object : AdvertiseCallback() { val advertiser = Advertiser(bluetoothAdapter)
override fun onStartFailure(errorCode: Int) {
Log.warning("failed to start advertising: $errorCode")
}
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.info("advertising started: $settingsInEffect")
}
}
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
advertiseSettingsBuilder.setDiscoverable(true)
}
val advertiseSettings = advertiseSettingsBuilder.build()
val advertiseData = AdvertiseData.Builder().build()
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel() val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
viewModel.l2capPsm = serverSocket.psm viewModel.l2capPsm = serverSocket.psm
Log.info("psm = $serverSocket.psm") Log.info("psm = $serverSocket.psm")
socketServer = SocketServer(viewModel, serverSocket, createIoClient) socketServer = SocketServer(viewModel, serverSocket, createIoClient)
socketServer!!.run( socketServer!!.run(
{ advertiser.stopAdvertising(callback) }, { advertiser.stop() },
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) } { advertiser.start() }
) )
} }

View File

@@ -17,9 +17,12 @@ package com.github.google.bumble.btbench
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -66,6 +69,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.github.google.bumble.btbench.ui.theme.BTBenchTheme import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
import java.io.IOException
import java.util.logging.Logger import java.util.logging.Logger
private val Log = Logger.getLogger("bumble.main-activity") private val Log = Logger.getLogger("bumble.main-activity")
@@ -76,6 +80,7 @@ const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
const val SENDER_PACKET_INTERVAL_PREF_KEY = "sender_packet_interval" const val SENDER_PACKET_INTERVAL_PREF_KEY = "sender_packet_interval"
const val SCENARIO_PREF_KEY = "scenario" const val SCENARIO_PREF_KEY = "scenario"
const val MODE_PREF_KEY = "mode" const val MODE_PREF_KEY = "mode"
const val CONNECTION_PRIORITY_PREF_KEY = "connection_priority"
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val appViewModel = AppViewModel() private val appViewModel = AppViewModel()
@@ -84,6 +89,47 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE)) appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
checkPermissions() checkPermissions()
registerReceivers()
}
private fun registerReceivers() {
val pairingRequestIntentFilter = IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST)
registerReceiver(object: BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
Log.info("ACTION_PAIRING_REQUEST")
val extras = intent.extras
if (extras != null) {
for (key in extras.keySet()) {
Log.info("$key: ${extras.get(key)}")
}
}
val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
if (device != null) {
if (checkSelfPermission(Manifest.permission.BLUETOOTH_PRIVILEGED) == PackageManager.PERMISSION_GRANTED) {
Log.info("confirming pairing")
device.setPairingConfirmation(true)
} else {
Log.info("we don't have BLUETOOTH_PRIVILEGED, not confirming")
}
}
}
}, pairingRequestIntentFilter)
val bondStateChangedIntentFilter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
registerReceiver(object: BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
Log.info("ACTION_BOND_STATE_CHANGED")
val extras = intent.extras
if (extras != null) {
for (key in extras.keySet()) {
Log.info("$key: ${extras.get(key)}")
}
}
}
}, bondStateChangedIntentFilter)
} }
private fun checkPermissions() { private fun checkPermissions() {
@@ -144,9 +190,7 @@ class MainActivity : ComponentActivity() {
initBluetooth() initBluetooth()
setContent { setContent {
MainView( MainView(
appViewModel, appViewModel, ::becomeDiscoverable, ::runScenario
::becomeDiscoverable,
::runScenario
) )
} }
@@ -182,6 +226,8 @@ class MainActivity : ComponentActivity() {
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE "rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE "l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE "l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
"gatt-client" -> appViewModel.mode = GATT_CLIENT_MODE
"gatt-server" -> appViewModel.mode = GATT_SERVER_MODE
} }
} }
intent.getStringExtra("autostart")?.let { intent.getStringExtra("autostart")?.let {
@@ -195,19 +241,24 @@ class MainActivity : ComponentActivity() {
private fun runScenario() { private fun runScenario() {
if (bluetoothAdapter == null) { if (bluetoothAdapter == null) {
return throw IOException("bluetooth not enabled")
} }
val runner = when (appViewModel.mode) { val runner = when (appViewModel.mode) {
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient) RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient) RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
L2CAP_CLIENT_MODE -> L2capClient( L2CAP_CLIENT_MODE -> L2capClient(
appViewModel, appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
bluetoothAdapter!!,
baseContext,
::createIoClient
) )
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient) L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
GATT_CLIENT_MODE -> GattClient(
appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
)
GATT_SERVER_MODE -> GattServer(
appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
)
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
runner.run() runner.run()
@@ -281,7 +332,7 @@ fun MainView(
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
}), }),
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE) enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE || appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == GATT_CLIENT_MODE)
) )
Divider() Divider()
TextField( TextField(
@@ -349,24 +400,45 @@ fun MainView(
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
}), }),
enabled = (appViewModel.scenario == PING_SCENARIO) enabled = (appViewModel.scenario == PING_SCENARIO || appViewModel.scenario == SEND_SCENARIO)
) )
Divider() Divider()
ActionButton(
text = "Become Discoverable", onClick = becomeDiscoverable, true
)
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(text = "2M PHY") Text(text = "2M PHY")
Spacer(modifier = Modifier.padding(start = 8.dp)) Spacer(modifier = Modifier.padding(start = 8.dp))
Switch( Switch(enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE || appViewModel.mode == GATT_CLIENT_MODE || appViewModel.mode == GATT_SERVER_MODE),
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
checked = appViewModel.use2mPhy, checked = appViewModel.use2mPhy,
onCheckedChange = { appViewModel.use2mPhy = it } onCheckedChange = { appViewModel.use2mPhy = it })
) Column(Modifier.selectableGroup()) {
listOf(
"BALANCED", "LOW", "HIGH", "DCK"
).forEach { text ->
Row(
Modifier
.selectable(
selected = (text == appViewModel.connectionPriority),
onClick = { appViewModel.updateConnectionPriority(text) },
role = Role.RadioButton,
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == appViewModel.connectionPriority),
onClick = null,
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE || appViewModel.mode == GATT_CLIENT_MODE || appViewModel.mode == GATT_SERVER_MODE)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
} }
Row { Row {
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
@@ -374,7 +446,9 @@ fun MainView(
RFCOMM_CLIENT_MODE, RFCOMM_CLIENT_MODE,
RFCOMM_SERVER_MODE, RFCOMM_SERVER_MODE,
L2CAP_CLIENT_MODE, L2CAP_CLIENT_MODE,
L2CAP_SERVER_MODE L2CAP_SERVER_MODE,
GATT_CLIENT_MODE,
GATT_SERVER_MODE
).forEach { text -> ).forEach { text ->
Row( Row(
Modifier Modifier
@@ -387,8 +461,7 @@ fun MainView(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( RadioButton(
selected = (text == appViewModel.mode), selected = (text == appViewModel.mode), onClick = null
onClick = null
) )
Text( Text(
text = text, text = text,
@@ -400,10 +473,7 @@ fun MainView(
} }
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
listOf( listOf(
SEND_SCENARIO, SEND_SCENARIO, RECEIVE_SCENARIO, PING_SCENARIO, PONG_SCENARIO
RECEIVE_SCENARIO,
PING_SCENARIO,
PONG_SCENARIO
).forEach { text -> ).forEach { text ->
Row( Row(
Modifier Modifier
@@ -416,8 +486,7 @@ fun MainView(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( RadioButton(
selected = (text == appViewModel.scenario), selected = (text == appViewModel.scenario), onClick = null
onClick = null
) )
Text( Text(
text = text, text = text,
@@ -435,20 +504,29 @@ fun MainView(
ActionButton( ActionButton(
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
) )
ActionButton(
text = "Become Discoverable", onClick = becomeDiscoverable, true
)
} }
Divider() Divider()
Text( if (appViewModel.mtu != 0) {
text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else "" Text(
) text = "MTU: ${appViewModel.mtu}"
Text( )
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else "" }
) if (appViewModel.rxPhy != 0) {
Text(
text = "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}"
)
}
Text( Text(
text = "Status: ${appViewModel.status}" text = "Status: ${appViewModel.status}"
) )
Text( if (appViewModel.lastError.isNotEmpty()) {
text = "Last Error: ${appViewModel.lastError}" Text(
) text = "Last Error: ${appViewModel.lastError}"
)
}
Text( Text(
text = "Packets Sent: ${appViewModel.packetsSent}" text = "Packets Sent: ${appViewModel.packetsSent}"
) )

View File

@@ -25,6 +25,7 @@ import java.util.UUID
val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE") val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF" const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
const val DEFAULT_STARTUP_DELAY = 3000
const val DEFAULT_SENDER_PACKET_COUNT = 100 const val DEFAULT_SENDER_PACKET_COUNT = 100
const val DEFAULT_SENDER_PACKET_SIZE = 1024 const val DEFAULT_SENDER_PACKET_SIZE = 1024
const val DEFAULT_SENDER_PACKET_INTERVAL = 100 const val DEFAULT_SENDER_PACKET_INTERVAL = 100
@@ -34,6 +35,8 @@ const val L2CAP_CLIENT_MODE = "L2CAP Client"
const val L2CAP_SERVER_MODE = "L2CAP Server" const val L2CAP_SERVER_MODE = "L2CAP Server"
const val RFCOMM_CLIENT_MODE = "RFCOMM Client" const val RFCOMM_CLIENT_MODE = "RFCOMM Client"
const val RFCOMM_SERVER_MODE = "RFCOMM Server" const val RFCOMM_SERVER_MODE = "RFCOMM Server"
const val GATT_CLIENT_MODE = "GATT Client"
const val GATT_SERVER_MODE = "GATT Server"
const val SEND_SCENARIO = "Send" const val SEND_SCENARIO = "Send"
const val RECEIVE_SCENARIO = "Receive" const val RECEIVE_SCENARIO = "Receive"
@@ -47,8 +50,10 @@ class AppViewModel : ViewModel() {
var mode by mutableStateOf(RFCOMM_SERVER_MODE) var mode by mutableStateOf(RFCOMM_SERVER_MODE)
var scenario by mutableStateOf(RECEIVE_SCENARIO) var scenario by mutableStateOf(RECEIVE_SCENARIO)
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS) var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
var startupDelay by mutableIntStateOf(DEFAULT_STARTUP_DELAY)
var l2capPsm by mutableIntStateOf(DEFAULT_PSM) var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
var use2mPhy by mutableStateOf(true) var use2mPhy by mutableStateOf(true)
var connectionPriority by mutableStateOf("BALANCED")
var mtu by mutableIntStateOf(0) var mtu by mutableIntStateOf(0)
var rxPhy by mutableIntStateOf(0) var rxPhy by mutableIntStateOf(0)
var txPhy by mutableIntStateOf(0) var txPhy by mutableIntStateOf(0)
@@ -98,6 +103,11 @@ class AppViewModel : ViewModel() {
if (savedScenario != null) { if (savedScenario != null) {
scenario = savedScenario scenario = savedScenario
} }
val savedConnectionPriority = preferences.getString(CONNECTION_PRIORITY_PREF_KEY, null)
if (savedConnectionPriority != null) {
connectionPriority = savedConnectionPriority
}
} }
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) { fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
@@ -220,6 +230,14 @@ class AppViewModel : ViewModel() {
} }
} }
fun updateConnectionPriority(connectionPriority: String) {
this.connectionPriority = connectionPriority
with(preferences!!.edit()) {
putString(CONNECTION_PRIORITY_PREF_KEY, connectionPriority)
apply()
}
}
fun clear() { fun clear() {
status = "" status = ""
lastError = "" lastError = ""

View File

@@ -17,6 +17,7 @@ package com.github.google.bumble.btbench
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.logging.Logger import java.util.logging.Logger
import kotlin.math.min import kotlin.math.min
@@ -37,11 +38,16 @@ abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
RESET -> ResetPacket() RESET -> ResetPacket()
SEQUENCE -> SequencePacket( SEQUENCE -> SequencePacket(
data[1].toInt(), data[1].toInt(),
ByteBuffer.wrap(data, 2, 4).getInt(), ByteBuffer.wrap(data, 2, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(),
data.sliceArray(6..<data.size) ByteBuffer.wrap(data, 6, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(),
data.sliceArray(10..<data.size)
)
ACK -> AckPacket(
data[1].toInt(),
ByteBuffer.wrap(data, 2, 4).order(ByteOrder.LITTLE_ENDIAN).getInt()
) )
ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size)) else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
} }
} }
@@ -57,16 +63,24 @@ class ResetPacket : Packet(RESET)
class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) { class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
override fun toBytes(): ByteArray { override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte()) return ByteBuffer.allocate(6).order(
ByteOrder.LITTLE_ENDIAN
).put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).array() .putInt(sequenceNumber).array()
} }
} }
class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) : class SequencePacket(
val flags: Int,
val sequenceNumber: Int,
val timestamp: Int,
payload: ByteArray
) :
Packet(SEQUENCE, payload) { Packet(SEQUENCE, payload) {
override fun toBytes(): ByteArray { override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte()) return ByteBuffer.allocate(10 + payload.size).order(ByteOrder.LITTLE_ENDIAN)
.putInt(sequenceNumber).put(payload).array() .put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).putInt(timestamp).put(payload).array()
} }
} }

View File

@@ -19,8 +19,6 @@ import java.util.logging.Logger
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource import kotlin.time.TimeSource
private const val DEFAULT_STARTUP_DELAY = 3000
private val Log = Logger.getLogger("btbench.pinger") private val Log = Logger.getLogger("btbench.pinger")
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
@@ -36,8 +34,8 @@ class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO
override fun run() { override fun run() {
viewModel.clear() viewModel.clear()
Log.info("startup delay: $DEFAULT_STARTUP_DELAY") Log.info("startup delay: ${viewModel.startupDelay}")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); Thread.sleep(viewModel.startupDelay.toLong());
Log.info("running") Log.info("running")
Log.info("sending reset") Log.info("sending reset")
@@ -48,19 +46,23 @@ class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO
val startTime = TimeSource.Monotonic.markNow() val startTime = TimeSource.Monotonic.markNow()
for (i in 0..<packetCount) { for (i in 0..<packetCount) {
val now = TimeSource.Monotonic.markNow() var now = TimeSource.Monotonic.markNow()
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds if (viewModel.senderPacketInterval > 0) {
val delay = targetTime - now val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
if (delay.isPositive()) { val delay = targetTime - now
Log.info("sleeping ${delay.inWholeMilliseconds} ms") if (delay.isPositive()) {
Thread.sleep(delay.inWholeMilliseconds) Log.info("sleeping ${delay.inWholeMilliseconds} ms")
Thread.sleep(delay.inWholeMilliseconds)
now = TimeSource.Monotonic.markNow()
}
} }
pingTimes.add(TimeSource.Monotonic.markNow()) pingTimes.add(TimeSource.Monotonic.markNow())
packetIO.sendPacket( packetIO.sendPacket(
SequencePacket( SequencePacket(
if (i < packetCount - 1) 0 else Packet.LAST_FLAG, if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
i, i,
ByteArray(packetSize - 6) (now - startTime).inWholeMicroseconds.toInt(),
ByteArray(packetSize - 10)
) )
) )
viewModel.packetsSent = i + 1 viewModel.packetsSent = i + 1

View File

@@ -14,6 +14,7 @@
package com.github.google.bumble.btbench package com.github.google.bumble.btbench
import java.util.concurrent.CountDownLatch
import java.util.logging.Logger import java.util.logging.Logger
import kotlin.time.TimeSource import kotlin.time.TimeSource
@@ -23,6 +24,7 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var expectedSequenceNumber: Int = 0 private var expectedSequenceNumber: Int = 0
private val done = CountDownLatch(1)
init { init {
packetIO.packetSink = this packetIO.packetSink = this
@@ -30,6 +32,7 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
override fun run() { override fun run() {
viewModel.clear() viewModel.clear()
done.await()
} }
override fun abort() {} override fun abort() {}
@@ -58,5 +61,10 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber)) packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
viewModel.packetsSent += 1 viewModel.packetsSent += 1
if (packet.flags and Packet.LAST_FLAG != 0) {
Log.info("received last packet")
done.countDown()
}
} }
} }

View File

@@ -14,6 +14,7 @@
package com.github.google.bumble.btbench package com.github.google.bumble.btbench
import java.util.concurrent.CountDownLatch
import java.util.logging.Logger import java.util.logging.Logger
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.TimeSource import kotlin.time.TimeSource
@@ -24,6 +25,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var bytesReceived = 0 private var bytesReceived = 0
private val done = CountDownLatch(1)
init { init {
packetIO.packetSink = this packetIO.packetSink = this
@@ -31,6 +33,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
override fun run() { override fun run() {
viewModel.clear() viewModel.clear()
done.await()
} }
override fun abort() {} override fun abort() {}
@@ -62,6 +65,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
Log.info("throughput: $throughput") Log.info("throughput: $throughput")
viewModel.throughput = throughput viewModel.throughput = throughput
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber)) packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
done.countDown()
} }
} }
} }

View File

@@ -16,11 +16,10 @@ package com.github.google.bumble.btbench
import java.util.concurrent.Semaphore import java.util.concurrent.Semaphore
import java.util.logging.Logger import java.util.logging.Logger
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.TimeSource import kotlin.time.TimeSource
private const val DEFAULT_STARTUP_DELAY = 3000
private val Log = Logger.getLogger("btbench.sender") private val Log = Logger.getLogger("btbench.sender")
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
@@ -36,8 +35,8 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
override fun run() { override fun run() {
viewModel.clear() viewModel.clear()
Log.info("startup delay: $DEFAULT_STARTUP_DELAY") Log.info("startup delay: ${viewModel.startupDelay}")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); Thread.sleep(viewModel.startupDelay.toLong());
Log.info("running") Log.info("running")
Log.info("sending reset") Log.info("sending reset")
@@ -47,20 +46,32 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
val packetCount = viewModel.senderPacketCount val packetCount = viewModel.senderPacketCount
val packetSize = viewModel.senderPacketSize val packetSize = viewModel.senderPacketSize
for (i in 0..<packetCount - 1) { for (i in 0..<packetCount) {
packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6))) var now = TimeSource.Monotonic.markNow()
if (viewModel.senderPacketInterval > 0) {
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
val delay = targetTime - now
if (delay.isPositive()) {
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
Thread.sleep(delay.inWholeMilliseconds)
}
now = TimeSource.Monotonic.markNow()
}
val flags = when (i) {
packetCount - 1 -> Packet.LAST_FLAG
else -> 0
}
packetIO.sendPacket(
SequencePacket(
flags,
i,
(now - startTime).inWholeMicroseconds.toInt(),
ByteArray(packetSize - 10)
)
)
bytesSent += packetSize bytesSent += packetSize
viewModel.packetsSent = i + 1 viewModel.packetsSent = i + 1
} }
packetIO.sendPacket(
SequencePacket(
Packet.LAST_FLAG,
packetCount - 1,
ByteArray(packetSize - 6)
)
)
bytesSent += packetSize
viewModel.packetsSent = packetCount
// Wait for the ACK // Wait for the ACK
Log.info("waiting for ACK") Log.info("waiting for ACK")

View File

@@ -153,7 +153,7 @@ disable = [
] ]
[tool.pylint.main] [tool.pylint.main]
ignore = "pandora" # FIXME: pylint does not support stubs yet: ignore=["pandora", "mobly"] # FIXME: pylint does not support stubs yet
[tool.pylint.typecheck] [tool.pylint.typecheck]
signature-mutators = "AsyncRunner.run_in_task" signature-mutators = "AsyncRunner.run_in_task"