mirror of
https://github.com/google/bumble.git
synced 2026-04-17 00:35:31 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082d55af10 | ||
|
|
4c3fd5688d | ||
|
|
9d3d5495ce | ||
|
|
b3869f267c | ||
|
|
b57096abe2 | ||
|
|
100bea6b41 | ||
|
|
63819bf9dd | ||
|
|
e3fdab4175 | ||
|
|
bbcd14dbf0 | ||
|
|
01dc0d574b | ||
|
|
5e959d638e | ||
|
|
c88b32a406 | ||
|
|
5a72eefb89 | ||
|
|
430046944b | ||
|
|
21d23320eb | ||
|
|
d0990ee04d | ||
|
|
2d88e853e8 | ||
|
|
a060a70fba | ||
|
|
a06394ad4a | ||
|
|
a1414c2b5b | ||
|
|
b2864dac2d | ||
|
|
b78f895143 | ||
|
|
c4e9726828 | ||
|
|
d4b8e8348a | ||
|
|
19debaa52e | ||
|
|
73fe564321 | ||
|
|
a00abd65b3 | ||
|
|
f169ceaebb | ||
|
|
528af0d338 | ||
|
|
4b25eed869 | ||
|
|
fcd6bd7136 | ||
|
|
2bed50b353 | ||
|
|
1fe3778a74 | ||
|
|
5e31bcf23d | ||
|
|
fe429cb2eb | ||
|
|
c91695c23a | ||
|
|
55f99e6887 | ||
|
|
b190069f48 |
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
rust-version: [ "1.76.0", "stable" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
|
||||
@@ -60,7 +60,7 @@ AURACAST_DEFAULT_ATT_MTU = 256
|
||||
class BroadcastScanner(pyee.EventEmitter):
|
||||
@dataclasses.dataclass
|
||||
class Broadcast(pyee.EventEmitter):
|
||||
name: str
|
||||
name: str | None
|
||||
sync: bumble.device.PeriodicAdvertisingSync
|
||||
rssi: int = 0
|
||||
public_broadcast_announcement: Optional[
|
||||
@@ -135,7 +135,8 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
self.sync.advertiser_address,
|
||||
color(self.sync.state.name, 'green'),
|
||||
)
|
||||
print(f' {color("Name", "cyan")}: {self.name}')
|
||||
if self.name is not None:
|
||||
print(f' {color("Name", "cyan")}: {self.name}')
|
||||
if self.appearance:
|
||||
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
||||
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
||||
@@ -174,7 +175,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
print(color(' Codec ID:', 'yellow'))
|
||||
print(
|
||||
color(' Coding Format: ', 'green'),
|
||||
subgroup.codec_id.coding_format.name,
|
||||
subgroup.codec_id.codec_id.name,
|
||||
)
|
||||
print(
|
||||
color(' Company ID: ', 'green'),
|
||||
@@ -274,13 +275,24 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
await self.device.stop_scanning()
|
||||
|
||||
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
||||
if (
|
||||
broadcast_name := advertisement.data.get(
|
||||
bumble.core.AdvertisingData.BROADCAST_NAME
|
||||
if not (
|
||||
ads := advertisement.data.get_all(
|
||||
bumble.core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
|
||||
)
|
||||
) is None:
|
||||
) or not (
|
||||
any(
|
||||
ad
|
||||
for ad in ads
|
||||
if isinstance(ad, tuple)
|
||||
and ad[0] == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||
)
|
||||
):
|
||||
return
|
||||
assert isinstance(broadcast_name, str)
|
||||
|
||||
broadcast_name = advertisement.data.get(
|
||||
bumble.core.AdvertisingData.BROADCAST_NAME
|
||||
)
|
||||
assert isinstance(broadcast_name, str) or broadcast_name is None
|
||||
|
||||
if broadcast := self.broadcasts.get(advertisement.address):
|
||||
broadcast.update(advertisement)
|
||||
@@ -291,7 +303,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
)
|
||||
|
||||
async def on_new_broadcast(
|
||||
self, name: str, advertisement: bumble.device.Advertisement
|
||||
self, name: str | None, advertisement: bumble.device.Advertisement
|
||||
) -> None:
|
||||
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
|
||||
advertiser_address=advertisement.address,
|
||||
@@ -299,10 +311,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
sync_timeout=self.sync_timeout,
|
||||
filter_duplicates=self.filter_duplicates,
|
||||
)
|
||||
broadcast = self.Broadcast(
|
||||
name,
|
||||
periodic_advertising_sync,
|
||||
)
|
||||
broadcast = self.Broadcast(name, periodic_advertising_sync)
|
||||
broadcast.update(advertisement)
|
||||
self.broadcasts[advertisement.address] = broadcast
|
||||
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
||||
|
||||
231
apps/bench.py
231
apps/bench.py
@@ -19,6 +19,7 @@ import asyncio
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import statistics
|
||||
import struct
|
||||
import time
|
||||
|
||||
@@ -194,17 +195,19 @@ def make_sdp_records(channel):
|
||||
}
|
||||
|
||||
|
||||
def log_stats(title, stats):
|
||||
def log_stats(title, stats, precision=2):
|
||||
stats_min = min(stats)
|
||||
stats_max = max(stats)
|
||||
stats_avg = sum(stats) / len(stats)
|
||||
stats_avg = statistics.mean(stats)
|
||||
stats_stdev = statistics.stdev(stats)
|
||||
logging.info(
|
||||
color(
|
||||
(
|
||||
f'### {title} stats: '
|
||||
f'min={stats_min:.2f}, '
|
||||
f'max={stats_max:.2f}, '
|
||||
f'average={stats_avg:.2f}'
|
||||
f'min={stats_min:.{precision}f}, '
|
||||
f'max={stats_max:.{precision}f}, '
|
||||
f'average={stats_avg:.{precision}f}, '
|
||||
f'stdev={stats_stdev:.{precision}f}'
|
||||
),
|
||||
'cyan',
|
||||
)
|
||||
@@ -448,9 +451,9 @@ class Ping:
|
||||
self.repeat_delay = repeat_delay
|
||||
self.pace = pace
|
||||
self.done = asyncio.Event()
|
||||
self.current_packet_index = 0
|
||||
self.ping_sent_time = 0.0
|
||||
self.latencies = []
|
||||
self.ping_times = []
|
||||
self.rtts = []
|
||||
self.next_expected_packet_index = 0
|
||||
self.min_stats = []
|
||||
self.max_stats = []
|
||||
self.avg_stats = []
|
||||
@@ -477,60 +480,57 @@ class Ping:
|
||||
logging.info(color('=== Sending RESET', 'magenta'))
|
||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||
|
||||
self.current_packet_index = 0
|
||||
self.latencies = []
|
||||
await self.send_next_ping()
|
||||
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)
|
||||
now = time.time()
|
||||
if now < target_time:
|
||||
await asyncio.sleep(target_time - now)
|
||||
|
||||
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)
|
||||
logging.info(color(f'Sending packet {i}', 'yellow'))
|
||||
self.ping_times.append(time.time())
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
await self.done.wait()
|
||||
|
||||
min_latency = min(self.latencies)
|
||||
max_latency = max(self.latencies)
|
||||
avg_latency = sum(self.latencies) / len(self.latencies)
|
||||
min_rtt = min(self.rtts)
|
||||
max_rtt = max(self.rtts)
|
||||
avg_rtt = statistics.mean(self.rtts)
|
||||
stdev_rtt = statistics.stdev(self.rtts)
|
||||
logging.info(
|
||||
color(
|
||||
'@@@ Latencies: '
|
||||
f'min={min_latency:.2f}, '
|
||||
f'max={max_latency:.2f}, '
|
||||
f'average={avg_latency:.2f}'
|
||||
'@@@ RTTs: '
|
||||
f'min={min_rtt:.2f}, '
|
||||
f'max={max_rtt:.2f}, '
|
||||
f'average={avg_rtt:.2f}, '
|
||||
f'stdev={stdev_rtt:.2f}'
|
||||
)
|
||||
)
|
||||
|
||||
self.min_stats.append(min_latency)
|
||||
self.max_stats.append(max_latency)
|
||||
self.avg_stats.append(avg_latency)
|
||||
self.min_stats.append(min_rtt)
|
||||
self.max_stats.append(max_rtt)
|
||||
self.avg_stats.append(avg_rtt)
|
||||
|
||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
||||
|
||||
if self.repeat:
|
||||
log_stats('Min Latency', self.min_stats)
|
||||
log_stats('Max Latency', self.max_stats)
|
||||
log_stats('Average Latency', self.avg_stats)
|
||||
log_stats('Min RTT', self.min_stats)
|
||||
log_stats('Max RTT', self.max_stats)
|
||||
log_stats('Average RTT', self.avg_stats)
|
||||
|
||||
if self.repeat:
|
||||
logging.info(color('--- End of runs', 'blue'))
|
||||
|
||||
async def send_next_ping(self):
|
||||
if self.pace:
|
||||
await asyncio.sleep(self.pace / 1000)
|
||||
|
||||
packet = struct.pack(
|
||||
'>bbI',
|
||||
PacketType.SEQUENCE,
|
||||
(
|
||||
PACKET_FLAG_LAST
|
||||
if self.current_packet_index == self.tx_packet_count - 1
|
||||
else 0
|
||||
),
|
||||
self.current_packet_index,
|
||||
) + bytes(self.tx_packet_size - 6)
|
||||
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
|
||||
self.ping_sent_time = time.time()
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
elapsed = time.time() - self.ping_sent_time
|
||||
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
except ValueError:
|
||||
@@ -542,21 +542,23 @@ class Ping:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.ACK:
|
||||
latency = elapsed * 1000
|
||||
self.latencies.append(latency)
|
||||
elapsed = time.time() - self.ping_times[packet_index]
|
||||
rtt = elapsed * 1000
|
||||
self.rtts.append(rtt)
|
||||
logging.info(
|
||||
color(
|
||||
f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
|
||||
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
|
||||
if packet_index == self.current_packet_index:
|
||||
self.current_packet_index += 1
|
||||
if packet_index == self.next_expected_packet_index:
|
||||
self.next_expected_packet_index += 1
|
||||
else:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.current_packet_index} '
|
||||
f'!!! Unexpected packet, '
|
||||
f'expected {self.next_expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
)
|
||||
)
|
||||
@@ -565,8 +567,6 @@ class Ping:
|
||||
self.done.set()
|
||||
return
|
||||
|
||||
AsyncRunner.spawn(self.send_next_ping())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pong
|
||||
@@ -583,8 +583,11 @@ class Pong:
|
||||
|
||||
def reset(self):
|
||||
self.expected_packet_index = 0
|
||||
self.receive_times = []
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
self.receive_times.append(time.time())
|
||||
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
except ValueError:
|
||||
@@ -599,10 +602,16 @@ class Pong:
|
||||
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(
|
||||
color(
|
||||
f'<<< Received packet {packet_index}: '
|
||||
f'flags=0x{packet_flags:02X}, {len(packet)} bytes',
|
||||
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
|
||||
f'interval={interval:.4f}',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
@@ -623,8 +632,35 @@ class Pong:
|
||||
)
|
||||
)
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST and not self.linger:
|
||||
self.done.set()
|
||||
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 not self.linger:
|
||||
self.done.set()
|
||||
|
||||
async def run(self):
|
||||
await self.done.wait()
|
||||
@@ -942,9 +978,12 @@ class RfcommClient(StreamedPacketIO):
|
||||
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
||||
connection, self.uuid
|
||||
)
|
||||
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||
if channel == 0:
|
||||
logging.info(color('!!! No RFComm service with this UUID found', 'red'))
|
||||
if channel:
|
||||
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||
else:
|
||||
logging.warning(
|
||||
color('!!! No RFComm service with this UUID found', 'red')
|
||||
)
|
||||
await connection.disconnect()
|
||||
return
|
||||
|
||||
@@ -1054,6 +1093,8 @@ class RfcommServer(StreamedPacketIO):
|
||||
if self.credits_threshold is not None:
|
||||
dlc.rx_credits_threshold = self.credits_threshold
|
||||
|
||||
self.ready.set()
|
||||
|
||||
async def drain(self):
|
||||
assert self.dlc
|
||||
await self.dlc.drain()
|
||||
@@ -1068,7 +1109,7 @@ class Central(Connection.Listener):
|
||||
transport,
|
||||
peripheral_address,
|
||||
classic,
|
||||
role_factory,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
connection_interval,
|
||||
phy,
|
||||
@@ -1081,7 +1122,7 @@ class Central(Connection.Listener):
|
||||
self.transport = transport
|
||||
self.peripheral_address = peripheral_address
|
||||
self.classic = classic
|
||||
self.role_factory = role_factory
|
||||
self.scenario_factory = scenario_factory
|
||||
self.mode_factory = mode_factory
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt or authenticate
|
||||
@@ -1134,7 +1175,7 @@ class Central(Connection.Listener):
|
||||
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
|
||||
)
|
||||
mode = self.mode_factory(self.device)
|
||||
role = self.role_factory(mode)
|
||||
scenario = self.scenario_factory(mode)
|
||||
self.device.classic_enabled = self.classic
|
||||
|
||||
# Set up a pairing config factory with minimal requirements.
|
||||
@@ -1215,7 +1256,7 @@ class Central(Connection.Listener):
|
||||
|
||||
await mode.on_connection(self.connection)
|
||||
|
||||
await role.run()
|
||||
await scenario.run()
|
||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||
await self.connection.disconnect()
|
||||
|
||||
@@ -1246,7 +1287,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
def __init__(
|
||||
self,
|
||||
transport,
|
||||
role_factory,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
classic,
|
||||
extended_data_length,
|
||||
@@ -1254,11 +1295,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
):
|
||||
self.transport = transport
|
||||
self.classic = classic
|
||||
self.role_factory = role_factory
|
||||
self.scenario_factory = scenario_factory
|
||||
self.mode_factory = mode_factory
|
||||
self.extended_data_length = extended_data_length
|
||||
self.role_switch = role_switch
|
||||
self.role = None
|
||||
self.scenario = None
|
||||
self.mode = None
|
||||
self.device = None
|
||||
self.connection = None
|
||||
@@ -1278,7 +1319,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
)
|
||||
self.device.listener = self
|
||||
self.mode = self.mode_factory(self.device)
|
||||
self.role = self.role_factory(self.mode)
|
||||
self.scenario = self.scenario_factory(self.mode)
|
||||
self.device.classic_enabled = self.classic
|
||||
|
||||
# Set up a pairing config factory with minimal requirements.
|
||||
@@ -1315,7 +1356,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
print_connection(self.connection)
|
||||
|
||||
await self.mode.on_connection(self.connection)
|
||||
await self.role.run()
|
||||
await self.scenario.run()
|
||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||
|
||||
def on_connection(self, connection):
|
||||
@@ -1344,7 +1385,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
def on_disconnection(self, reason):
|
||||
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||
self.connection = None
|
||||
self.role.reset()
|
||||
self.scenario.reset()
|
||||
|
||||
if self.classic:
|
||||
AsyncRunner.spawn(self.device.set_discoverable(True))
|
||||
@@ -1426,13 +1467,13 @@ def create_mode_factory(ctx, default_mode):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def create_role_factory(ctx, default_role):
|
||||
role = ctx.obj['role']
|
||||
if role is None:
|
||||
role = default_role
|
||||
def create_scenario_factory(ctx, default_scenario):
|
||||
scenario = ctx.obj['scenario']
|
||||
if scenario is None:
|
||||
scenarion = default_scenario
|
||||
|
||||
def create_role(packet_io):
|
||||
if role == 'sender':
|
||||
def create_scenario(packet_io):
|
||||
if scenario == 'send':
|
||||
return Sender(
|
||||
packet_io,
|
||||
start_delay=ctx.obj['start_delay'],
|
||||
@@ -1443,10 +1484,10 @@ def create_role_factory(ctx, default_role):
|
||||
packet_count=ctx.obj['packet_count'],
|
||||
)
|
||||
|
||||
if role == 'receiver':
|
||||
if scenario == 'receive':
|
||||
return Receiver(packet_io, ctx.obj['linger'])
|
||||
|
||||
if role == 'ping':
|
||||
if scenario == 'ping':
|
||||
return Ping(
|
||||
packet_io,
|
||||
start_delay=ctx.obj['start_delay'],
|
||||
@@ -1457,12 +1498,12 @@ def create_role_factory(ctx, default_role):
|
||||
packet_count=ctx.obj['packet_count'],
|
||||
)
|
||||
|
||||
if role == 'pong':
|
||||
if scenario == 'pong':
|
||||
return Pong(packet_io, ctx.obj['linger'])
|
||||
|
||||
raise ValueError('invalid role')
|
||||
raise ValueError('invalid scenario')
|
||||
|
||||
return create_role
|
||||
return create_scenario
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1470,7 +1511,7 @@ def create_role_factory(ctx, default_role):
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.group()
|
||||
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
||||
@click.option('--role', type=click.Choice(['sender', 'receiver', 'ping', 'pong']))
|
||||
@click.option('--scenario', type=click.Choice(['send', 'receive', 'ping', 'pong']))
|
||||
@click.option(
|
||||
'--mode',
|
||||
type=click.Choice(
|
||||
@@ -1503,7 +1544,7 @@ def create_role_factory(ctx, default_role):
|
||||
'--rfcomm-channel',
|
||||
type=int,
|
||||
default=DEFAULT_RFCOMM_CHANNEL,
|
||||
help='RFComm channel to use',
|
||||
help='RFComm channel to use (specify 0 for channel discovery via SDP)',
|
||||
)
|
||||
@click.option(
|
||||
'--rfcomm-uuid',
|
||||
@@ -1565,7 +1606,7 @@ def create_role_factory(ctx, default_role):
|
||||
metavar='SIZE',
|
||||
type=click.IntRange(8, 8192),
|
||||
default=500,
|
||||
help='Packet size (client or ping role)',
|
||||
help='Packet size (send or ping scenario)',
|
||||
)
|
||||
@click.option(
|
||||
'--packet-count',
|
||||
@@ -1573,7 +1614,7 @@ def create_role_factory(ctx, default_role):
|
||||
metavar='COUNT',
|
||||
type=int,
|
||||
default=10,
|
||||
help='Packet count (client or ping role)',
|
||||
help='Packet count (send or ping scenario)',
|
||||
)
|
||||
@click.option(
|
||||
'--start-delay',
|
||||
@@ -1581,7 +1622,7 @@ def create_role_factory(ctx, default_role):
|
||||
metavar='SECONDS',
|
||||
type=int,
|
||||
default=1,
|
||||
help='Start delay (client or ping role)',
|
||||
help='Start delay (send or ping scenario)',
|
||||
)
|
||||
@click.option(
|
||||
'--repeat',
|
||||
@@ -1589,7 +1630,7 @@ def create_role_factory(ctx, default_role):
|
||||
type=int,
|
||||
default=0,
|
||||
help=(
|
||||
'Repeat the run N times (client and ping roles)'
|
||||
'Repeat the run N times (send and ping scenario)'
|
||||
'(0, which is the fault, to run just once) '
|
||||
),
|
||||
)
|
||||
@@ -1613,13 +1654,13 @@ def create_role_factory(ctx, default_role):
|
||||
@click.option(
|
||||
'--linger',
|
||||
is_flag=True,
|
||||
help="Don't exit at the end of a run (server and pong roles)",
|
||||
help="Don't exit at the end of a run (receive and pong scenarios)",
|
||||
)
|
||||
@click.pass_context
|
||||
def bench(
|
||||
ctx,
|
||||
device_config,
|
||||
role,
|
||||
scenario,
|
||||
mode,
|
||||
att_mtu,
|
||||
extended_data_length,
|
||||
@@ -1645,7 +1686,7 @@ def bench(
|
||||
):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['device_config'] = device_config
|
||||
ctx.obj['role'] = role
|
||||
ctx.obj['scenario'] = scenario
|
||||
ctx.obj['mode'] = mode
|
||||
ctx.obj['att_mtu'] = att_mtu
|
||||
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
||||
@@ -1699,7 +1740,7 @@ def central(
|
||||
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
||||
):
|
||||
"""Run as a central (initiates the connection)"""
|
||||
role_factory = create_role_factory(ctx, 'sender')
|
||||
scenario_factory = create_scenario_factory(ctx, 'send')
|
||||
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||
classic = ctx.obj['classic']
|
||||
|
||||
@@ -1708,7 +1749,7 @@ def central(
|
||||
transport,
|
||||
peripheral_address,
|
||||
classic,
|
||||
role_factory,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
connection_interval,
|
||||
phy,
|
||||
@@ -1726,13 +1767,13 @@ def central(
|
||||
@click.pass_context
|
||||
def peripheral(ctx, transport):
|
||||
"""Run as a peripheral (waits for a connection)"""
|
||||
role_factory = create_role_factory(ctx, 'receiver')
|
||||
scenario_factory = create_scenario_factory(ctx, 'receive')
|
||||
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
||||
|
||||
async def run_peripheral():
|
||||
await Peripheral(
|
||||
transport,
|
||||
role_factory,
|
||||
scenario_factory,
|
||||
mode_factory,
|
||||
ctx.obj['classic'],
|
||||
ctx.obj['extended_data_length'],
|
||||
@@ -1743,7 +1784,11 @@ def peripheral(ctx, transport):
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
logging.basicConfig(
|
||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
bench()
|
||||
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ async def async_main():
|
||||
return_parameters=bytes([hci.HCI_SUCCESS]),
|
||||
)
|
||||
# Return a packet with 'respond to sender' set to True
|
||||
return (response.to_bytes(), True)
|
||||
return (bytes(response), True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -486,7 +486,12 @@ class Speaker:
|
||||
|
||||
def on_pdu(pdu: HCI_IsoDataPacket, ase: ascs.AseStateMachine):
|
||||
codec_config = ase.codec_specific_configuration
|
||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
if (
|
||||
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
or codec_config.frame_duration is None
|
||||
or codec_config.audio_channel_allocation is None
|
||||
):
|
||||
return
|
||||
pcm = decode(
|
||||
codec_config.frame_duration.us,
|
||||
codec_config.audio_channel_allocation.channel_count,
|
||||
@@ -495,11 +500,17 @@ class Speaker:
|
||||
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
|
||||
|
||||
def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
|
||||
codec_config = ase.codec_specific_configuration
|
||||
if ase.state == ascs.AseStateMachine.State.STREAMING:
|
||||
codec_config = ase.codec_specific_configuration
|
||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
assert ase.cis_link
|
||||
if ase.role == ascs.AudioRole.SOURCE:
|
||||
if (
|
||||
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
or ase.cis_link is None
|
||||
or codec_config.octets_per_codec_frame is None
|
||||
or codec_config.frame_duration is None
|
||||
or codec_config.codec_frames_per_sdu is None
|
||||
):
|
||||
return
|
||||
ase.cis_link.abort_on(
|
||||
'disconnection',
|
||||
lc3_source_task(
|
||||
@@ -514,10 +525,17 @@ class Speaker:
|
||||
),
|
||||
)
|
||||
else:
|
||||
if not ase.cis_link:
|
||||
return
|
||||
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
|
||||
elif ase.state == ascs.AseStateMachine.State.CODEC_CONFIGURED:
|
||||
codec_config = ase.codec_specific_configuration
|
||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
if (
|
||||
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
or codec_config.sampling_frequency is None
|
||||
or codec_config.frame_duration is None
|
||||
or codec_config.audio_channel_allocation is None
|
||||
):
|
||||
return
|
||||
if ase.role == ascs.AudioRole.SOURCE:
|
||||
setup_encoders(
|
||||
codec_config.sampling_frequency.hz,
|
||||
|
||||
@@ -237,6 +237,7 @@ class ClientBridge:
|
||||
address: str,
|
||||
tcp_host: str,
|
||||
tcp_port: int,
|
||||
authenticate: bool,
|
||||
encrypt: bool,
|
||||
):
|
||||
self.channel = channel
|
||||
@@ -245,6 +246,7 @@ class ClientBridge:
|
||||
self.address = address
|
||||
self.tcp_host = tcp_host
|
||||
self.tcp_port = tcp_port
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt
|
||||
self.device: Optional[Device] = None
|
||||
self.connection: Optional[Connection] = None
|
||||
@@ -274,6 +276,11 @@ class ClientBridge:
|
||||
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
||||
self.connection.on("disconnection", self.on_disconnection)
|
||||
|
||||
if self.authenticate:
|
||||
print(color("@@@ Authenticating Bluetooth connection", "blue"))
|
||||
await self.connection.authenticate()
|
||||
print(color("@@@ Bluetooth connection authenticated", "blue"))
|
||||
|
||||
if self.encrypt:
|
||||
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
||||
await self.connection.encrypt()
|
||||
@@ -491,8 +498,9 @@ def server(context, tcp_host, tcp_port):
|
||||
@click.argument("bluetooth-address")
|
||||
@click.option("--tcp-host", help="TCP host", default="_")
|
||||
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
||||
@click.option("--authenticate", is_flag=True, help="Authenticate the connection")
|
||||
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
|
||||
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
||||
def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt):
|
||||
bridge = ClientBridge(
|
||||
context.obj["channel"],
|
||||
context.obj["uuid"],
|
||||
@@ -500,6 +508,7 @@ def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
||||
bluetooth_address,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
authenticate,
|
||||
encrypt,
|
||||
)
|
||||
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||
|
||||
@@ -291,9 +291,6 @@ class ATT_PDU:
|
||||
def init_from_bytes(self, pdu, offset):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
@property
|
||||
def is_command(self):
|
||||
return ((self.op_code >> 6) & 1) == 1
|
||||
@@ -303,7 +300,7 @@ class ATT_PDU:
|
||||
return ((self.op_code >> 7) & 1) == 1
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'yellow')
|
||||
|
||||
@@ -134,6 +134,8 @@ class Frame:
|
||||
opcode_offset = 3
|
||||
elif subunit_id == 6:
|
||||
raise core.InvalidPacketError("reserved subunit ID")
|
||||
else:
|
||||
raise core.InvalidPacketError("invalid subunit ID")
|
||||
|
||||
opcode = Frame.OperationCode(data[opcode_offset])
|
||||
operands = data[opcode_offset + 1 :]
|
||||
|
||||
@@ -314,7 +314,7 @@ class Controller:
|
||||
f'{color("CONTROLLER -> HOST", "green")}: {packet}'
|
||||
)
|
||||
if self.host:
|
||||
self.host.on_packet(packet.to_bytes())
|
||||
self.host.on_packet(bytes(packet))
|
||||
|
||||
# This method allows the controller to emulate the same API as a transport source
|
||||
async def wait_for_termination(self):
|
||||
@@ -1192,7 +1192,7 @@ class Controller:
|
||||
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||
'''
|
||||
bd_addr = (
|
||||
self._public_address.to_bytes()
|
||||
bytes(self._public_address)
|
||||
if self._public_address is not None
|
||||
else bytes(6)
|
||||
)
|
||||
@@ -1543,6 +1543,41 @@ class Controller:
|
||||
}
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_advertising_set_random_address_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.52 LE Set Advertising Set Random Address
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_extended_advertising_parameters_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.53 LE Set Extended Advertising Parameters
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS, 0])
|
||||
|
||||
def on_hci_le_set_extended_advertising_data_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.54 LE Set Extended Advertising Data
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_extended_scan_response_data_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.55 LE Set Extended Scan Response Data
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_extended_advertising_enable_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.56 LE Set Extended Advertising Enable
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
|
||||
@@ -1557,6 +1592,27 @@ class Controller:
|
||||
'''
|
||||
return struct.pack('<BB', HCI_SUCCESS, 0xF0)
|
||||
|
||||
def on_hci_le_set_periodic_advertising_parameters_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.61 LE Set Periodic Advertising Parameters
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_periodic_advertising_data_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.62 LE Set Periodic Advertising Data
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_set_periodic_advertising_enable_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.63 LE Set Periodic Advertising Enable
|
||||
Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_read_transmit_power_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||
|
||||
952
bumble/device.py
952
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -410,7 +410,7 @@ class IncludedServiceDeclaration(Attribute):
|
||||
|
||||
def __init__(self, service: Service) -> None:
|
||||
declaration_bytes = struct.pack(
|
||||
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
||||
'<HH2s', service.handle, service.end_group_handle, bytes(service.uuid)
|
||||
)
|
||||
super().__init__(
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
|
||||
|
||||
@@ -292,7 +292,7 @@ class Client:
|
||||
logger.debug(
|
||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
||||
)
|
||||
self.send_gatt_pdu(command.to_bytes())
|
||||
self.send_gatt_pdu(bytes(command))
|
||||
|
||||
async def send_request(self, request: ATT_PDU):
|
||||
logger.debug(
|
||||
@@ -310,7 +310,7 @@ class Client:
|
||||
self.pending_request = request
|
||||
|
||||
try:
|
||||
self.send_gatt_pdu(request.to_bytes())
|
||||
self.send_gatt_pdu(bytes(request))
|
||||
response = await asyncio.wait_for(
|
||||
self.pending_response, GATT_REQUEST_TIMEOUT
|
||||
)
|
||||
@@ -328,7 +328,7 @@ class Client:
|
||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||
f'{confirmation}'
|
||||
)
|
||||
self.send_gatt_pdu(confirmation.to_bytes())
|
||||
self.send_gatt_pdu(bytes(confirmation))
|
||||
|
||||
async def request_mtu(self, mtu: int) -> int:
|
||||
# Check the range
|
||||
@@ -898,6 +898,12 @@ class Client:
|
||||
) and subscriber in subscribers:
|
||||
subscribers.remove(subscriber)
|
||||
|
||||
# The characteristic itself is added as subscriber. If it is the
|
||||
# last remaining subscriber, we remove it, such that the clean up
|
||||
# works correctly. Otherwise the CCCD never is set back to 0.
|
||||
if len(subscribers) == 1 and characteristic in subscribers:
|
||||
subscribers.remove(characteristic)
|
||||
|
||||
# Cleanup if we removed the last one
|
||||
if not subscribers:
|
||||
del subscriber_set[characteristic.handle]
|
||||
|
||||
@@ -353,7 +353,7 @@ class Server(EventEmitter):
|
||||
logger.debug(
|
||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
||||
)
|
||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
||||
self.send_gatt_pdu(connection.handle, bytes(response))
|
||||
|
||||
async def notify_subscriber(
|
||||
self,
|
||||
@@ -450,7 +450,7 @@ class Server(EventEmitter):
|
||||
)
|
||||
|
||||
try:
|
||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
||||
self.send_gatt_pdu(connection.handle, bytes(indication))
|
||||
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||
|
||||
126
bumble/hci.py
126
bumble/hci.py
@@ -915,6 +915,8 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
HCI_READ_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+3),
|
||||
HCI_WRITE_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+4),
|
||||
HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (12*8+1),
|
||||
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMMAND : 1 << (12*8+2),
|
||||
HCI_LE_CS_WRITE_CACHED_REMOTE_FAE_TABLE_COMMAND : 1 << (12*8+3),
|
||||
HCI_READ_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+4),
|
||||
HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+5),
|
||||
HCI_READ_INQUIRY_MODE_COMMAND : 1 << (12*8+6),
|
||||
@@ -940,6 +942,8 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (16*8+3),
|
||||
HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+4),
|
||||
HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+5),
|
||||
HCI_LE_CS_CREATE_CONFIG_COMMAND : 1 << (16*8+6),
|
||||
HCI_LE_CS_REMOVE_CONFIG_COMMAND : 1 << (16*8+7),
|
||||
HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+0),
|
||||
HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+1),
|
||||
HCI_REFRESH_ENCRYPTION_KEY_COMMAND : 1 << (17*8+2),
|
||||
@@ -963,13 +967,20 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND : 1 << (20*8+2),
|
||||
HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (20*8+3),
|
||||
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
|
||||
HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+5),
|
||||
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+6),
|
||||
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES : 1 << (20*8+7),
|
||||
HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
|
||||
HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
|
||||
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
|
||||
HCI_READ_DATA_BLOCK_SIZE_COMMAND : 1 << (23*8+2),
|
||||
HCI_LE_CS_TEST_COMMAND : 1 << (23*8+3),
|
||||
HCI_LE_CS_TEST_END_COMMAND : 1 << (23*8+4),
|
||||
HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (24*8+0),
|
||||
HCI_LE_CS_SECURITY_ENABLE_COMMAND : 1 << (24*8+1),
|
||||
HCI_READ_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+5),
|
||||
HCI_WRITE_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+6),
|
||||
HCI_LE_CS_SET_DEFAULT_SETTINGS_COMMAND : 1 << (24*8+7),
|
||||
HCI_LE_SET_EVENT_MASK_COMMAND : 1 << (25*8+0),
|
||||
HCI_LE_READ_BUFFER_SIZE_COMMAND : 1 << (25*8+1),
|
||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (25*8+2),
|
||||
@@ -1000,6 +1011,10 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
HCI_LE_RECEIVER_TEST_COMMAND : 1 << (28*8+4),
|
||||
HCI_LE_TRANSMITTER_TEST_COMMAND : 1 << (28*8+5),
|
||||
HCI_LE_TEST_END_COMMAND : 1 << (28*8+6),
|
||||
HCI_LE_ENABLE_MONITORING_ADVERTISERS_COMMAND : 1 << (28*8+7),
|
||||
HCI_LE_CS_SET_CHANNEL_CLASSIFICATION_COMMAND : 1 << (29*8+0),
|
||||
HCI_LE_CS_SET_PROCEDURE_PARAMETERS_COMMAND : 1 << (29*8+1),
|
||||
HCI_LE_CS_PROCEDURE_ENABLE_COMMAND : 1 << (29*8+2),
|
||||
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (29*8+3),
|
||||
HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (29*8+4),
|
||||
HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND : 1 << (29*8+5),
|
||||
@@ -1136,11 +1151,21 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
|
||||
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND : 1 << (46*8+0),
|
||||
HCI_LE_SUBRATE_REQUEST_COMMAND : 1 << (46*8+1),
|
||||
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (46*8+2),
|
||||
HCI_LE_SET_DECISION_DATA_COMMAND : 1 << (46*8+3),
|
||||
HCI_LE_SET_DECISION_INSTRUCTIONS_COMMAND : 1 << (46*8+4),
|
||||
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND : 1 << (46*8+5),
|
||||
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND : 1 << (46*8+6),
|
||||
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND : 1 << (46*8+7),
|
||||
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND : 1 << (47*8+0),
|
||||
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (47*8+1),
|
||||
HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (47*8+2),
|
||||
HCI_LE_READ_ALL_REMOTE_FEATURES_COMMAND : 1 << (47*8+3),
|
||||
HCI_LE_SET_HOST_FEATURE_V2_COMMAND : 1 << (47*8+4),
|
||||
HCI_LE_ADD_DEVICE_TO_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+5),
|
||||
HCI_LE_REMOVE_DEVICE_FROM_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+6),
|
||||
HCI_LE_CLEAR_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+7),
|
||||
HCI_LE_READ_MONITORED_ADVERTISERS_LIST_SIZE_COMMAND : 1 << (48*8+0),
|
||||
HCI_LE_FRAME_SPACE_UPDATE_COMMAND : 1 << (48*8+1),
|
||||
}
|
||||
|
||||
# LE Supported Features
|
||||
@@ -1457,7 +1482,7 @@ class CodingFormat:
|
||||
vendor_specific_codec_id: int = 0
|
||||
|
||||
@classmethod
|
||||
def parse_from_bytes(cls, data: bytes, offset: int):
|
||||
def parse_from_bytes(cls, data: bytes, offset: int) -> tuple[int, CodingFormat]:
|
||||
(codec_id, company_id, vendor_specific_codec_id) = struct.unpack_from(
|
||||
'<BHH', data, offset
|
||||
)
|
||||
@@ -1467,14 +1492,15 @@ class CodingFormat:
|
||||
vendor_specific_codec_id=vendor_specific_codec_id,
|
||||
)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> CodingFormat:
|
||||
return cls.parse_from_bytes(data, 0)[1]
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return struct.pack(
|
||||
'<BHH', self.codec_id, self.company_id, self.vendor_specific_codec_id
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Constant:
|
||||
@@ -1691,7 +1717,7 @@ class HCI_Object:
|
||||
field_length = len(field_bytes)
|
||||
field_bytes = bytes([field_length]) + field_bytes
|
||||
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
|
||||
field_value, 'to_bytes'
|
||||
field_value, '__bytes__'
|
||||
):
|
||||
field_bytes = bytes(field_value)
|
||||
if isinstance(field_type, int) and 4 < field_type <= 256:
|
||||
@@ -1736,7 +1762,7 @@ class HCI_Object:
|
||||
def from_bytes(cls, data, offset, fields):
|
||||
return cls(fields, **cls.dict_from_bytes(data, offset, fields))
|
||||
|
||||
def to_bytes(self):
|
||||
def __bytes__(self):
|
||||
return HCI_Object.dict_to_bytes(self.__dict__, self.fields)
|
||||
|
||||
@staticmethod
|
||||
@@ -1831,9 +1857,6 @@ class HCI_Object:
|
||||
for field_name, field_value in field_strings
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
|
||||
def __init__(self, fields, **kwargs):
|
||||
self.fields = fields
|
||||
self.init_from_fields(self, fields, kwargs)
|
||||
@@ -2008,9 +2031,6 @@ class Address:
|
||||
def is_static(self):
|
||||
return self.is_random and (self.address_bytes[5] >> 6 == 3)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.address_bytes
|
||||
|
||||
def to_string(self, with_type_qualifier=True):
|
||||
'''
|
||||
String representation of the address, MSB first, with an optional type
|
||||
@@ -2022,7 +2042,7 @@ class Address:
|
||||
return result + '/P'
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.address_bytes
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.address_bytes)
|
||||
@@ -2228,16 +2248,13 @@ class HCI_Command(HCI_Packet):
|
||||
self.op_code = op_code
|
||||
self.parameters = parameters
|
||||
|
||||
def to_bytes(self):
|
||||
def __bytes__(self):
|
||||
parameters = b'' if self.parameters is None else self.parameters
|
||||
return (
|
||||
struct.pack('<BHB', HCI_COMMAND_PACKET, self.op_code, len(parameters))
|
||||
+ parameters
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'green')
|
||||
if fields := getattr(self, 'fields', None):
|
||||
@@ -4302,6 +4319,61 @@ class HCI_LE_Clear_Advertising_Sets_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
[
|
||||
('advertising_handle', 1),
|
||||
('periodic_advertising_interval_min', 2),
|
||||
('periodic_advertising_interval_max', 2),
|
||||
('periodic_advertising_properties', 2),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Set_Periodic_Advertising_Parameters_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.8.61 LE Set Periodic Advertising Parameters command
|
||||
'''
|
||||
|
||||
class Properties(enum.IntFlag):
|
||||
INCLUDE_TX_POWER = 1 << 6
|
||||
|
||||
advertising_handle: int
|
||||
periodic_advertising_interval_min: int
|
||||
periodic_advertising_interval_max: int
|
||||
periodic_advertising_properties: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
[
|
||||
('advertising_handle', 1),
|
||||
(
|
||||
'operation',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
|
||||
x
|
||||
).name,
|
||||
},
|
||||
),
|
||||
(
|
||||
'advertising_data',
|
||||
{
|
||||
'parser': HCI_Object.parse_length_prefixed_bytes,
|
||||
'serializer': HCI_Object.serialize_length_prefixed_bytes,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Set_Periodic_Advertising_Data_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.8.62 LE Set Periodic Advertising Data command
|
||||
'''
|
||||
|
||||
advertising_handle: int
|
||||
operation: int
|
||||
advertising_data: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command([('enable', 1), ('advertising_handle', 1)])
|
||||
class HCI_LE_Set_Periodic_Advertising_Enable_Command(HCI_Command):
|
||||
@@ -5106,13 +5178,10 @@ class HCI_Event(HCI_Packet):
|
||||
self.event_code = event_code
|
||||
self.parameters = parameters
|
||||
|
||||
def to_bytes(self):
|
||||
def __bytes__(self):
|
||||
parameters = b'' if self.parameters is None else self.parameters
|
||||
return bytes([HCI_EVENT_PACKET, self.event_code, len(parameters)]) + parameters
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'magenta')
|
||||
if fields := getattr(self, 'fields', None):
|
||||
@@ -6663,7 +6732,7 @@ class HCI_AclDataPacket(HCI_Packet):
|
||||
connection_handle, pb_flag, bc_flag, data_total_length, data
|
||||
)
|
||||
|
||||
def to_bytes(self):
|
||||
def __bytes__(self):
|
||||
h = (self.pb_flag << 12) | (self.bc_flag << 14) | self.connection_handle
|
||||
return (
|
||||
struct.pack('<BHH', HCI_ACL_DATA_PACKET, h, self.data_total_length)
|
||||
@@ -6677,9 +6746,6 @@ class HCI_AclDataPacket(HCI_Packet):
|
||||
self.data_total_length = data_total_length
|
||||
self.data = data
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'{color("ACL", "blue")}: '
|
||||
@@ -6713,7 +6779,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
connection_handle, packet_status, data_total_length, data
|
||||
)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
def __bytes__(self) -> bytes:
|
||||
h = (self.packet_status << 12) | self.connection_handle
|
||||
return (
|
||||
struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
|
||||
@@ -6732,9 +6798,6 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
self.data_total_length = data_total_length
|
||||
self.data = data
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'{color("SCO", "blue")}: '
|
||||
@@ -6807,9 +6870,6 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
fmt = '<BHH'
|
||||
args = [
|
||||
HCI_ISO_DATA_PACKET,
|
||||
|
||||
@@ -141,7 +141,7 @@ class HfFeature(enum.IntFlag):
|
||||
"""
|
||||
HF supported features (AT+BRSF=) (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
Hands-Free Profile v1.9, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||
@@ -155,14 +155,14 @@ class HfFeature(enum.IntFlag):
|
||||
HF_INDICATORS = 0x100
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||
VOICE_RECOGNITION_TEST = 0x800
|
||||
VOICE_RECOGNITION_TEXT = 0x800
|
||||
|
||||
|
||||
class AgFeature(enum.IntFlag):
|
||||
"""
|
||||
AG supported features (+BRSF:) (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
Hands-Free Profile v1.9, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
THREE_WAY_CALLING = 0x001
|
||||
@@ -178,7 +178,7 @@ class AgFeature(enum.IntFlag):
|
||||
HF_INDICATORS = 0x400
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||
VOICE_RECOGNITION_TEST = 0x2000
|
||||
VOICE_RECOGNITION_TEXT = 0x2000
|
||||
|
||||
|
||||
class AudioCodec(enum.IntEnum):
|
||||
@@ -1390,6 +1390,7 @@ class AgProtocol(pyee.EventEmitter):
|
||||
|
||||
def _on_bac(self, *args) -> None:
|
||||
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
|
||||
self.emit('supported_audio_codecs', self.supported_audio_codecs)
|
||||
self.send_ok()
|
||||
|
||||
def _on_bcs(self, codec: bytes) -> None:
|
||||
@@ -1618,7 +1619,7 @@ class ProfileVersion(enum.IntEnum):
|
||||
"""
|
||||
Profile version (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
Hands-Free Profile v1.8, 6.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
V1_5 = 0x0105
|
||||
@@ -1632,7 +1633,7 @@ class HfSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
HF supported features (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
Hands-Free Profile v1.9, 6.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||
@@ -1640,16 +1641,17 @@ class HfSdpFeature(enum.IntFlag):
|
||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||
REMOTE_VOLUME_CONTROL = 0x10
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
WIDE_BAND_SPEECH = 0x20
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
VOICE_RECOGNITION_TEXT = 0x80
|
||||
SUPER_WIDE_BAND = 0x100
|
||||
|
||||
|
||||
class AgSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
AG supported features (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
Hands-Free Profile v1.9, 6.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
THREE_WAY_CALLING = 0x01
|
||||
@@ -1657,9 +1659,10 @@ class AgSdpFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
WIDE_BAND_SPEECH = 0x20
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
VOICE_RECOGNITION_TEXT = 0x80
|
||||
SUPER_WIDE_BAND_SPEED_SPEECH = 0x100
|
||||
|
||||
|
||||
def make_hf_sdp_records(
|
||||
@@ -1692,11 +1695,11 @@ def make_hf_sdp_records(
|
||||
in configuration.supported_hf_features
|
||||
):
|
||||
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
||||
if HfFeature.VOICE_RECOGNITION_TEXT in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEXT
|
||||
|
||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
||||
hf_supported_features |= HfSdpFeature.WIDE_BAND_SPEECH
|
||||
|
||||
return [
|
||||
sdp.ServiceAttribute(
|
||||
@@ -1772,14 +1775,14 @@ def make_ag_sdp_records(
|
||||
in configuration.supported_ag_features
|
||||
):
|
||||
ag_supported_features |= AgSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
if AgFeature.VOICE_RECOGNITION_TEST in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEST
|
||||
if AgFeature.VOICE_RECOGNITION_TEXT in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEXT
|
||||
if AgFeature.IN_BAND_RING_TONE_CAPABILITY in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||
if AgFeature.VOICE_RECOGNITION_FUNCTION in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_FUNCTION
|
||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||
ag_supported_features |= AgSdpFeature.WIDE_BAND
|
||||
ag_supported_features |= AgSdpFeature.WIDE_BAND_SPEECH
|
||||
|
||||
return [
|
||||
sdp.ServiceAttribute(
|
||||
|
||||
@@ -199,7 +199,7 @@ class Host(AbortableEventEmitter):
|
||||
check_address_type: bool = False,
|
||||
) -> Optional[Connection]:
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address.to_bytes() == bd_addr.to_bytes():
|
||||
if bytes(connection.peer_address) == bytes(bd_addr):
|
||||
if (
|
||||
check_address_type
|
||||
and connection.peer_address.address_type != bd_addr.address_type
|
||||
|
||||
@@ -225,7 +225,7 @@ class L2CAP_PDU:
|
||||
|
||||
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
def __bytes__(self) -> bytes:
|
||||
header = struct.pack('<HH', len(self.payload), self.cid)
|
||||
return header + self.payload
|
||||
|
||||
@@ -233,9 +233,6 @@ class L2CAP_PDU:
|
||||
self.cid = cid
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
||||
|
||||
@@ -333,11 +330,8 @@ class L2CAP_Control_Frame:
|
||||
def init_from_bytes(self, pdu, offset):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self) -> str:
|
||||
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
||||
@@ -1911,6 +1905,7 @@ class ChannelManager:
|
||||
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
||||
else:
|
||||
result = L2CAP_Information_Response.NOT_SUPPORTED
|
||||
data = b''
|
||||
|
||||
self.send_control_frame(
|
||||
connection,
|
||||
|
||||
@@ -122,6 +122,8 @@ class LocalLink:
|
||||
elif transport == BT_BR_EDR_TRANSPORT:
|
||||
destination_controller = self.find_classic_controller(destination_address)
|
||||
source_address = sender_controller.public_address
|
||||
else:
|
||||
raise ValueError("unsupported transport type")
|
||||
|
||||
if destination_controller is not None:
|
||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
||||
|
||||
@@ -39,7 +39,6 @@ from bumble.device import (
|
||||
AdvertisingEventProperties,
|
||||
AdvertisingType,
|
||||
Device,
|
||||
Phy,
|
||||
)
|
||||
from bumble.gatt import Service
|
||||
from bumble.hci import (
|
||||
@@ -47,6 +46,7 @@ from bumble.hci import (
|
||||
HCI_PAGE_TIMEOUT_ERROR,
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
Address,
|
||||
Phy,
|
||||
)
|
||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||
|
||||
@@ -95,7 +95,7 @@ class AudioInputStatus(OpenIntEnum):
|
||||
Cf. 3.4 Audio Input Status
|
||||
'''
|
||||
|
||||
INATIVE = 0x00
|
||||
INACTIVE = 0x00
|
||||
ACTIVE = 0x01
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class AudioInputControlPointOpCode(OpenIntEnum):
|
||||
Cf. 3.5.1 Audio Input Control Point procedure requirements
|
||||
'''
|
||||
|
||||
SET_GAIN_SETTING = 0x00
|
||||
SET_GAIN_SETTING = 0x01
|
||||
UNMUTE = 0x02
|
||||
MUTE = 0x03
|
||||
SET_MANUAL_GAIN_MODE = 0x04
|
||||
@@ -239,7 +239,7 @@ class AudioInputControlPoint:
|
||||
or gain_settings_operand
|
||||
> self.gain_settings_properties.gain_settings_maximum
|
||||
):
|
||||
logger.error("gain_seetings value out of range")
|
||||
logger.error("gain_settings value out of range")
|
||||
raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
|
||||
|
||||
if self.audio_input_state.gain_settings != gain_settings_operand:
|
||||
|
||||
@@ -102,6 +102,7 @@ class ContextType(enum.IntFlag):
|
||||
|
||||
# fmt: off
|
||||
PROHIBITED = 0x0000
|
||||
UNSPECIFIED = 0x0001
|
||||
CONVERSATIONAL = 0x0002
|
||||
MEDIA = 0x0004
|
||||
GAME = 0x0008
|
||||
@@ -264,7 +265,7 @@ class UnicastServerAdvertisingData:
|
||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||
struct.pack(
|
||||
'<2sBIB',
|
||||
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
|
||||
bytes(gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE),
|
||||
self.announcement_type,
|
||||
self.available_audio_contexts,
|
||||
len(self.metadata),
|
||||
@@ -350,6 +351,7 @@ class CodecSpecificCapabilities:
|
||||
supported_max_codec_frames_per_sdu = value
|
||||
|
||||
# It is expected here that if some fields are missing, an error should be raised.
|
||||
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||
return CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||
supported_frame_durations=supported_frame_durations,
|
||||
@@ -396,18 +398,21 @@ class CodecSpecificConfiguration:
|
||||
OCTETS_PER_FRAME = 0x04
|
||||
CODEC_FRAMES_PER_SDU = 0x05
|
||||
|
||||
sampling_frequency: SamplingFrequency
|
||||
frame_duration: FrameDuration
|
||||
audio_channel_allocation: AudioLocation
|
||||
octets_per_codec_frame: int
|
||||
codec_frames_per_sdu: int
|
||||
sampling_frequency: SamplingFrequency | None = None
|
||||
frame_duration: FrameDuration | None = None
|
||||
audio_channel_allocation: AudioLocation | None = None
|
||||
octets_per_codec_frame: int | None = None
|
||||
codec_frames_per_sdu: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
|
||||
offset = 0
|
||||
# Allowed default values.
|
||||
audio_channel_allocation = AudioLocation.NOT_ALLOWED
|
||||
codec_frames_per_sdu = 1
|
||||
sampling_frequency: SamplingFrequency | None = None
|
||||
frame_duration: FrameDuration | None = None
|
||||
audio_channel_allocation: AudioLocation | None = None
|
||||
octets_per_codec_frame: int | None = None
|
||||
codec_frames_per_sdu: int | None = None
|
||||
|
||||
while offset < len(data):
|
||||
length, type = struct.unpack_from('BB', data, offset)
|
||||
offset += 2
|
||||
@@ -425,7 +430,6 @@ class CodecSpecificConfiguration:
|
||||
elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
|
||||
codec_frames_per_sdu = value
|
||||
|
||||
# It is expected here that if some fields are missing, an error should be raised.
|
||||
return CodecSpecificConfiguration(
|
||||
sampling_frequency=sampling_frequency,
|
||||
frame_duration=frame_duration,
|
||||
@@ -435,23 +439,43 @@ class CodecSpecificConfiguration:
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return struct.pack(
|
||||
'<BBBBBBBBIBBHBBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
||||
self.sampling_frequency,
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
||||
self.frame_duration,
|
||||
5,
|
||||
CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
|
||||
self.audio_channel_allocation,
|
||||
3,
|
||||
CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
|
||||
self.octets_per_codec_frame,
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
|
||||
self.codec_frames_per_sdu,
|
||||
return b''.join(
|
||||
[
|
||||
struct.pack(fmt, length, tag, value)
|
||||
for fmt, length, tag, value in [
|
||||
(
|
||||
'<BBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
||||
self.sampling_frequency,
|
||||
),
|
||||
(
|
||||
'<BBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
||||
self.frame_duration,
|
||||
),
|
||||
(
|
||||
'<BBI',
|
||||
5,
|
||||
CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
|
||||
self.audio_channel_allocation,
|
||||
),
|
||||
(
|
||||
'<BBH',
|
||||
3,
|
||||
CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
|
||||
self.octets_per_codec_frame,
|
||||
),
|
||||
(
|
||||
'<BBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
|
||||
self.codec_frames_per_sdu,
|
||||
),
|
||||
]
|
||||
if value is not None
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -463,6 +487,24 @@ class BroadcastAudioAnnouncement:
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
return cls(int.from_bytes(data[:3], 'little'))
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.broadcast_id.to_bytes(3, 'little')
|
||||
|
||||
def get_advertising_data(self) -> bytes:
|
||||
return bytes(
|
||||
core.AdvertisingData(
|
||||
[
|
||||
(
|
||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||
(
|
||||
bytes(gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE)
|
||||
+ bytes(self)
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class BasicAudioAnnouncement:
|
||||
@@ -471,26 +513,37 @@ class BasicAudioAnnouncement:
|
||||
index: int
|
||||
codec_specific_configuration: CodecSpecificConfiguration
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CodecInfo:
|
||||
coding_format: hci.CodecID
|
||||
company_id: int
|
||||
vendor_specific_codec_id: int
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
coding_format = hci.CodecID(data[0])
|
||||
company_id = int.from_bytes(data[1:3], 'little')
|
||||
vendor_specific_codec_id = int.from_bytes(data[3:5], 'little')
|
||||
return cls(coding_format, company_id, vendor_specific_codec_id)
|
||||
def __bytes__(self) -> bytes:
|
||||
codec_specific_configuration_bytes = bytes(
|
||||
self.codec_specific_configuration
|
||||
)
|
||||
return (
|
||||
bytes([self.index, len(codec_specific_configuration_bytes)])
|
||||
+ codec_specific_configuration_bytes
|
||||
)
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Subgroup:
|
||||
codec_id: BasicAudioAnnouncement.CodecInfo
|
||||
codec_id: hci.CodingFormat
|
||||
codec_specific_configuration: CodecSpecificConfiguration
|
||||
metadata: le_audio.Metadata
|
||||
bis: List[BasicAudioAnnouncement.BIS]
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
metadata_bytes = bytes(self.metadata)
|
||||
codec_specific_configuration_bytes = bytes(
|
||||
self.codec_specific_configuration
|
||||
)
|
||||
return (
|
||||
bytes([len(self.bis)])
|
||||
+ bytes(self.codec_id)
|
||||
+ bytes([len(codec_specific_configuration_bytes)])
|
||||
+ codec_specific_configuration_bytes
|
||||
+ bytes([len(metadata_bytes)])
|
||||
+ metadata_bytes
|
||||
+ b''.join(map(bytes, self.bis))
|
||||
)
|
||||
|
||||
presentation_delay: int
|
||||
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
||||
|
||||
@@ -502,7 +555,7 @@ class BasicAudioAnnouncement:
|
||||
for _ in range(data[3]):
|
||||
num_bis = data[offset]
|
||||
offset += 1
|
||||
codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
|
||||
codec_id = hci.CodingFormat.from_bytes(data[offset : offset + 5])
|
||||
offset += 5
|
||||
codec_specific_configuration_length = data[offset]
|
||||
offset += 1
|
||||
@@ -546,3 +599,25 @@ class BasicAudioAnnouncement:
|
||||
)
|
||||
|
||||
return cls(presentation_delay, subgroups)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return (
|
||||
self.presentation_delay.to_bytes(3, 'little')
|
||||
+ bytes([len(self.subgroups)])
|
||||
+ b''.join(map(bytes, self.subgroups))
|
||||
)
|
||||
|
||||
def get_advertising_data(self) -> bytes:
|
||||
return bytes(
|
||||
core.AdvertisingData(
|
||||
[
|
||||
(
|
||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||
(
|
||||
bytes(gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE)
|
||||
+ bytes(self)
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -344,9 +344,6 @@ class DataElement:
|
||||
] # Keep a copy so we can re-serialize to an exact replica
|
||||
return result
|
||||
|
||||
def to_bytes(self):
|
||||
return bytes(self)
|
||||
|
||||
def __bytes__(self):
|
||||
# Return early if we have a cache
|
||||
if self.bytes:
|
||||
@@ -434,6 +431,8 @@ class DataElement:
|
||||
if size != 1:
|
||||
raise InvalidArgumentError('boolean must be 1 byte')
|
||||
size_index = 0
|
||||
else:
|
||||
raise RuntimeError("internal error - self.type not supported")
|
||||
|
||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||
return self.bytes
|
||||
@@ -621,11 +620,8 @@ class SDP_PDU:
|
||||
def init_from_bytes(self, pdu, offset):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
||||
|
||||
@@ -298,11 +298,8 @@ class SMP_Command:
|
||||
def init_from_bytes(self, pdu: bytes, offset: int) -> None:
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'yellow')
|
||||
@@ -1839,7 +1836,7 @@ class Session:
|
||||
if self.is_initiator:
|
||||
if self.pairing_method == PairingMethod.OOB:
|
||||
self.send_pairing_random_command()
|
||||
else:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
if self.pairing_method == PairingMethod.PASSKEY:
|
||||
@@ -1949,7 +1946,7 @@ class Manager(EventEmitter):
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
||||
connection.send_l2cap_pdu(cid, bytes(command))
|
||||
|
||||
def on_smp_security_request_command(
|
||||
self, connection: Connection, request: SMP_Security_Request_Command
|
||||
|
||||
@@ -370,11 +370,13 @@ class PumpedPacketSource(ParserSource):
|
||||
self.parser.feed_data(packet)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('source pump task done')
|
||||
self.terminated.set_result(None)
|
||||
if not self.terminated.done():
|
||||
self.terminated.set_result(None)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.warning(f'exception while waiting for packet: {error}')
|
||||
self.terminated.set_exception(error)
|
||||
if not self.terminated.done():
|
||||
self.terminated.set_exception(error)
|
||||
break
|
||||
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
|
||||
@@ -11,32 +11,44 @@ Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Options:
|
||||
--device-config FILENAME Device configuration file
|
||||
--role [sender|receiver|ping|pong]
|
||||
--scenario [send|receive|ping|pong]
|
||||
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||
--extended-data-length TEXT Request a data length upon connection,
|
||||
specified as tx_octets/tx_time
|
||||
--rfcomm-channel INTEGER RFComm channel to use
|
||||
--role-switch [central|peripheral]
|
||||
Request role switch upon connection (central
|
||||
or peripheral)
|
||||
--rfcomm-channel INTEGER RFComm channel to use (specify 0 for channel
|
||||
discovery via SDP)
|
||||
--rfcomm-uuid TEXT RFComm service UUID to use (ignored if
|
||||
--rfcomm-channel is not 0)
|
||||
--rfcomm-l2cap-mtu INTEGER RFComm L2CAP MTU
|
||||
--rfcomm-max-frame-size INTEGER
|
||||
RFComm maximum frame size
|
||||
--rfcomm-initial-credits INTEGER
|
||||
RFComm initial credits
|
||||
--rfcomm-max-credits INTEGER RFComm max credits
|
||||
--rfcomm-credits-threshold INTEGER
|
||||
RFComm credits threshold
|
||||
--l2cap-psm INTEGER L2CAP PSM to use
|
||||
--l2cap-mtu INTEGER L2CAP MTU to use
|
||||
--l2cap-mps INTEGER L2CAP MPS to use
|
||||
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
||||
the peer
|
||||
-s, --packet-size SIZE Packet size (client or ping role)
|
||||
[8<=x<=4096]
|
||||
-c, --packet-count COUNT Packet count (client or ping role)
|
||||
-sd, --start-delay SECONDS Start delay (client or ping role)
|
||||
--repeat N Repeat the run N times (client and ping
|
||||
roles)(0, which is the fault, to run just
|
||||
-s, --packet-size SIZE Packet size (send or ping scenario)
|
||||
[8<=x<=8192]
|
||||
-c, --packet-count COUNT Packet count (send or ping scenario)
|
||||
-sd, --start-delay SECONDS Start delay (send or ping scenario)
|
||||
--repeat N Repeat the run N times (send and ping
|
||||
scenario)(0, which is the fault, to run just
|
||||
once)
|
||||
--repeat-delay SECONDS Delay, in seconds, between repeats
|
||||
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
||||
which is the fault, to send as fast as
|
||||
possible)
|
||||
--linger Don't exit at the end of a run (server and
|
||||
pong roles)
|
||||
--linger Don't exit at the end of a run (receive and
|
||||
pong scenarios)
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
@@ -71,19 +83,19 @@ using the ``--peripheral`` option. The address will be printed by the Peripheral
|
||||
it starts.
|
||||
|
||||
Independently of whether the device is the Central or Peripheral, each device selects a
|
||||
``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral
|
||||
``mode`` and and ``scenario`` to run as. The ``mode`` and ``scenario`` of the Central and Peripheral
|
||||
must be compatible.
|
||||
|
||||
Device 1 mode | Device 2 mode
|
||||
Device 1 scenario | Device 2 scenario
|
||||
------------------|------------------
|
||||
``gatt-client`` | ``gatt-server``
|
||||
``l2cap-client`` | ``l2cap-server``
|
||||
``rfcomm-client`` | ``rfcomm-server``
|
||||
|
||||
Device 1 role | Device 2 role
|
||||
--------------|--------------
|
||||
``sender`` | ``receiver``
|
||||
``ping`` | ``pong``
|
||||
Device 1 scenario | Device 2 scenario
|
||||
------------------|--------------
|
||||
``send`` | ``receive``
|
||||
``ping`` | ``pong``
|
||||
|
||||
|
||||
# Examples
|
||||
@@ -92,7 +104,7 @@ In the following examples, we have two USB Bluetooth controllers, one on `usb:0`
|
||||
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||
|
||||
!!! example "GATT Throughput"
|
||||
Using the default mode and role for the Central and Peripheral.
|
||||
Using the default mode and scenario for the Central and Peripheral.
|
||||
|
||||
In the first console/terminal:
|
||||
```
|
||||
@@ -137,12 +149,12 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||
!!! example "Ping/Pong Latency"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --role pong peripheral usb:0
|
||||
$ bumble-bench --scenario pong peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --role ping central usb:1
|
||||
$ bumble-bench --scenario ping central usb:1
|
||||
```
|
||||
|
||||
!!! example "Reversed modes with GATT and custom connection interval"
|
||||
@@ -167,13 +179,13 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
|
||||
```
|
||||
|
||||
!!! example "Reversed roles with L2CAP"
|
||||
!!! example "Reversed scenarios with L2CAP"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
|
||||
$ bumble-bench --mode l2cap-client --scenario send peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-server --role receiver central usb:1
|
||||
$ bumble-bench --mode l2cap-server --scenario receive central usb:1
|
||||
```
|
||||
|
||||
@@ -3,9 +3,7 @@ GETTING STARTED WITH BUMBLE
|
||||
|
||||
# Prerequisites
|
||||
|
||||
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
|
||||
necessary (there may be some optional functionality that will not work on some platforms with
|
||||
python 3.8).
|
||||
You need Python 3.9 or above.
|
||||
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
||||
for your platform.
|
||||
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
||||
|
||||
@@ -31,7 +31,7 @@ Some of the configurations that may be useful:
|
||||
|
||||
See the [use cases page](use_cases/index.md) for more use cases.
|
||||
|
||||
The project is implemented in Python (Python >= 3.8 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
||||
The project is implemented in Python (Python >= 3.9 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PLATFORMS
|
||||
=========
|
||||
|
||||
Most of the code included in the project should run on any platform that supports Python >= 3.8. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
||||
Most of the code included in the project should run on any platform that supports Python >= 3.9. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
||||
|
||||
For platform-specific information, see the following pages:
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- pip=23
|
||||
- python=3.8
|
||||
- python=3.9
|
||||
- pip:
|
||||
- --editable .[development,documentation,test]
|
||||
|
||||
47
examples/mobly/bench/one_device_bench_test.py
Normal file
47
examples/mobly/bench/one_device_bench_test.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from mobly import base_test
|
||||
from mobly import test_runner
|
||||
from mobly.controllers import android_device
|
||||
|
||||
|
||||
class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||
|
||||
def setup_class(self):
|
||||
self.ads = self.register_controller(android_device)
|
||||
self.dut = self.ads[0]
|
||||
self.dut.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||
|
||||
def test_rfcomm_client_ping(self):
|
||||
runner = self.dut.bench.runRfcommClient(
|
||||
"ping", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_rfcomm_client_send(self):
|
||||
runner = self.dut.bench.runRfcommClient(
|
||||
"send", "DC:E5:5B:E5:51:2C", 100, 970, 0
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_l2cap_client_ping(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_l2cap_client_send(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_runner.main()
|
||||
9
examples/mobly/bench/sample_config.yml
Normal file
9
examples/mobly/bench/sample_config.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
TestBeds:
|
||||
- Name: BenchTestBed
|
||||
Controllers:
|
||||
AndroidDevice:
|
||||
- serial: 37211FDJG000DJ
|
||||
local_bt_address: 94:45:60:5E:03:B0
|
||||
|
||||
- serial: 23071FDEE001F7
|
||||
local_bt_address: DC:E5:5B:E5:51:2C
|
||||
38
examples/mobly/bench/two_devices_bench_test.py
Normal file
38
examples/mobly/bench/two_devices_bench_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import time
|
||||
|
||||
from mobly import base_test
|
||||
from mobly import test_runner
|
||||
from mobly.controllers import android_device
|
||||
|
||||
|
||||
class TwoDevicesBenchTest(base_test.BaseTestClass):
|
||||
def setup_class(self):
|
||||
self.ads = self.register_controller(android_device)
|
||||
self.dut1 = self.ads[0]
|
||||
self.dut1.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||
self.dut2 = self.ads[1]
|
||||
self.dut2.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||
|
||||
def test_rfcomm_client_send_receive(self):
|
||||
print("### Starting Receiver")
|
||||
receiver = self.dut2.bench.runRfcommServer("receive")
|
||||
receiver_id = receiver["id"]
|
||||
print("--- Receiver status:", receiver)
|
||||
while not receiver["model"]["running"]:
|
||||
print("--- Waiting for Receiver to be running...")
|
||||
time.sleep(1)
|
||||
receiver = self.dut2.bench.getRunner(receiver_id)
|
||||
|
||||
print("### Starting Sender")
|
||||
sender = self.dut1.bench.runRfcommClient(
|
||||
"send", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||
)
|
||||
print("--- Sender status:", sender)
|
||||
|
||||
print("--- Waiting for Sender to complete...")
|
||||
sender_result = self.dut1.bench.waitForRunnerCompletion(sender["id"])
|
||||
print("--- Sender result:", sender_result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_runner.main()
|
||||
@@ -64,6 +64,7 @@ async def main() -> None:
|
||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
||||
)
|
||||
|
||||
# pylint: disable=possibly-used-before-assignment
|
||||
if device.host.number_of_supported_advertising_sets >= 2:
|
||||
set2 = await device.create_advertising_set(
|
||||
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
||||
|
||||
@@ -21,9 +21,9 @@ import sys
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
import websockets
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from typing import Optional
|
||||
import websockets
|
||||
|
||||
import bumble.core
|
||||
from bumble.device import Device, ScoLink
|
||||
@@ -82,6 +82,10 @@ def on_microphone_volume(level: int):
|
||||
send_message(type='microphone_volume', level=level)
|
||||
|
||||
|
||||
def on_supported_audio_codecs(codecs: Iterable[hfp.AudioCodec]):
|
||||
send_message(type='supported_audio_codecs', codecs=[codec.name for codec in codecs])
|
||||
|
||||
|
||||
def on_sco_state_change(codec: int):
|
||||
if codec == hfp.AudioCodec.CVSD:
|
||||
sample_rate = 8000
|
||||
@@ -207,6 +211,7 @@ async def main() -> None:
|
||||
ag_protocol = hfp.AgProtocol(dlc, configuration)
|
||||
ag_protocol.on('speaker_volume', on_speaker_volume)
|
||||
ag_protocol.on('microphone_volume', on_microphone_volume)
|
||||
ag_protocol.on('supported_audio_codecs', on_supported_audio_codecs)
|
||||
on_hfp_state_change(True)
|
||||
dlc.multiplexer.l2cap_channel.on(
|
||||
'close', lambda: on_hfp_state_change(False)
|
||||
@@ -241,7 +246,7 @@ async def main() -> None:
|
||||
# Pick the first one
|
||||
channel, version, hf_sdp_features = hfp_record
|
||||
print(f'HF version: {version}')
|
||||
print(f'HF features: {hf_sdp_features}')
|
||||
print(f'HF features: {hf_sdp_features.name}')
|
||||
|
||||
# Request authentication
|
||||
print('*** Authenticating...')
|
||||
|
||||
@@ -57,6 +57,9 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||
]
|
||||
else:
|
||||
raise RuntimeError("unknown active codec")
|
||||
|
||||
connection.abort_on(
|
||||
'disconnection',
|
||||
connection.device.send_command(
|
||||
|
||||
@@ -161,7 +161,13 @@ async def main() -> None:
|
||||
else:
|
||||
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
|
||||
codec_configuration = ase.codec_specific_configuration
|
||||
assert isinstance(codec_configuration, CodecSpecificConfiguration)
|
||||
if (
|
||||
not isinstance(codec_configuration, CodecSpecificConfiguration)
|
||||
or codec_configuration.sampling_frequency is None
|
||||
or codec_configuration.audio_channel_allocation is None
|
||||
or codec_configuration.frame_duration is None
|
||||
):
|
||||
return
|
||||
# Write a LC3 header.
|
||||
file_output.write(
|
||||
bytes([0x1C, 0xCC]) # Header.
|
||||
|
||||
@@ -60,6 +60,8 @@ dependencies {
|
||||
implementation(libs.ui.graphics)
|
||||
implementation(libs.ui.tooling.preview)
|
||||
implementation(libs.material3)
|
||||
implementation(libs.mobly.snippet)
|
||||
implementation(libs.androidx.core)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BTBench"
|
||||
>
|
||||
<meta-data
|
||||
android:name="mobly-snippets"
|
||||
android:value="com.github.google.bumble.btbench.AutomationSnippet"/>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -35,5 +38,7 @@
|
||||
</activity>
|
||||
<!-- <profileable android:shell="true"/>-->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
<instrumentation
|
||||
android:name="com.google.android.mobly.snippet.SnippetRunner"
|
||||
android:targetPackage="com.github.google.bumble.btbench" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// 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.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import com.google.android.mobly.snippet.Snippet;
|
||||
import com.google.android.mobly.snippet.rpc.Rpc;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.security.InvalidParameterException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
class Runner {
|
||||
public UUID mId;
|
||||
private final Mode mMode;
|
||||
private final String mModeName;
|
||||
private final String mScenario;
|
||||
private final AppViewModel mModel;
|
||||
|
||||
Runner(Mode mode, String modeName, String scenario, AppViewModel model) {
|
||||
this.mId = UUID.randomUUID();
|
||||
this.mMode = mode;
|
||||
this.mModeName = modeName;
|
||||
this.mScenario = scenario;
|
||||
this.mModel = model;
|
||||
}
|
||||
|
||||
public JSONObject toJson() throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("id", mId.toString());
|
||||
result.put("mode", mModeName);
|
||||
result.put("scenario", mScenario);
|
||||
result.put("model", AutomationSnippet.modelToJson(mModel));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
mModel.abort();
|
||||
}
|
||||
|
||||
public void waitForCompletion() {
|
||||
mMode.waitForCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
public class AutomationSnippet implements Snippet {
|
||||
private static final String TAG = "btbench.snippet";
|
||||
private final BluetoothAdapter mBluetoothAdapter;
|
||||
private final Context mContext;
|
||||
private final ArrayList<Runner> mRunners = new ArrayList<>();
|
||||
|
||||
public AutomationSnippet() {
|
||||
mContext = ApplicationProvider.getApplicationContext();
|
||||
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
|
||||
mBluetoothAdapter = bluetoothManager.getAdapter();
|
||||
if (mBluetoothAdapter == null) {
|
||||
throw new RuntimeException("bluetooth not supported");
|
||||
}
|
||||
}
|
||||
|
||||
private Runner runScenario(AppViewModel model, String mode, String scenario) {
|
||||
Mode runnable;
|
||||
switch (mode) {
|
||||
case "rfcomm-client":
|
||||
runnable = new RfcommClient(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "rfcomm-server":
|
||||
runnable = new RfcommServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-client":
|
||||
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-server":
|
||||
runnable = new L2capServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
runnable.run();
|
||||
Runner runner = new Runner(runnable, mode, scenario, model);
|
||||
mRunners.add(runner);
|
||||
return runner;
|
||||
}
|
||||
|
||||
private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) {
|
||||
switch (scenario) {
|
||||
case "send":
|
||||
return new Sender(model, packetIO);
|
||||
|
||||
case "receive":
|
||||
return new Receiver(model, packetIO);
|
||||
|
||||
case "ping":
|
||||
return new Pinger(model, packetIO);
|
||||
|
||||
case "pong":
|
||||
return new Ponger(model, packetIO);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static JSONObject modelToJson(AppViewModel model) throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("status", model.getStatus());
|
||||
result.put("running", model.getRunning());
|
||||
result.put("l2cap_psm", model.getL2capPsm());
|
||||
if (model.getStatus().equals("OK")) {
|
||||
JSONObject stats = new JSONObject();
|
||||
result.put("stats", stats);
|
||||
stats.put("throughput", model.getThroughput());
|
||||
JSONObject rttStats = new JSONObject();
|
||||
stats.put("rtt", rttStats);
|
||||
rttStats.put("compound", model.getStats());
|
||||
} else {
|
||||
result.put("last_error", model.getLastError());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Runner findRunner(String runnerId) {
|
||||
for (Runner runner : mRunners) {
|
||||
if (runner.mId.toString().equals(runnerId)) {
|
||||
return runner;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
// 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.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-client", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in RFComm Server mode")
|
||||
public JSONObject runRfcommServer(String scenario) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
// 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();
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
// 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.setL2capPsm(psm);
|
||||
model.setUse2mPhy(use_2m_phy);
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
|
||||
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);
|
||||
|
||||
// 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();
|
||||
|
||||
Runner runner = runScenario(model, "l2cap-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Stop a Runner")
|
||||
public JSONObject stopRunner(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
runner.stop();
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Wait for a Runner to complete")
|
||||
public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
runner.waitForCompletion();
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Get a Runner by ID")
|
||||
public JSONObject getRunner(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Get all Runners")
|
||||
public JSONObject getRunners() throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
JSONArray runners = new JSONArray();
|
||||
result.put("runners", runners);
|
||||
for (Runner runner: mRunners) {
|
||||
runners.put(runner.toJson());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
|
||||
interface IoClient {
|
||||
fun run()
|
||||
fun abort()
|
||||
}
|
||||
@@ -29,10 +29,13 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
|
||||
class L2capClient(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val context: Context
|
||||
) {
|
||||
private val context: Context,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var socketClient: SocketClient? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
override fun run() {
|
||||
viewModel.running = true
|
||||
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
||||
val address = viewModel.peerBluetoothAddress.take(17)
|
||||
@@ -75,6 +78,7 @@ class L2capClient(
|
||||
) {
|
||||
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,
|
||||
@@ -95,7 +99,11 @@ class L2capClient(
|
||||
|
||||
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||
|
||||
val client = SocketClient(viewModel, socket)
|
||||
client.run()
|
||||
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||
socketClient!!.run()
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
socketClient?.waitForCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,17 @@ import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.l2cap-server")
|
||||
|
||||
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
|
||||
class L2capServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var socketServer: SocketServer? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
override fun run() {
|
||||
// Advertise so that the peer can find us and connect.
|
||||
val callback = object: AdvertiseCallback() {
|
||||
val callback = object : AdvertiseCallback() {
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.warning("failed to start advertising: $errorCode")
|
||||
}
|
||||
@@ -55,7 +61,14 @@ class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdap
|
||||
viewModel.l2capPsm = serverSocket.psm
|
||||
Log.info("psm = $serverSocket.psm")
|
||||
|
||||
val server = SocketServer(viewModel, serverSocket)
|
||||
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
|
||||
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||
socketServer!!.run(
|
||||
{ advertiser.stopAdvertising(callback) },
|
||||
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
socketServer?.waitForCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -54,6 +57,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -69,6 +73,9 @@ private val Log = Logger.getLogger("bumble.main-activity")
|
||||
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
||||
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
||||
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"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appViewModel = AppViewModel()
|
||||
@@ -139,10 +146,7 @@ class MainActivity : ComponentActivity() {
|
||||
MainView(
|
||||
appViewModel,
|
||||
::becomeDiscoverable,
|
||||
::runRfcommClient,
|
||||
::runRfcommServer,
|
||||
::runL2capClient,
|
||||
::runL2capServer,
|
||||
::runScenario
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,37 +163,54 @@ class MainActivity : ComponentActivity() {
|
||||
if (packetSize > 0) {
|
||||
appViewModel.senderPacketSize = packetSize
|
||||
}
|
||||
val packetInterval = intent.getIntExtra("packet-interval", 0)
|
||||
if (packetInterval > 0) {
|
||||
appViewModel.senderPacketInterval = packetInterval
|
||||
}
|
||||
appViewModel.updateSenderPacketSizeSlider()
|
||||
intent.getStringExtra("scenario")?.let {
|
||||
when (it) {
|
||||
"send" -> appViewModel.scenario = SEND_SCENARIO
|
||||
"receive" -> appViewModel.scenario = RECEIVE_SCENARIO
|
||||
"ping" -> appViewModel.scenario = PING_SCENARIO
|
||||
"pong" -> appViewModel.scenario = PONG_SCENARIO
|
||||
}
|
||||
}
|
||||
intent.getStringExtra("mode")?.let {
|
||||
when (it) {
|
||||
"rfcomm-client" -> appViewModel.mode = RFCOMM_CLIENT_MODE
|
||||
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
|
||||
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
|
||||
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
|
||||
}
|
||||
}
|
||||
intent.getStringExtra("autostart")?.let {
|
||||
when (it) {
|
||||
"rfcomm-client" -> runRfcommClient()
|
||||
"rfcomm-server" -> runRfcommServer()
|
||||
"l2cap-client" -> runL2capClient()
|
||||
"l2cap-server" -> runL2capServer()
|
||||
"run-scenario" -> runScenario()
|
||||
"scan-start" -> runScan(true)
|
||||
"stop-start" -> runScan(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runRfcommClient() {
|
||||
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
|
||||
rfcommClient?.run()
|
||||
}
|
||||
private fun runScenario() {
|
||||
if (bluetoothAdapter == null) {
|
||||
return
|
||||
}
|
||||
|
||||
private fun runRfcommServer() {
|
||||
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
|
||||
rfcommServer?.run()
|
||||
}
|
||||
|
||||
private fun runL2capClient() {
|
||||
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
|
||||
l2capClient?.run()
|
||||
}
|
||||
|
||||
private fun runL2capServer() {
|
||||
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
|
||||
l2capServer?.run()
|
||||
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
|
||||
)
|
||||
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
runner.run()
|
||||
}
|
||||
|
||||
private fun runScan(startScan: Boolean) {
|
||||
@@ -197,6 +218,17 @@ class MainActivity : ComponentActivity() {
|
||||
scan?.run(startScan)
|
||||
}
|
||||
|
||||
private fun createIoClient(packetIo: PacketIO): IoClient {
|
||||
return when (appViewModel.scenario) {
|
||||
SEND_SCENARIO -> Sender(appViewModel, packetIo)
|
||||
RECEIVE_SCENARIO -> Receiver(appViewModel, packetIo)
|
||||
PING_SCENARIO -> Pinger(appViewModel, packetIo)
|
||||
PONG_SCENARIO -> Ponger(appViewModel, packetIo)
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun becomeDiscoverable() {
|
||||
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||
@@ -210,10 +242,7 @@ class MainActivity : ComponentActivity() {
|
||||
fun MainView(
|
||||
appViewModel: AppViewModel,
|
||||
becomeDiscoverable: () -> Unit,
|
||||
runRfcommClient: () -> Unit,
|
||||
runRfcommServer: () -> Unit,
|
||||
runL2capClient: () -> Unit,
|
||||
runL2capServer: () -> Unit,
|
||||
runScenario: () -> Unit,
|
||||
) {
|
||||
BTBenchTheme {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -239,7 +268,9 @@ fun MainView(
|
||||
Text(text = "Peer Bluetooth Address")
|
||||
},
|
||||
value = appViewModel.peerBluetoothAddress,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||
),
|
||||
@@ -249,14 +280,18 @@ fun MainView(
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}),
|
||||
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||
)
|
||||
Divider()
|
||||
TextField(label = {
|
||||
Text(text = "L2CAP PSM")
|
||||
},
|
||||
TextField(
|
||||
label = {
|
||||
Text(text = "L2CAP PSM")
|
||||
},
|
||||
value = appViewModel.l2capPsm.toString(),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
@@ -271,7 +306,8 @@ fun MainView(
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}),
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||
)
|
||||
Divider()
|
||||
Slider(
|
||||
@@ -290,6 +326,32 @@ fun MainView(
|
||||
)
|
||||
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
||||
Divider()
|
||||
TextField(
|
||||
label = {
|
||||
Text(text = "Packet Interval (ms)")
|
||||
},
|
||||
value = appViewModel.senderPacketInterval.toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
onValueChange = {
|
||||
if (it.isNotEmpty()) {
|
||||
val interval = it.toIntOrNull()
|
||||
if (interval != null) {
|
||||
appViewModel.updateSenderPacketInterval(interval)
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}),
|
||||
enabled = (appViewModel.scenario == PING_SCENARIO)
|
||||
)
|
||||
Divider()
|
||||
ActionButton(
|
||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||
)
|
||||
@@ -300,25 +362,78 @@ fun MainView(
|
||||
Text(text = "2M PHY")
|
||||
Spacer(modifier = Modifier.padding(start = 8.dp))
|
||||
Switch(
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
|
||||
checked = appViewModel.use2mPhy,
|
||||
onCheckedChange = { appViewModel.use2mPhy = it }
|
||||
)
|
||||
|
||||
}
|
||||
Row {
|
||||
ActionButton(
|
||||
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
||||
)
|
||||
ActionButton(
|
||||
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
||||
)
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
RFCOMM_CLIENT_MODE,
|
||||
RFCOMM_SERVER_MODE,
|
||||
L2CAP_CLIENT_MODE,
|
||||
L2CAP_SERVER_MODE
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
.selectable(
|
||||
selected = (text == appViewModel.mode),
|
||||
onClick = { appViewModel.updateMode(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.mode),
|
||||
onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
SEND_SCENARIO,
|
||||
RECEIVE_SCENARIO,
|
||||
PING_SCENARIO,
|
||||
PONG_SCENARIO
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
.selectable(
|
||||
selected = (text == appViewModel.scenario),
|
||||
onClick = { appViewModel.updateScenario(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.scenario),
|
||||
onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
ActionButton(
|
||||
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
||||
text = "Start", onClick = runScenario, enabled = !appViewModel.running
|
||||
)
|
||||
ActionButton(
|
||||
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
|
||||
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
@@ -328,6 +443,12 @@ fun MainView(
|
||||
Text(
|
||||
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
||||
)
|
||||
Text(
|
||||
text = "Status: ${appViewModel.status}"
|
||||
)
|
||||
Text(
|
||||
text = "Last Error: ${appViewModel.lastError}"
|
||||
)
|
||||
Text(
|
||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||
)
|
||||
@@ -337,9 +458,8 @@ fun MainView(
|
||||
Text(
|
||||
text = "Throughput: ${appViewModel.throughput}"
|
||||
)
|
||||
Divider()
|
||||
ActionButton(
|
||||
text = "Abort", onClick = appViewModel::abort, appViewModel.running
|
||||
Text(
|
||||
text = "Stats: ${appViewModel.stats}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -351,4 +471,4 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
|
||||
Button(onClick = onClick, enabled = enabled) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
|
||||
interface Mode {
|
||||
fun run()
|
||||
fun waitForCompletion()
|
||||
}
|
||||
@@ -27,10 +27,25 @@ val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF
|
||||
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
|
||||
const val DEFAULT_PSM = 128
|
||||
|
||||
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 SEND_SCENARIO = "Send"
|
||||
const val RECEIVE_SCENARIO = "Receive"
|
||||
const val PING_SCENARIO = "Ping"
|
||||
const val PONG_SCENARIO = "Pong"
|
||||
|
||||
class AppViewModel : ViewModel() {
|
||||
private var preferences: SharedPreferences? = null
|
||||
var status by mutableStateOf("")
|
||||
var lastError by mutableStateOf("")
|
||||
var mode by mutableStateOf(RFCOMM_SERVER_MODE)
|
||||
var scenario by mutableStateOf(RECEIVE_SCENARIO)
|
||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||
var use2mPhy by mutableStateOf(true)
|
||||
@@ -41,9 +56,11 @@ class AppViewModel : ViewModel() {
|
||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
||||
var senderPacketInterval by mutableIntStateOf(DEFAULT_SENDER_PACKET_INTERVAL)
|
||||
var packetsSent by mutableIntStateOf(0)
|
||||
var packetsReceived by mutableIntStateOf(0)
|
||||
var throughput by mutableIntStateOf(0)
|
||||
var stats by mutableStateOf("")
|
||||
var running by mutableStateOf(false)
|
||||
var aborter: (() -> Unit)? = null
|
||||
|
||||
@@ -66,6 +83,21 @@ class AppViewModel : ViewModel() {
|
||||
senderPacketSize = savedSenderPacketSize
|
||||
}
|
||||
updateSenderPacketSizeSlider()
|
||||
|
||||
val savedSenderPacketInterval = preferences.getInt(SENDER_PACKET_INTERVAL_PREF_KEY, -1)
|
||||
if (savedSenderPacketInterval != -1) {
|
||||
senderPacketInterval = savedSenderPacketInterval
|
||||
}
|
||||
|
||||
val savedMode = preferences.getString(MODE_PREF_KEY, null)
|
||||
if (savedMode != null) {
|
||||
mode = savedMode
|
||||
}
|
||||
|
||||
val savedScenario = preferences.getString(SCENARIO_PREF_KEY, null)
|
||||
if (savedScenario != null) {
|
||||
scenario = savedScenario
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||
@@ -164,6 +196,42 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSenderPacketInterval(senderPacketInterval: Int) {
|
||||
this.senderPacketInterval = senderPacketInterval
|
||||
with(preferences!!.edit()) {
|
||||
putInt(SENDER_PACKET_INTERVAL_PREF_KEY, senderPacketInterval)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateScenario(scenario: String) {
|
||||
this.scenario = scenario
|
||||
with(preferences!!.edit()) {
|
||||
putString(SCENARIO_PREF_KEY, scenario)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMode(mode: String) {
|
||||
this.mode = mode
|
||||
with(preferences!!.edit()) {
|
||||
putString(MODE_PREF_KEY, mode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
status = ""
|
||||
lastError = ""
|
||||
mtu = 0
|
||||
rxPhy = 0
|
||||
txPhy = 0
|
||||
packetsSent = 0
|
||||
packetsReceived = 0
|
||||
throughput = 0
|
||||
stats = ""
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
aborter?.let { it() }
|
||||
}
|
||||
|
||||
@@ -74,13 +74,13 @@ abstract class PacketSink {
|
||||
fun onPacket(packet: Packet) {
|
||||
when (packet) {
|
||||
is ResetPacket -> onResetPacket()
|
||||
is AckPacket -> onAckPacket()
|
||||
is AckPacket -> onAckPacket(packet)
|
||||
is SequencePacket -> onSequencePacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun onResetPacket()
|
||||
abstract fun onAckPacket()
|
||||
abstract fun onAckPacket(packet: AckPacket)
|
||||
abstract fun onSequencePacket(packet: SequencePacket)
|
||||
}
|
||||
|
||||
@@ -175,4 +175,4 @@ class SocketDataSource(
|
||||
} while (true)
|
||||
Log.info("end of stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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.concurrent.Semaphore
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||
|
||||
private val Log = Logger.getLogger("btbench.pinger")
|
||||
|
||||
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||
PacketSink() {
|
||||
private val pingTimes: ArrayList<TimeSource.Monotonic.ValueTimeMark> = ArrayList()
|
||||
private val rtts: ArrayList<Long> = ArrayList()
|
||||
private val done = Semaphore(0)
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
|
||||
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("running")
|
||||
|
||||
Log.info("sending reset")
|
||||
packetIO.sendPacket(ResetPacket())
|
||||
|
||||
val packetCount = viewModel.senderPacketCount
|
||||
val packetSize = viewModel.senderPacketSize
|
||||
|
||||
val startTime = TimeSource.Monotonic.markNow()
|
||||
for (i in 0..<packetCount) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||
val delay = targetTime - now
|
||||
if (delay.isPositive()) {
|
||||
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||
Thread.sleep(delay.inWholeMilliseconds)
|
||||
}
|
||||
pingTimes.add(TimeSource.Monotonic.markNow())
|
||||
packetIO.sendPacket(
|
||||
SequencePacket(
|
||||
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
|
||||
i,
|
||||
ByteArray(packetSize - 6)
|
||||
)
|
||||
)
|
||||
viewModel.packetsSent = i + 1
|
||||
}
|
||||
|
||||
// Wait for the last ACK
|
||||
Log.info("waiting for last ACK")
|
||||
done.acquire()
|
||||
Log.info("got last ACK")
|
||||
}
|
||||
|
||||
override fun abort() {
|
||||
done.release()
|
||||
}
|
||||
|
||||
override fun onResetPacket() {
|
||||
}
|
||||
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
viewModel.packetsReceived += 1
|
||||
if (packet.sequenceNumber < pingTimes.size) {
|
||||
val rtt = (now - pingTimes[packet.sequenceNumber]).inWholeMilliseconds
|
||||
rtts.add(rtt)
|
||||
Log.info("received ACK ${packet.sequenceNumber}, RTT=$rtt")
|
||||
} else {
|
||||
Log.warning("received ACK with unexpected sequence ${packet.sequenceNumber}")
|
||||
}
|
||||
|
||||
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||
Log.info("last packet received")
|
||||
val stats = "RTTs: min=${rtts.min()}, max=${rtts.max()}, avg=${rtts.sum() / rtts.size}"
|
||||
Log.info(stats)
|
||||
viewModel.stats = stats
|
||||
done.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSequencePacket(packet: SequencePacket) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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.logging.Logger
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private val Log = Logger.getLogger("btbench.receiver")
|
||||
|
||||
class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var expectedSequenceNumber: Int = 0
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
}
|
||||
|
||||
override fun abort() {}
|
||||
|
||||
override fun onResetPacket() {
|
||||
startTime = TimeSource.Monotonic.markNow()
|
||||
lastPacketTime = startTime
|
||||
expectedSequenceNumber = 0
|
||||
viewModel.packetsSent = 0
|
||||
viewModel.packetsReceived = 0
|
||||
viewModel.stats = ""
|
||||
}
|
||||
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
}
|
||||
|
||||
override fun onSequencePacket(packet: SequencePacket) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
lastPacketTime = now
|
||||
viewModel.packetsReceived += 1
|
||||
|
||||
if (packet.sequenceNumber != expectedSequenceNumber) {
|
||||
Log.warning("unexpected packet sequence number (expected ${expectedSequenceNumber}, got ${packet.sequenceNumber})")
|
||||
}
|
||||
expectedSequenceNumber += 1
|
||||
|
||||
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||
viewModel.packetsSent += 1
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import kotlin.time.TimeSource
|
||||
|
||||
private val Log = Logger.getLogger("btbench.receiver")
|
||||
|
||||
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
|
||||
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var bytesReceived = 0
|
||||
@@ -29,6 +29,12 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
}
|
||||
|
||||
override fun abort() {}
|
||||
|
||||
override fun onResetPacket() {
|
||||
startTime = TimeSource.Monotonic.markNow()
|
||||
lastPacketTime = startTime
|
||||
@@ -36,9 +42,10 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
||||
viewModel.throughput = 0
|
||||
viewModel.packetsSent = 0
|
||||
viewModel.packetsReceived = 0
|
||||
viewModel.stats = ""
|
||||
}
|
||||
|
||||
override fun onAckPacket() {
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -16,22 +16,30 @@ package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import java.io.IOException
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.rfcomm-client")
|
||||
|
||||
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||
class RfcommClient(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var socketClient: SocketClient? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
override fun run() {
|
||||
val address = viewModel.peerBluetoothAddress.take(17)
|
||||
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
||||
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
||||
DEFAULT_RFCOMM_UUID
|
||||
)
|
||||
|
||||
val client = SocketClient(viewModel, socket)
|
||||
client.run()
|
||||
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||
socketClient!!.run()
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
socketClient?.waitForCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,27 @@ package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import java.io.IOException
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
||||
|
||||
class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||
class RfcommServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var socketServer: SocketServer? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
override fun run() {
|
||||
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
||||
"BumbleBench", DEFAULT_RFCOMM_UUID
|
||||
)
|
||||
|
||||
val server = SocketServer(viewModel, serverSocket)
|
||||
server.run({}, {})
|
||||
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||
socketServer!!.run({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
socketServer?.waitForCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ class Scan(val bluetoothAdapter: BluetoothAdapter) {
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,12 @@ import java.util.logging.Logger
|
||||
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) : PacketSink() {
|
||||
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||
PacketSink() {
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var bytesSent = 0
|
||||
private val done = Semaphore(0)
|
||||
@@ -30,10 +33,12 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
fun run() {
|
||||
viewModel.packetsSent = 0
|
||||
viewModel.packetsReceived = 0
|
||||
viewModel.throughput = 0
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
|
||||
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("running")
|
||||
|
||||
Log.info("sending reset")
|
||||
packetIO.sendPacket(ResetPacket())
|
||||
@@ -63,14 +68,14 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
Log.info("got ACK")
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
override fun abort() {
|
||||
done.release()
|
||||
}
|
||||
|
||||
override fun onResetPacket() {
|
||||
}
|
||||
|
||||
override fun onAckPacket() {
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
Log.info("received ACK")
|
||||
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
||||
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
||||
@@ -81,4 +86,4 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
|
||||
override fun onSequencePacket(packet: SequencePacket) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,16 +22,20 @@ import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.socket-client")
|
||||
|
||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||
class SocketClient(
|
||||
private val viewModel: AppViewModel,
|
||||
private val socket: BluetoothSocket,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) {
|
||||
private var clientThread: Thread? = null
|
||||
|
||||
class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
viewModel.running = true
|
||||
val socketDataSink = SocketDataSink(socket)
|
||||
val streamIO = StreamedPacketIO(socketDataSink)
|
||||
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||
val sender = Sender(viewModel, streamIO)
|
||||
val ioClient = createIoClient(streamIO)
|
||||
|
||||
fun cleanup() {
|
||||
socket.close()
|
||||
@@ -39,9 +43,9 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
||||
viewModel.running = false
|
||||
}
|
||||
|
||||
thread(name = "SocketClient") {
|
||||
clientThread = thread(name = "SocketClient") {
|
||||
viewModel.aborter = {
|
||||
sender.abort()
|
||||
ioClient.abort()
|
||||
socket.close()
|
||||
}
|
||||
Log.info("connecting to remote")
|
||||
@@ -49,27 +53,37 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
||||
socket.connect()
|
||||
} catch (error: IOException) {
|
||||
Log.warning("connection failed")
|
||||
viewModel.status = "ABORTED"
|
||||
viewModel.lastError = "CONNECTION_FAILED"
|
||||
cleanup()
|
||||
return@thread
|
||||
}
|
||||
Log.info("connected")
|
||||
|
||||
thread {
|
||||
val sourceThread = thread {
|
||||
socketDataSource.receive()
|
||||
socket.close()
|
||||
sender.abort()
|
||||
ioClient.abort()
|
||||
}
|
||||
|
||||
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("Starting to send")
|
||||
|
||||
try {
|
||||
sender.run()
|
||||
ioClient.run()
|
||||
socket.close()
|
||||
viewModel.status = "OK"
|
||||
} catch (error: IOException) {
|
||||
Log.info("run ended abruptly")
|
||||
viewModel.status = "ABORTED"
|
||||
viewModel.lastError = "IO_ERROR"
|
||||
}
|
||||
|
||||
Log.info("waiting for source thread to finish")
|
||||
sourceThread.join()
|
||||
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForCompletion() {
|
||||
clientThread?.join()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.socket-server")
|
||||
|
||||
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
||||
class SocketServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val serverSocket: BluetoothServerSocket,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) {
|
||||
private var serverThread: Thread? = null
|
||||
|
||||
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
|
||||
var aborted = false
|
||||
viewModel.running = true
|
||||
@@ -31,7 +37,7 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
viewModel.running = false
|
||||
}
|
||||
|
||||
thread(name = "SocketServer") {
|
||||
serverThread = thread(name = "SocketServer") {
|
||||
while (!aborted) {
|
||||
viewModel.aborter = {
|
||||
serverSocket.close()
|
||||
@@ -46,6 +52,8 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
return@thread
|
||||
}
|
||||
Log.info("got connection from ${socket.remoteDevice.address}")
|
||||
Log.info("maxReceivePacketSize=${socket.maxReceivePacketSize}")
|
||||
Log.info("maxTransmitPacketSize=${socket.maxTransmitPacketSize}")
|
||||
onConnected()
|
||||
|
||||
viewModel.aborter = {
|
||||
@@ -57,11 +65,22 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
val socketDataSink = SocketDataSink(socket)
|
||||
val streamIO = StreamedPacketIO(socketDataSink)
|
||||
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||
val receiver = Receiver(viewModel, streamIO)
|
||||
|
||||
val ioThread = thread(name = "IoClient") {
|
||||
val ioClient = createIoClient(streamIO)
|
||||
ioClient.run()
|
||||
}
|
||||
|
||||
socketDataSource.receive()
|
||||
socket.close()
|
||||
ioThread.join()
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForCompletion() {
|
||||
serverThread?.join()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.2.0"
|
||||
agp = "8.4.0"
|
||||
kotlin = "1.9.0"
|
||||
core-ktx = "1.12.0"
|
||||
junit = "4.13.2"
|
||||
@@ -8,6 +8,8 @@ espresso-core = "3.5.1"
|
||||
lifecycle-runtime-ktx = "2.6.2"
|
||||
activity-compose = "1.7.2"
|
||||
compose-bom = "2023.08.00"
|
||||
mobly-snippet = "1.4.0"
|
||||
core = "1.6.1"
|
||||
|
||||
[libraries]
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
||||
@@ -24,6 +26,8 @@ ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview
|
||||
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
mobly-snippet = { group = "com.google.android.mobly", name = "mobly-snippet-lib", version.ref = "mobly.snippet" }
|
||||
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#Wed Oct 25 07:40:52 PDT 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -23,6 +23,7 @@ public class HciProxy {
|
||||
HciHal hciHal = HciHal.create(new HciHalCallback() {
|
||||
@Override
|
||||
public void onPacket(HciPacket.Type type, byte[] packet) {
|
||||
Log.d(TAG, String.format("CONTROLLER->HOST: type=%s, size=%d", type, packet.length));
|
||||
mServer.sendPacket(type, packet);
|
||||
|
||||
switch (type) {
|
||||
@@ -83,7 +84,7 @@ public class HciProxy {
|
||||
|
||||
@Override
|
||||
public void onPacket(HciPacket.Type type, byte[] packet) {
|
||||
Log.d(TAG, String.format("onPacket: type=%s, size=%d", type, packet.length));
|
||||
Log.d(TAG, String.format("HOST->CONTROLLER: type=%s, size=%d", type, packet.length));
|
||||
hciHal.sendPacket(type, packet);
|
||||
|
||||
switch (type) {
|
||||
|
||||
@@ -45,6 +45,7 @@ ignore="pandora" # FIXME: pylint does not support stubs yet:
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
signature-mutators="AsyncRunner.run_in_task"
|
||||
disable="not-callable"
|
||||
|
||||
[tool.black]
|
||||
skip-string-normalization = true
|
||||
@@ -55,7 +56,7 @@ extend-exclude = '''
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
exclude = ['bumble/transport/grpc_protobuf']
|
||||
exclude = ['bumble/transport/grpc_protobuf', 'examples/mobly/bench']
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "bumble.transport.grpc_protobuf.*"
|
||||
|
||||
@@ -80,7 +80,7 @@ impl Address {
|
||||
/// Creates a new [Address] object.
|
||||
pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.device"))?
|
||||
PyModule::import(py, intern!(py, "bumble.hci"))?
|
||||
.getattr(intern!(py, "Address"))?
|
||||
.call1((address, address_type))
|
||||
.map(|any| Self(any.into()))
|
||||
|
||||
@@ -51,7 +51,7 @@ install_requires =
|
||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
||||
pyserial >= 3.5; platform_system!='Emscripten'
|
||||
pyusb >= 1.2; platform_system!='Emscripten'
|
||||
websockets >= 12.0; platform_system!='Emscripten'
|
||||
websockets == 13.1; platform_system!='Emscripten'
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
@@ -91,9 +91,10 @@ development =
|
||||
black == 24.3
|
||||
grpcio-tools >= 1.62.1
|
||||
invoke >= 1.7.3
|
||||
mypy == 1.10.0
|
||||
mobly >= 1.12.2
|
||||
mypy == 1.12.0
|
||||
nox >= 2022
|
||||
pylint == 3.1.0
|
||||
pylint == 3.3.1
|
||||
pyyaml >= 6.0
|
||||
types-appdirs >= 1.4.3
|
||||
types-invoke >= 1.7.3
|
||||
|
||||
@@ -39,6 +39,8 @@ from bumble.profiles.ascs import (
|
||||
)
|
||||
from bumble.profiles.bap import (
|
||||
AudioLocation,
|
||||
BasicAudioAnnouncement,
|
||||
BroadcastAudioAnnouncement,
|
||||
SupportedFrameDuration,
|
||||
SupportedSamplingFrequency,
|
||||
SamplingFrequency,
|
||||
@@ -200,6 +202,56 @@ def test_codec_specific_configuration() -> None:
|
||||
assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_broadcast_audio_announcement() -> None:
|
||||
broadcast_audio_announcement = BroadcastAudioAnnouncement(123456)
|
||||
assert (
|
||||
BroadcastAudioAnnouncement.from_bytes(bytes(broadcast_audio_announcement))
|
||||
== broadcast_audio_announcement
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_basic_audio_announcement() -> None:
|
||||
basic_audio_announcement = BasicAudioAnnouncement(
|
||||
presentation_delay=40000,
|
||||
subgroups=[
|
||||
BasicAudioAnnouncement.Subgroup(
|
||||
codec_id=CodingFormat(codec_id=CodecID.LC3),
|
||||
codec_specific_configuration=CodecSpecificConfiguration(
|
||||
sampling_frequency=SamplingFrequency.FREQ_48000,
|
||||
frame_duration=FrameDuration.DURATION_10000_US,
|
||||
octets_per_codec_frame=100,
|
||||
),
|
||||
metadata=Metadata(
|
||||
[
|
||||
Metadata.Entry(tag=Metadata.Tag.LANGUAGE, data=b'eng'),
|
||||
Metadata.Entry(tag=Metadata.Tag.PROGRAM_INFO, data=b'Disco'),
|
||||
]
|
||||
),
|
||||
bis=[
|
||||
BasicAudioAnnouncement.BIS(
|
||||
index=0,
|
||||
codec_specific_configuration=CodecSpecificConfiguration(
|
||||
audio_channel_allocation=AudioLocation.FRONT_LEFT
|
||||
),
|
||||
),
|
||||
BasicAudioAnnouncement.BIS(
|
||||
index=1,
|
||||
codec_specific_configuration=CodecSpecificConfiguration(
|
||||
audio_channel_allocation=AudioLocation.FRONT_RIGHT
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert (
|
||||
BasicAudioAnnouncement.from_bytes(bytes(basic_audio_announcement))
|
||||
== basic_audio_announcement
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_pacs():
|
||||
|
||||
@@ -19,9 +19,7 @@ import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from types import LambdaType
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from bumble.core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
@@ -29,7 +27,13 @@ from bumble.core import (
|
||||
BT_PERIPHERAL_ROLE,
|
||||
ConnectionParameters,
|
||||
)
|
||||
from bumble.device import AdvertisingParameters, Connection, Device
|
||||
from bumble.device import (
|
||||
AdvertisingEventProperties,
|
||||
AdvertisingParameters,
|
||||
Connection,
|
||||
Device,
|
||||
PeriodicAdvertisingParameters,
|
||||
)
|
||||
from bumble.host import AclPacketQueue, Host
|
||||
from bumble.hci import (
|
||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||
@@ -265,7 +269,8 @@ async def test_flush():
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_advertising():
|
||||
device = Device(host=mock.AsyncMock(Host))
|
||||
device = TwoDevices()[0]
|
||||
await device.power_on()
|
||||
|
||||
# Start advertising
|
||||
await device.start_advertising()
|
||||
@@ -283,7 +288,10 @@ async def test_legacy_advertising():
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_advertising_disconnection(auto_restart):
|
||||
device = Device(host=mock.AsyncMock(spec=Host))
|
||||
devices = TwoDevices()
|
||||
device = devices[0]
|
||||
devices.controllers[0].le_features = bytes.fromhex('ffffffffffffffff')
|
||||
await device.power_on()
|
||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||
await device.start_advertising(auto_restart=auto_restart)
|
||||
device.on_connection(
|
||||
@@ -305,6 +313,11 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
||||
await async_barrier()
|
||||
|
||||
if auto_restart:
|
||||
assert device.legacy_advertising_set
|
||||
started = asyncio.Event()
|
||||
if not device.is_advertising:
|
||||
device.legacy_advertising_set.once('start', started.set)
|
||||
await asyncio.wait_for(started.wait(), _TIMEOUT)
|
||||
assert device.is_advertising
|
||||
else:
|
||||
assert not device.is_advertising
|
||||
@@ -313,7 +326,8 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_extended_advertising():
|
||||
device = Device(host=mock.AsyncMock(Host))
|
||||
device = TwoDevices()[0]
|
||||
await device.power_on()
|
||||
|
||||
# Start advertising
|
||||
advertising_set = await device.create_advertising_set()
|
||||
@@ -332,7 +346,8 @@ async def test_extended_advertising():
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_extended_advertising_connection(own_address_type):
|
||||
device = Device(host=mock.AsyncMock(spec=Host))
|
||||
device = TwoDevices()[0]
|
||||
await device.power_on()
|
||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||
advertising_set = await device.create_advertising_set(
|
||||
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
||||
@@ -368,8 +383,10 @@ async def test_extended_advertising_connection(own_address_type):
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_extended_advertising_connection_out_of_order(own_address_type):
|
||||
device = Device(host=mock.AsyncMock(spec=Host))
|
||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||
devices = TwoDevices()
|
||||
device = devices[0]
|
||||
devices.controllers[0].le_features = bytes.fromhex('ffffffffffffffff')
|
||||
await device.power_on()
|
||||
advertising_set = await device.create_advertising_set(
|
||||
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
||||
)
|
||||
@@ -382,7 +399,7 @@ async def test_extended_advertising_connection_out_of_order(own_address_type):
|
||||
device.on_connection(
|
||||
0x0001,
|
||||
BT_LE_TRANSPORT,
|
||||
peer_address,
|
||||
Address('F0:F1:F2:F3:F4:F5'),
|
||||
None,
|
||||
None,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
@@ -397,6 +414,34 @@ async def test_extended_advertising_connection_out_of_order(own_address_type):
|
||||
await async_barrier()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_advertising():
|
||||
device = TwoDevices()[0]
|
||||
await device.power_on()
|
||||
|
||||
# Start advertising
|
||||
advertising_set = await device.create_advertising_set(
|
||||
advertising_parameters=AdvertisingParameters(
|
||||
advertising_event_properties=AdvertisingEventProperties(
|
||||
is_connectable=False
|
||||
)
|
||||
),
|
||||
advertising_data=b'123',
|
||||
periodic_advertising_parameters=PeriodicAdvertisingParameters(),
|
||||
periodic_advertising_data=b'abc',
|
||||
)
|
||||
assert device.extended_advertising_sets
|
||||
assert advertising_set.enabled
|
||||
assert not advertising_set.periodic_enabled
|
||||
|
||||
await advertising_set.start_periodic()
|
||||
assert advertising_set.periodic_enabled
|
||||
|
||||
await advertising_set.stop_periodic()
|
||||
assert not advertising_set.periodic_enabled
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_remote_le_features():
|
||||
|
||||
@@ -57,7 +57,7 @@ from .test_utils import async_barrier
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def basic_check(x):
|
||||
pdu = x.to_bytes()
|
||||
pdu = bytes(x)
|
||||
parsed = ATT_PDU.from_bytes(pdu)
|
||||
x_str = str(x)
|
||||
parsed_str = str(parsed)
|
||||
@@ -74,7 +74,7 @@ def test_UUID():
|
||||
assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
v = UUID(str(u))
|
||||
assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
w = UUID.from_bytes(v.to_bytes())
|
||||
w = UUID.from_bytes(bytes(v))
|
||||
assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
|
||||
u1 = UUID.from_16_bits(0x1234)
|
||||
@@ -851,7 +851,12 @@ async def test_unsubscribe():
|
||||
await async_barrier()
|
||||
mock1.assert_called_once_with(ANY, True, False)
|
||||
|
||||
await c2.subscribe()
|
||||
assert len(server.gatt_server.subscribers) == 1
|
||||
|
||||
def callback(_):
|
||||
pass
|
||||
|
||||
await c2.subscribe(callback)
|
||||
await async_barrier()
|
||||
mock2.assert_called_once_with(ANY, True, False)
|
||||
|
||||
@@ -861,10 +866,16 @@ async def test_unsubscribe():
|
||||
mock1.assert_called_once_with(ANY, False, False)
|
||||
|
||||
mock2.reset_mock()
|
||||
await c2.unsubscribe()
|
||||
await c2.unsubscribe(callback)
|
||||
await async_barrier()
|
||||
mock2.assert_called_once_with(ANY, False, False)
|
||||
|
||||
# All CCCDs should be zeros now
|
||||
assert list(server.gatt_server.subscribers.values())[0] == {
|
||||
c1.handle: bytes([0, 0]),
|
||||
c2.handle: bytes([0, 0]),
|
||||
}
|
||||
|
||||
mock1.reset_mock()
|
||||
await c1.unsubscribe()
|
||||
await async_barrier()
|
||||
|
||||
@@ -75,13 +75,13 @@ from bumble.hci import (
|
||||
|
||||
|
||||
def basic_check(x):
|
||||
packet = x.to_bytes()
|
||||
packet = bytes(x)
|
||||
print(packet.hex())
|
||||
parsed = HCI_Packet.from_bytes(packet)
|
||||
x_str = str(x)
|
||||
parsed_str = str(parsed)
|
||||
print(x_str)
|
||||
parsed_bytes = parsed.to_bytes()
|
||||
parsed_bytes = bytes(parsed)
|
||||
assert x_str == parsed_str
|
||||
assert packet == parsed_bytes
|
||||
|
||||
@@ -188,7 +188,7 @@ def test_HCI_Command_Complete_Event():
|
||||
return_parameters=bytes([7]),
|
||||
)
|
||||
basic_check(event)
|
||||
event = HCI_Packet.from_bytes(event.to_bytes())
|
||||
event = HCI_Packet.from_bytes(bytes(event))
|
||||
assert event.return_parameters == 7
|
||||
|
||||
# With a simple status as an integer status
|
||||
@@ -562,7 +562,7 @@ def test_iso_data_packet():
|
||||
'6281bc77ed6a3206d984bcdabee6be831c699cb50e2'
|
||||
)
|
||||
|
||||
assert packet.to_bytes() == data
|
||||
assert bytes(packet) == data
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -61,7 +61,7 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
|
||||
# -----------------------------------------------------------------------------
|
||||
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
|
||||
return (
|
||||
hfp.HfSdpFeature.WIDE_BAND
|
||||
hfp.HfSdpFeature.WIDE_BAND_SPEECH
|
||||
| hfp.HfSdpFeature.THREE_WAY_CALLING
|
||||
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||
)
|
||||
@@ -108,7 +108,7 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
|
||||
# -----------------------------------------------------------------------------
|
||||
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
|
||||
return (
|
||||
hfp.AgSdpFeature.WIDE_BAND
|
||||
hfp.AgSdpFeature.WIDE_BAND_SPEECH
|
||||
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||
| hfp.AgSdpFeature.THREE_WAY_CALLING
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ async def test_self_gatt():
|
||||
result = await peer.discover_included_services(result[0])
|
||||
assert len(result) == 2
|
||||
# Service UUID is only present when the UUID is 16-bit Bluetooth UUID
|
||||
assert result[1].uuid.to_bytes() == s3.uuid.to_bytes()
|
||||
assert bytes(result[1].uuid) == bytes(s3.uuid)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user