diff --git a/apps/bench.py b/apps/bench.py index 67299db..7bbba77 100644 --- a/apps/bench.py +++ b/apps/bench.py @@ -16,6 +16,7 @@ # Imports # ----------------------------------------------------------------------------- import asyncio +import dataclasses import enum import logging import os @@ -97,34 +98,6 @@ DEFAULT_RFCOMM_MTU = 2048 # ----------------------------------------------------------------------------- # 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): 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) @@ -225,13 +198,135 @@ async def switch_roles(connection, role): logging.info(f'{color("### Role switch failed:", "red")} {error}') -class PacketType(enum.IntEnum): - RESET = 0 - SEQUENCE = 1 - ACK = 2 +# ----------------------------------------------------------------------------- +# Packet +# ----------------------------------------------------------------------------- +@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("= 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(" 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) 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.bytes_sent = 0 for tx_i in range(self.tx_packet_count): - packet_flags = ( - PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0 + if self.pace > 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( color( f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow' @@ -302,14 +415,6 @@ class Sender: self.bytes_sent += len(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() run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else '' @@ -321,13 +426,13 @@ class Sender: if self.repeat: logging.info(color('--- End of runs', 'blue')) - def on_packet_received(self, packet): + def on_packet_received(self, data): try: - packet_type, _ = parse_packet(packet) + packet = Packet.from_bytes(data) except ValueError: return - if packet_type == PacketType.ACK: + if packet.packet_type == Packet.PacketType.ACK: elapsed = time.time() - self.start_time average_tx_speed = self.bytes_sent / elapsed self.stats.append(average_tx_speed) @@ -350,52 +455,53 @@ class Receiver: last_timestamp: float def __init__(self, packet_io, linger): - self.reset() + self.jitter_stats = JitterStats() self.packet_io = packet_io self.packet_io.packet_listener = self self.linger = linger self.done = asyncio.Event() + self.reset() def reset(self): self.expected_packet_index = 0 self.measurements = [(time.time(), 0)] self.total_bytes_received = 0 + self.jitter_stats.reset() - def on_packet_received(self, packet): + def on_packet_received(self, data): try: - packet_type, packet_data = parse_packet(packet) + packet = Packet.from_bytes(data) except ValueError: + logging.exception("invalid packet") return - if packet_type == PacketType.RESET: + if packet.packet_type == Packet.PacketType.RESET: logging.info(color('=== Received RESET', 'magenta')) self.reset() return - try: - packet_flags, packet_index = parse_packet_sequence(packet_data) - except ValueError: - return + jitter = self.jitter_stats.on_packet_received(packet) logging.info( - f'<<< Received packet {packet_index}: ' - f'flags=0x{packet_flags:02X}, ' - f'{len(packet) + self.packet_io.overhead_size} bytes' + f'<<< Received packet {packet.sequence}: ' + f'flags={packet.flags}, ' + 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( color( f'!!! Unexpected packet, expected {self.expected_packet_index} ' - f'but received {packet_index}' + f'but received {packet.sequence}' ) ) now = time.time() elapsed_since_start = now - self.measurements[0][0] elapsed_since_last = now - self.measurements[-1][0] - self.measurements.append((now, len(packet))) - self.total_bytes_received += len(packet) - instant_rx_speed = len(packet) / elapsed_since_last + self.measurements.append((now, len(data))) + self.total_bytes_received += len(data) + instant_rx_speed = len(data) / elapsed_since_last average_rx_speed = self.total_bytes_received / elapsed_since_start window = self.measurements[-64:] 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( 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')) + self.jitter_stats.show_stats() + if not self.linger: self.done.set() @@ -479,25 +587,32 @@ class Ping: await asyncio.sleep(self.tx_start_delay) 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() self.next_expected_packet_index = 0 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() if now < target_time: await asyncio.sleep(target_time - now) + now = time.time() - packet = struct.pack( - '>bbI', - PacketType.SEQUENCE, - (PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0), - i, - ) + bytes(self.tx_packet_size - 6) + packet = bytes( + Packet( + packet_type=Packet.PacketType.SEQUENCE, + flags=( + Packet.PacketFlags.LAST + 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')) - self.ping_times.append(time.time()) + self.ping_times.append(now) await self.packet_io.send_packet(packet) await self.done.wait() @@ -531,40 +646,35 @@ class Ping: if self.repeat: logging.info(color('--- End of runs', 'blue')) - def on_packet_received(self, packet): + def on_packet_received(self, data): try: - packet_type, packet_data = parse_packet(packet) + packet = Packet.from_bytes(data) except ValueError: return - try: - packet_flags, packet_index = parse_packet_sequence(packet_data) - except ValueError: - return - - if packet_type == PacketType.ACK: - elapsed = time.time() - self.ping_times[packet_index] + if packet.packet_type == Packet.PacketType.ACK: + elapsed = time.time() - self.ping_times[packet.sequence] rtt = elapsed * 1000 self.rtts.append(rtt) logging.info( color( - f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms', + f'<<< Received ACK [{packet.sequence}], RTT={rtt:.2f}ms', 'green', ) ) - if packet_index == self.next_expected_packet_index: + if packet.sequence == self.next_expected_packet_index: self.next_expected_packet_index += 1 else: logging.info( color( f'!!! Unexpected packet, ' 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() return @@ -576,89 +686,56 @@ class Pong: expected_packet_index: int def __init__(self, packet_io, linger): - self.reset() + self.jitter_stats = JitterStats() self.packet_io = packet_io self.packet_io.packet_listener = self self.linger = linger self.done = asyncio.Event() + self.reset() def reset(self): self.expected_packet_index = 0 - self.receive_times = [] - - def on_packet_received(self, packet): - self.receive_times.append(time.time()) + self.jitter_stats.reset() + def on_packet_received(self, data): try: - packet_type, packet_data = parse_packet(packet) + packet = Packet.from_bytes(data) except ValueError: return - if packet_type == PacketType.RESET: + if packet.packet_type == Packet.PacketType.RESET: logging.info(color('=== Received RESET', 'magenta')) self.reset() return - try: - 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 - ) + jitter = self.jitter_stats.on_packet_received(packet) logging.info( color( - f'<<< Received packet {packet_index}: ' - f'flags=0x{packet_flags:02X}, {len(packet)} bytes, ' - f'interval={interval:.4f}', + f'<<< Received packet {packet.sequence}: ' + f'flags={packet.flags}, {len(data)} bytes, ' + f'jitter={jitter:.4f}', 'green', ) ) - if packet_index != self.expected_packet_index: + if packet.sequence != self.expected_packet_index: logging.info( color( 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( 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 len(self.receive_times) >= 3: - # 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 packet.flags & Packet.PacketFlags.LAST: + self.jitter_stats.show_stats() if not self.linger: self.done.set() @@ -1471,7 +1548,7 @@ def create_mode_factory(ctx, default_mode): def create_scenario_factory(ctx, default_scenario): scenario = ctx.obj['scenario'] if scenario is None: - scenarion = default_scenario + scenario = default_scenario def create_scenario(packet_io): if scenario == 'send': @@ -1530,6 +1607,7 @@ def create_scenario_factory(ctx, default_scenario): '--att-mtu', metavar='MTU', type=click.IntRange(23, 517), + default=517, help='GATT MTU (gatt-client mode)', ) @click.option( @@ -1605,7 +1683,7 @@ def create_scenario_factory(ctx, default_scenario): '--packet-size', '-s', metavar='SIZE', - type=click.IntRange(8, 8192), + type=click.IntRange(10, 8192), default=500, help='Packet size (send or ping scenario)', ) diff --git a/bumble/device.py b/bumble/device.py index 4ab5c28..14ae6c3 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -2475,7 +2475,7 @@ class Device(CompositeEventEmitter): if self.random_address != hci.Address.ANY_RANDOM: logger.debug( color( - f'LE Random hci.Address: {self.random_address}', + f'LE Random Address: {self.random_address}', 'yellow', ) ) diff --git a/examples/mobly/bench/one_device_bench_test.py b/examples/mobly/bench/one_device_bench_test.py index 6fbc101..c715885 100644 --- a/examples/mobly/bench/one_device_bench_test.py +++ b/examples/mobly/bench/one_device_bench_test.py @@ -28,7 +28,7 @@ class OneDeviceBenchTest(base_test.BaseTestClass): def test_l2cap_client_ping(self): 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) final_status = self.dut.bench.waitForRunnerCompletion(runner["id"]) @@ -36,12 +36,34 @@ class OneDeviceBenchTest(base_test.BaseTestClass): def test_l2cap_client_send(self): 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) final_status = self.dut.bench.waitForRunnerCompletion(runner["id"]) 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__": test_runner.main() diff --git a/examples/mobly/bench/sample_config.yml b/examples/mobly/bench/sample_config.yml index 7a1a0d4..50823a4 100644 --- a/examples/mobly/bench/sample_config.yml +++ b/examples/mobly/bench/sample_config.yml @@ -2,8 +2,8 @@ TestBeds: - Name: BenchTestBed Controllers: AndroidDevice: - - serial: 37211FDJG000DJ + - serial: emulator-5554 local_bt_address: 94:45:60:5E:03:B0 - - serial: 23071FDEE001F7 - local_bt_address: DC:E5:5B:E5:51:2C + #- serial: 23071FDEE001F7 + # local_bt_address: DC:E5:5B:E5:51:2C diff --git a/extras/android/BtBench/app/build.gradle.kts b/extras/android/BtBench/app/build.gradle.kts index 05d36e1..887be16 100644 --- a/extras/android/BtBench/app/build.gradle.kts +++ b/extras/android/BtBench/app/build.gradle.kts @@ -10,7 +10,7 @@ android { defaultConfig { applicationId = "com.github.google.bumble.btbench" - minSdk = 30 + minSdk = 33 targetSdk = 34 versionCode = 1 versionName = "1.0" diff --git a/extras/android/BtBench/app/src/main/AndroidManifest.xml b/extras/android/BtBench/app/src/main/AndroidManifest.xml index 6be3478..f522fb8 100644 --- a/extras/android/BtBench/app/src/main/AndroidManifest.xml +++ b/extras/android/BtBench/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Advertiser.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Advertiser.kt new file mode 100644 index 0000000..b7370a8 --- /dev/null +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Advertiser.kt @@ -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") + } +} \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java index 3a7afa4..ca7d41d 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java @@ -22,11 +22,13 @@ import androidx.test.core.app.ApplicationProvider; import com.google.android.mobly.snippet.Snippet; import com.google.android.mobly.snippet.rpc.Rpc; +import com.google.android.mobly.snippet.rpc.RpcOptional; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.io.IOException; import java.security.InvalidParameterException; import java.util.ArrayList; import java.util.UUID; @@ -71,12 +73,15 @@ public class AutomationSnippet implements Snippet { private final Context mContext; private final ArrayList mRunners = new ArrayList<>(); - public AutomationSnippet() { + public AutomationSnippet() throws IOException { mContext = ApplicationProvider.getApplicationContext(); BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class); mBluetoothAdapter = bluetoothManager.getAdapter(); 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) { case "rfcomm-client": runnable = new RfcommClient(model, mBluetoothAdapter, - (PacketIO packetIO) -> createIoClient(model, scenario, - packetIO)); + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "rfcomm-server": runnable = new RfcommServer(model, mBluetoothAdapter, - (PacketIO packetIO) -> createIoClient(model, scenario, - packetIO)); + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "l2cap-client": runnable = new L2capClient(model, mBluetoothAdapter, mContext, - (PacketIO packetIO) -> createIoClient(model, scenario, - packetIO)); + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "l2cap-server": runnable = new L2capServer(model, mBluetoothAdapter, - (PacketIO packetIO) -> createIoClient(model, scenario, - packetIO)); + (PacketIO packetIO) -> createIoClient(model, scenario, + 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; default: return null; } + model.setMode(mode); + model.setScenario(scenario); runnable.run(); Runner runner = new Runner(runnable, mode, scenario, model); mRunners.add(runner); @@ -140,7 +159,21 @@ public class AutomationSnippet implements Snippet { JSONObject result = new JSONObject(); result.put("status", model.getStatus()); 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("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")) { JSONObject stats = new JSONObject(); result.put("stats", stats); @@ -167,12 +200,12 @@ public class AutomationSnippet implements Snippet { @Rpc(description = "Run a scenario in RFComm Client mode") public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount, - int packetSize, int packetInterval) throws JSONException { - assert (mBluetoothAdapter != null); - + int packetSize, int packetInterval, + @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"); + throw new InvalidParameterException( + "only 'send' and 'ping' are supported for this mode"); } AppViewModel model = new AppViewModel(); @@ -180,6 +213,9 @@ public class AutomationSnippet implements Snippet { model.setSenderPacketCount(packetCount); model.setSenderPacketSize(packetSize); model.setSenderPacketInterval(packetInterval); + if (startupDelay != null) { + model.setStartupDelay(startupDelay); + } Runner runner = runScenario(model, "rfcomm-client", scenario); assert runner != null; @@ -187,15 +223,18 @@ public class AutomationSnippet implements Snippet { } @Rpc(description = "Run a scenario in RFComm Server mode") - public JSONObject runRfcommServer(String scenario) throws JSONException { - assert (mBluetoothAdapter != null); - + public JSONObject runRfcommServer(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"); + 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, "rfcomm-server", scenario); assert runner != null; @@ -205,12 +244,12 @@ public class AutomationSnippet implements Snippet { @Rpc(description = "Run a scenario in L2CAP Client mode") public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm, boolean use_2m_phy, int packetCount, int packetSize, - int packetInterval) throws JSONException { - assert (mBluetoothAdapter != null); - + 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"); + throw new InvalidParameterException( + "only 'send' and 'ping' are supported for this mode"); } AppViewModel model = new AppViewModel(); @@ -220,28 +259,83 @@ public class AutomationSnippet implements Snippet { 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, "l2cap-client", scenario); assert runner != null; return runner.toJson(); } @Rpc(description = "Run a scenario in L2CAP Server mode") - public JSONObject runL2capServer(String scenario) throws JSONException { - assert (mBluetoothAdapter != null); - + public JSONObject runL2capServer(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"); + 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, "l2cap-server", scenario); assert runner != null; 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") public JSONObject stopRunner(String runnerId) throws JSONException { Runner runner = findRunner(runnerId); @@ -276,7 +370,7 @@ public class AutomationSnippet implements Snippet { JSONObject result = new JSONObject(); JSONArray runners = new JSONArray(); result.put("runners", runners); - for (Runner runner: mRunners) { + for (Runner runner : mRunners) { runners.put(runner.toJson()); } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Connection.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Connection.kt new file mode 100644 index 0000000..df46521 --- /dev/null +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Connection.kt @@ -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") + } + } + } +} \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Gatt.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Gatt.kt new file mode 100644 index 0000000..126c5bc --- /dev/null +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Gatt.kt @@ -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") diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattClient.kt new file mode 100644 index 0000000..fe7080e --- /dev/null +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattClient.kt @@ -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() + } +} diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattServer.kt new file mode 100644 index 0000000..8a36ecc --- /dev/null +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/GattServer.kt @@ -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? = 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") + } +} \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt index a6567a6..1049411 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt @@ -16,89 +16,25 @@ 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.BluetoothProfile import android.content.Context -import android.os.Build import java.util.logging.Logger private val Log = Logger.getLogger("btbench.l2cap-client") class L2capClient( private val viewModel: AppViewModel, - private val bluetoothAdapter: BluetoothAdapter, - private val context: Context, + bluetoothAdapter: BluetoothAdapter, + context: Context, private val createIoClient: (packetIo: PacketIO) -> IoClient ) : Mode { + private var connection: Connection = Connection(viewModel, bluetoothAdapter, context) private var socketClient: SocketClient? = null @SuppressLint("MissingPermission") override fun run() { viewModel.running = true - val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P") - val address = viewModel.peerBluetoothAddress.take(17) - 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) - + connection.connect() + val socket = connection.remoteDevice!!.createInsecureL2capChannel(viewModel.l2capPsm) socketClient = SocketClient(viewModel, socket, createIoClient) socketClient!!.run() } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt index bb989c6..69b31dc 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt @@ -37,34 +37,15 @@ class L2capServer( @SuppressLint("MissingPermission") override fun run() { // Advertise so that the peer can find us and connect. - val callback = object : AdvertiseCallback() { - 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 advertiser = Advertiser(bluetoothAdapter) val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel() viewModel.l2capPsm = serverSocket.psm Log.info("psm = $serverSocket.psm") socketServer = SocketServer(viewModel, serverSocket, createIoClient) socketServer!!.run( - { advertiser.stopAdvertising(callback) }, - { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) } + { advertiser.stop() }, + { advertiser.start() } ) } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt index 02729eb..bb3e558 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt @@ -17,9 +17,12 @@ package com.github.google.bumble.btbench import android.Manifest import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -66,6 +69,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import com.github.google.bumble.btbench.ui.theme.BTBenchTheme +import java.io.IOException import java.util.logging.Logger 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 SCENARIO_PREF_KEY = "scenario" const val MODE_PREF_KEY = "mode" +const val CONNECTION_PRIORITY_PREF_KEY = "connection_priority" class MainActivity : ComponentActivity() { private val appViewModel = AppViewModel() @@ -84,6 +89,47 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE)) 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() { @@ -144,9 +190,7 @@ class MainActivity : ComponentActivity() { initBluetooth() setContent { MainView( - appViewModel, - ::becomeDiscoverable, - ::runScenario + appViewModel, ::becomeDiscoverable, ::runScenario ) } @@ -182,6 +226,8 @@ class MainActivity : ComponentActivity() { "rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE "l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_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 { @@ -195,19 +241,24 @@ class MainActivity : ComponentActivity() { private fun runScenario() { if (bluetoothAdapter == null) { - return + throw IOException("bluetooth not enabled") } val runner = when (appViewModel.mode) { RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient) RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient) L2CAP_CLIENT_MODE -> L2capClient( - appViewModel, - bluetoothAdapter!!, - baseContext, - ::createIoClient + appViewModel, bluetoothAdapter!!, baseContext, ::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() } runner.run() @@ -281,7 +332,7 @@ fun MainView( keyboardController?.hide() 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() TextField( @@ -349,24 +400,45 @@ fun MainView( keyboardController?.hide() focusManager.clearFocus() }), - enabled = (appViewModel.scenario == PING_SCENARIO) + enabled = (appViewModel.scenario == PING_SCENARIO || appViewModel.scenario == SEND_SCENARIO) ) Divider() - ActionButton( - text = "Become Discoverable", onClick = becomeDiscoverable, true - ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(text = "2M PHY") Spacer(modifier = Modifier.padding(start = 8.dp)) - Switch( - enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE), + Switch(enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE || appViewModel.mode == GATT_CLIENT_MODE || appViewModel.mode == GATT_SERVER_MODE), 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 { Column(Modifier.selectableGroup()) { @@ -374,7 +446,9 @@ fun MainView( RFCOMM_CLIENT_MODE, RFCOMM_SERVER_MODE, L2CAP_CLIENT_MODE, - L2CAP_SERVER_MODE + L2CAP_SERVER_MODE, + GATT_CLIENT_MODE, + GATT_SERVER_MODE ).forEach { text -> Row( Modifier @@ -387,8 +461,7 @@ fun MainView( verticalAlignment = Alignment.CenterVertically ) { RadioButton( - selected = (text == appViewModel.mode), - onClick = null + selected = (text == appViewModel.mode), onClick = null ) Text( text = text, @@ -400,10 +473,7 @@ fun MainView( } Column(Modifier.selectableGroup()) { listOf( - SEND_SCENARIO, - RECEIVE_SCENARIO, - PING_SCENARIO, - PONG_SCENARIO + SEND_SCENARIO, RECEIVE_SCENARIO, PING_SCENARIO, PONG_SCENARIO ).forEach { text -> Row( Modifier @@ -416,8 +486,7 @@ fun MainView( verticalAlignment = Alignment.CenterVertically ) { RadioButton( - selected = (text == appViewModel.scenario), - onClick = null + selected = (text == appViewModel.scenario), onClick = null ) Text( text = text, @@ -435,20 +504,29 @@ fun MainView( ActionButton( text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running ) + ActionButton( + text = "Become Discoverable", onClick = becomeDiscoverable, true + ) } Divider() - Text( - text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else "" - ) - Text( - text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else "" - ) + if (appViewModel.mtu != 0) { + Text( + text = "MTU: ${appViewModel.mtu}" + ) + } + if (appViewModel.rxPhy != 0) { + Text( + text = "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" + ) + } Text( text = "Status: ${appViewModel.status}" ) - Text( - text = "Last Error: ${appViewModel.lastError}" - ) + if (appViewModel.lastError.isNotEmpty()) { + Text( + text = "Last Error: ${appViewModel.lastError}" + ) + } Text( text = "Packets Sent: ${appViewModel.packetsSent}" ) diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt index a58e344..5b69949 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt @@ -25,6 +25,7 @@ import java.util.UUID 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_STARTUP_DELAY = 3000 const val DEFAULT_SENDER_PACKET_COUNT = 100 const val DEFAULT_SENDER_PACKET_SIZE = 1024 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 RFCOMM_CLIENT_MODE = "RFCOMM Client" 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 RECEIVE_SCENARIO = "Receive" @@ -47,8 +50,10 @@ class AppViewModel : ViewModel() { var mode by mutableStateOf(RFCOMM_SERVER_MODE) var scenario by mutableStateOf(RECEIVE_SCENARIO) var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS) + var startupDelay by mutableIntStateOf(DEFAULT_STARTUP_DELAY) var l2capPsm by mutableIntStateOf(DEFAULT_PSM) var use2mPhy by mutableStateOf(true) + var connectionPriority by mutableStateOf("BALANCED") var mtu by mutableIntStateOf(0) var rxPhy by mutableIntStateOf(0) var txPhy by mutableIntStateOf(0) @@ -98,6 +103,11 @@ class AppViewModel : ViewModel() { if (savedScenario != null) { scenario = savedScenario } + + val savedConnectionPriority = preferences.getString(CONNECTION_PRIORITY_PREF_KEY, null) + if (savedConnectionPriority != null) { + connectionPriority = savedConnectionPriority + } } 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() { status = "" lastError = "" diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt index 0ccd8cf..b1cf174 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt @@ -17,6 +17,7 @@ package com.github.google.bumble.btbench import android.bluetooth.BluetoothSocket import java.io.IOException import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.logging.Logger import kotlin.math.min @@ -37,11 +38,16 @@ abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) { RESET -> ResetPacket() SEQUENCE -> SequencePacket( data[1].toInt(), - ByteBuffer.wrap(data, 2, 4).getInt(), - data.sliceArray(6.. 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.. 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() + } } pingTimes.add(TimeSource.Monotonic.markNow()) packetIO.sendPacket( SequencePacket( if (i < packetCount - 1) 0 else Packet.LAST_FLAG, i, - ByteArray(packetSize - 6) + (now - startTime).inWholeMicroseconds.toInt(), + ByteArray(packetSize - 10) ) ) viewModel.packetsSent = i + 1 diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt index b2ddb7e..16c366b 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt @@ -14,6 +14,7 @@ package com.github.google.bumble.btbench +import java.util.concurrent.CountDownLatch import java.util.logging.Logger 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 lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var expectedSequenceNumber: Int = 0 + private val done = CountDownLatch(1) init { packetIO.packetSink = this @@ -30,6 +32,7 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO override fun run() { viewModel.clear() + done.await() } override fun abort() {} @@ -58,5 +61,10 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber)) viewModel.packetsSent += 1 + + if (packet.flags and Packet.LAST_FLAG != 0) { + Log.info("received last packet") + done.countDown() + } } } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt index 71055c1..1dc1750 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt @@ -14,6 +14,7 @@ package com.github.google.bumble.btbench +import java.util.concurrent.CountDownLatch import java.util.logging.Logger import kotlin.time.DurationUnit 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 lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow() private var bytesReceived = 0 + private val done = CountDownLatch(1) init { packetIO.packetSink = this @@ -31,6 +33,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet override fun run() { viewModel.clear() + done.await() } override fun abort() {} @@ -62,6 +65,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet Log.info("throughput: $throughput") viewModel.throughput = throughput packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber)) + done.countDown() } } } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt index ed45cdd..50af553 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt @@ -16,11 +16,10 @@ package com.github.google.bumble.btbench import java.util.concurrent.Semaphore import java.util.logging.Logger +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit import kotlin.time.TimeSource -private const val DEFAULT_STARTUP_DELAY = 3000 - private val Log = Logger.getLogger("btbench.sender") 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() { viewModel.clear() - Log.info("startup delay: $DEFAULT_STARTUP_DELAY") - Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); + Log.info("startup delay: ${viewModel.startupDelay}") + Thread.sleep(viewModel.startupDelay.toLong()); Log.info("running") Log.info("sending reset") @@ -47,20 +46,32 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO val packetCount = viewModel.senderPacketCount val packetSize = viewModel.senderPacketSize - for (i in 0.. 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 viewModel.packetsSent = i + 1 } - packetIO.sendPacket( - SequencePacket( - Packet.LAST_FLAG, - packetCount - 1, - ByteArray(packetSize - 6) - ) - ) - bytesSent += packetSize - viewModel.packetsSent = packetCount // Wait for the ACK Log.info("waiting for ACK") diff --git a/pyproject.toml b/pyproject.toml index 7038551..03e1176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,7 @@ disable = [ ] [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] signature-mutators = "AsyncRunner.run_in_task"