Compare commits

...

31 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
3dc2e4036c rebase 2023-11-22 15:32:37 -08:00
Gilles Boccon-Gibod
268f6b0d51 remove unneeded constructor parameters 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
46239b321b address PR comments 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
8a536cd522 fix missed merge 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
f9f5d7ccbd first implementation (+1 squashed commit)
Squashed commits:
[ee00d67] wip
2023-11-22 15:30:16 -08:00
zxzxwu
e08c84dd20 Merge pull request #333 from zxzxwu/iso
Add ISO related HCI packets
2023-11-21 15:55:00 +08:00
Josh Wu
8b46136703 Add ISO related HCI packets 2023-11-20 22:47:02 +08:00
Gilles Boccon-Gibod
aac8d89cd0 Merge pull request #330 from benquike/main
Do not exit after pairing is finished
2023-11-18 08:57:58 -08:00
Hui Peng
24e75bfeab Do not exit after pairing is finished
Android performs additional service
discovery during pairing, otherwise
pairing fails.
2023-11-17 09:17:40 -08:00
zxzxwu
42868b08d3 Merge pull request #335 from zxzxwu/a2dp
Typing A2DP
2023-11-18 00:21:20 +08:00
zxzxwu
19b61d9ac0 Merge pull request #336 from zxzxwu/hid
Cleanup HID module
2023-11-17 23:34:03 +08:00
Josh Wu
db2a2e2bb9 Cleanup HID module
* Remove unused imports
* Replace typing exceptions by better assertions
2023-11-17 17:43:07 +08:00
Josh Wu
e1fdb12647 Typing A2DP 2023-11-17 17:29:35 +08:00
Gilles Boccon-Gibod
2e30b2de77 Merge pull request #329 from google/gbg/le-oob
le oob
2023-11-15 16:10:20 -08:00
Gilles Boccon-Gibod
7e407ccae1 address PR comments 2023-11-15 15:48:19 -08:00
zxzxwu
0667e83919 Merge pull request #254 from zxzxwu/sco
eSCO codec/HCI definitions + Host support
2023-11-13 20:01:06 +08:00
Gilles Boccon-Gibod
1a6c9a4d04 improve help 2023-11-10 12:17:21 -08:00
Gilles Boccon-Gibod
14f5b912ad use ad_data directly 2023-11-10 11:53:54 -08:00
Gilles Boccon-Gibod
46d6242171 Merge pull request #316 from whitevegagabriel/extended
Add support for extended advertising via Rust-only API
2023-11-09 13:43:00 -08:00
Gilles Boccon-Gibod
753b966148 format 2023-11-09 12:44:02 -08:00
Gilles Boccon-Gibod
5a307c19b8 add oob data on command line 2023-11-07 20:38:35 -08:00
Lucas Abel
2cd4f84800 pandora: add annotations import 2023-11-06 14:06:56 -08:00
Gilles Boccon-Gibod
4ae612090b wip 2023-11-06 13:19:13 -08:00
Gilles Boccon-Gibod
c67ca4a09e Merge pull request #324 from google/gbg/hotfix-002
fix typo
2023-10-31 20:58:19 +01:00
Gilles Boccon-Gibod
94506220d3 fix typo 2023-10-31 12:18:28 -07:00
Gilles Boccon-Gibod
dbd865a484 Merge pull request #323 from google/gbg/device-hive
Device hive
2023-10-31 16:44:18 +01:00
Gabriel White-Vega
59d7717963 Remove mutable ret pattern and test feature combinations
After adding test for feature combinations, I found a corner case where, when Transport is dropped and the process is terminated in a test, the `close` Python future is not awaited.
I don't know what other situations this issue may arise, so I have safe-guarded it via `block_on` instead of spawning a thread.
2023-10-18 15:39:37 -04:00
Gabriel White-Vega
1004f10384 Address PR comments 2023-10-10 16:45:02 -04:00
Gabriel White-Vega
1051648ffb Add support for extended advertising via Rust-only API
* Extended functionality is gated on an "unstable" feature
* Designed for very simple use and minimal interferance with existing legacy implementation
* Intended to be temporary, until bumble can integrate extended advertising into its core functionality
* Dropped `HciCommandWrapper` in favor of using bumble's `HCI_Command.from_bytes` for converting from PDL into bumble implementation
* Refactored Address and Device constructors to better match what the python constructors expect
2023-10-10 13:35:31 -04:00
Josh Wu
45edcafb06 SCO: A loopback example 2023-09-27 23:30:26 +08:00
Josh Wu
9f0bcc131f eSCO support 2023-09-27 23:30:17 +08:00
102 changed files with 3861 additions and 364 deletions

View File

@@ -56,7 +56,7 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
@@ -65,15 +65,17 @@ jobs:
with:
components: clippy,rustfmt
toolchain: ${{ matrix.rust-version }}
- name: Install Rust dependencies
run: cargo install cargo-all-features # allows building/testing combinations of features
- name: Check License Headers
run: cd rust && cargo run --features dev-tools --bin file-header check-all
- name: Rust Build
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
run: cd rust && cargo build --all-targets && cargo build-all-features --all-targets
# Lints after build so what clippy needs is already built
- name: Rust Lints
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
- name: Rust Tests
run: cd rust && cargo test
run: cd rust && cargo test-all-features
# At some point, hook up publishing the binary. For now, just make sure it builds.
# Once we're ready to publish binaries, this should be built with `--release`.
- name: Build Bumble CLI

View File

@@ -50,8 +50,10 @@ from bumble.sdp import (
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement,
ServiceAttribute,
Client as SdpClient,
)
from bumble.transport import open_transport_or_link
import bumble.rfcomm
@@ -77,6 +79,7 @@ SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
DEFAULT_L2CAP_PSM = 1234
DEFAULT_L2CAP_MAX_CREDITS = 128
DEFAULT_L2CAP_MTU = 1022
@@ -128,11 +131,16 @@ def print_connection(connection):
if connection.transport == BT_LE_TRANSPORT:
phy_state = (
'PHY='
f'RX:{le_phy_name(connection.phy.rx_phy)}/'
f'TX:{le_phy_name(connection.phy.tx_phy)}'
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
f'RX:{le_phy_name(connection.phy.rx_phy)}'
)
data_length = f'DL={connection.data_length}'
data_length = (
'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
')'
)
connection_parameters = (
'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/'
@@ -169,9 +177,7 @@ def make_sdp_records(channel):
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -191,6 +197,48 @@ def make_sdp_records(channel):
}
async def find_rfcomm_channel_with_uuid(connection: Connection, uuid: str) -> int:
# Connect to the SDP Server
sdp_client = SdpClient(connection)
await sdp_client.connect()
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
[BT_L2CAP_PROTOCOL_ID],
[
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
],
)
for attribute_list in search_result:
service_uuid = None
service_class_id_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
)
if service_class_id_list:
if service_class_id_list.value:
for service_class_id in service_class_id_list.value:
service_uuid = service_class_id.value
if str(service_uuid) != uuid:
# This service doesn't have a UUID or isn't the right one.
continue
# Look for the RFCOMM Channel number
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
)
if protocol_descriptor_list:
for protocol_descriptor in protocol_descriptor_list.value:
if len(protocol_descriptor.value) >= 2:
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
await sdp_client.disconnect()
return protocol_descriptor.value[1].value
await sdp_client.disconnect()
return 0
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
@@ -224,7 +272,7 @@ class Sender:
if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
await asyncio.sleep(self.tx_start_delay) # FIXME
await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -364,7 +412,7 @@ class Ping:
if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
await asyncio.sleep(self.tx_start_delay) # FIXME
await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -710,14 +758,14 @@ class L2capServer(StreamedPacketIO):
self.l2cap_channel = None
self.ready = asyncio.Event()
# Listen for incoming L2CAP CoC connections
# Listen for incoming L2CAP connections
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=self.on_l2cap_channel,
)
print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
print(color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow'))
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
@@ -743,21 +791,35 @@ class L2capServer(StreamedPacketIO):
# RfcommClient
# -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO):
def __init__(self, device):
def __init__(self, device, channel, uuid):
super().__init__()
self.device = device
self.channel = channel
self.uuid = uuid
self.ready = asyncio.Event()
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
# Find the channel number if not specified
channel = self.channel
if channel == 0:
print(
color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
)
channel = await find_rfcomm_channel_with_uuid(connection, self.uuid)
print(color(f'@@@ Channel number = {channel}', 'cyan'))
if channel == 0:
print(color('!!! No RFComm service with this UUID found', 'red'))
await connection.disconnect()
return
# Create a client and start it
print(color('*** Starting RFCOMM client...', 'blue'))
rfcomm_client = bumble.rfcomm.Client(self.device, connection)
rfcomm_client = bumble.rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print(color('*** Started', 'blue'))
channel = DEFAULT_RFCOMM_CHANNEL
print(color(f'### Opening session for channel {channel}...', 'yellow'))
try:
rfcomm_session = await rfcomm_mux.open_dlc(channel)
@@ -780,7 +842,7 @@ class RfcommClient(StreamedPacketIO):
# RfcommServer
# -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO):
def __init__(self, device):
def __init__(self, device, channel):
super().__init__()
self.ready = asyncio.Event()
@@ -788,7 +850,7 @@ class RfcommServer(StreamedPacketIO):
rfcomm_server = bumble.rfcomm.Server(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL)
channel_number = rfcomm_server.listen(self.on_dlc, channel)
# Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number)
@@ -825,6 +887,9 @@ class Central(Connection.Listener):
mode_factory,
connection_interval,
phy,
authenticate,
encrypt,
extended_data_length,
):
super().__init__()
self.transport = transport
@@ -832,6 +897,9 @@ class Central(Connection.Listener):
self.classic = classic
self.role_factory = role_factory
self.mode_factory = mode_factory
self.authenticate = authenticate
self.encrypt = encrypt or authenticate
self.extended_data_length = extended_data_length
self.device = None
self.connection = None
@@ -904,7 +972,26 @@ class Central(Connection.Listener):
self.connection.listener = self
print_connection(self.connection)
await mode.on_connection(self.connection)
# Request a new data length if requested
if self.extended_data_length:
print(color('+++ Requesting extended data length', 'cyan'))
await self.connection.set_data_length(
self.extended_data_length[0], self.extended_data_length[1]
)
# Authenticate if requested
if self.authenticate:
# Request authentication
print(color('*** Authenticating...', 'cyan'))
await self.connection.authenticate()
print(color('*** Authenticated', 'cyan'))
# Encrypt if requested
if self.encrypt:
# Enable encryption
print(color('*** Enabling encryption...', 'cyan'))
await self.connection.encrypt()
print(color('*** Encryption on', 'cyan'))
# Set the PHY if requested
if self.phy is not None:
@@ -919,6 +1006,8 @@ class Central(Connection.Listener):
)
)
await mode.on_connection(self.connection)
await role.run()
await asyncio.sleep(DEFAULT_LINGER_TIME)
@@ -943,9 +1032,12 @@ class Central(Connection.Listener):
# Peripheral
# -----------------------------------------------------------------------------
class Peripheral(Device.Listener, Connection.Listener):
def __init__(self, transport, classic, role_factory, mode_factory):
def __init__(
self, transport, classic, extended_data_length, role_factory, mode_factory
):
self.transport = transport
self.classic = classic
self.extended_data_length = extended_data_length
self.role_factory = role_factory
self.role = None
self.mode_factory = mode_factory
@@ -1006,6 +1098,15 @@ class Peripheral(Device.Listener, Connection.Listener):
self.connection = connection
self.connected.set()
# Request a new data length if needed
if self.extended_data_length:
print("+++ Requesting extended data length")
AsyncRunner.spawn(
connection.set_data_length(
self.extended_data_length[0], self.extended_data_length[1]
)
)
def on_disconnection(self, reason):
print(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None
@@ -1038,16 +1139,18 @@ def create_mode_factory(ctx, default_mode):
return GattServer(device)
if mode == 'l2cap-client':
return L2capClient(device)
return L2capClient(device, psm=ctx.obj['l2cap_psm'])
if mode == 'l2cap-server':
return L2capServer(device)
return L2capServer(device, psm=ctx.obj['l2cap_psm'])
if mode == 'rfcomm-client':
return RfcommClient(device)
return RfcommClient(
device, channel=ctx.obj['rfcomm_channel'], uuid=ctx.obj['rfcomm_uuid']
)
if mode == 'rfcomm-server':
return RfcommServer(device)
return RfcommServer(device, channel=ctx.obj['rfcomm_channel'])
raise ValueError('invalid mode')
@@ -1113,6 +1216,27 @@ def create_role_factory(ctx, default_role):
type=click.IntRange(23, 517),
help='GATT MTU (gatt-client mode)',
)
@click.option(
'--extended-data-length',
help='Request a data length upon connection, specified as tx_octets/tx_time',
)
@click.option(
'--rfcomm-channel',
type=int,
default=DEFAULT_RFCOMM_CHANNEL,
help='RFComm channel to use',
)
@click.option(
'--rfcomm-uuid',
default=DEFAULT_RFCOMM_UUID,
help='RFComm service UUID to use (ignored is --rfcomm-channel is not 0)',
)
@click.option(
'--l2cap-psm',
type=int,
default=DEFAULT_L2CAP_PSM,
help='L2CAP PSM to use',
)
@click.option(
'--packet-size',
'-s',
@@ -1139,17 +1263,36 @@ def create_role_factory(ctx, default_role):
)
@click.pass_context
def bench(
ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay
ctx,
device_config,
role,
mode,
att_mtu,
extended_data_length,
packet_size,
packet_count,
start_delay,
rfcomm_channel,
rfcomm_uuid,
l2cap_psm,
):
ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config
ctx.obj['role'] = role
ctx.obj['mode'] = mode
ctx.obj['att_mtu'] = att_mtu
ctx.obj['rfcomm_channel'] = rfcomm_channel
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
ctx.obj['l2cap_psm'] = l2cap_psm
ctx.obj['packet_size'] = packet_size
ctx.obj['packet_count'] = packet_count
ctx.obj['start_delay'] = start_delay
ctx.obj['extended_data_length'] = (
[int(x) for x in extended_data_length.split('/')]
if extended_data_length
else None
)
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
@@ -1170,8 +1313,12 @@ def bench(
help='Connection interval (in ms)',
)
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
@click.pass_context
def central(ctx, transport, peripheral_address, connection_interval, phy):
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')
mode_factory = create_mode_factory(ctx, 'gatt-client')
@@ -1186,6 +1333,9 @@ def central(ctx, transport, peripheral_address, connection_interval, phy):
mode_factory,
connection_interval,
phy,
authenticate,
encrypt or authenticate,
ctx.obj['extended_data_length'],
).run()
)
@@ -1199,7 +1349,13 @@ def peripheral(ctx, transport):
mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run(
Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run()
Peripheral(
transport,
ctx.obj['classic'],
ctx.obj['extended_data_length'],
role_factory,
mode_factory,
).run()
)

View File

@@ -42,6 +42,8 @@ from bumble.hci import (
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
HCI_LE_Read_Suggested_Default_Data_Length_Command,
)
from bumble.host import Host
from bumble.transport import open_transport_or_link
@@ -117,6 +119,18 @@ async def get_le_info(host):
'\n',
)
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
response = await host.send_command(
HCI_LE_Read_Suggested_Default_Data_Length_Command()
)
if command_succeeded(response):
print(
color('Suggested Default Data Length:', 'yellow'),
f'{response.return_parameters.suggested_max_tx_octets}/'
f'{response.return_parameters.suggested_max_tx_time}',
'\n',
)
print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features:
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))

View File

@@ -24,10 +24,16 @@ from prompt_toolkit.shortcuts import PromptSession
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link
from bumble.pairing import PairingDelegate, PairingConfig
from bumble.pairing import OobData, PairingDelegate, PairingConfig
from bumble.smp import OobContext, OobLegacyContext
from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore
from bumble.core import ProtocolError
from bumble.core import (
AdvertisingData,
ProtocolError,
BT_LE_TRANSPORT,
BT_BR_EDR_TRANSPORT,
)
from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE,
@@ -60,7 +66,7 @@ class Waiter:
class Delegate(PairingDelegate):
def __init__(self, mode, connection, capability_string, do_prompt):
super().__init__(
{
io_capability={
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
@@ -285,7 +291,9 @@ async def pair(
mitm,
bond,
ctkd,
linger,
io,
oob,
prompt,
request,
print_keys,
@@ -343,16 +351,52 @@ async def pair(
await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
# Create an OOB context if needed
if oob:
our_oob_context = OobContext()
shared_data = (
None
if oob == '-'
else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
)
legacy_context = OobLegacyContext()
oob_contexts = PairingConfig.OobConfig(
our_context=our_oob_context,
peer_data=shared_data,
legacy_context=legacy_context,
)
oob_data = OobData(
address=device.random_address,
shared_data=shared_data,
legacy_context=legacy_context,
)
print(color('@@@-----------------------------------', 'yellow'))
print(color('@@@ OOB Data:', 'yellow'))
print(color(f'@@@ {our_oob_context.share()}', 'yellow'))
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
print(color(f'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
print(color('@@@-----------------------------------', 'yellow'))
else:
oob_contexts = None
# Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig(
sc, mitm, bond, Delegate(mode, connection, io, prompt)
sc=sc,
mitm=mitm,
bonding=bond,
oob=oob_contexts,
delegate=Delegate(mode, connection, io, prompt),
)
# Connect to a peer or wait for a connection
device.on('connection', lambda connection: on_connection(connection, request))
if address_or_name is not None:
print(color(f'=== Connecting to {address_or_name}...', 'green'))
connection = await device.connect(address_or_name)
connection = await device.connect(
address_or_name,
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
)
pairing_failure = False
if not request:
try:
@@ -360,10 +404,12 @@ async def pair(
await connection.pair()
else:
await connection.authenticate()
return
except ProtocolError as error:
pairing_failure = True
print(color(f'Pairing failed: {error}', 'red'))
return
if not linger or pairing_failure:
return
else:
if mode == 'le':
# Advertise so that peers can find us and connect
@@ -413,6 +459,7 @@ class LogHandler(logging.Handler):
help='Enable CTKD',
show_default=True,
)
@click.option('--linger', default=True, is_flag=True, help='Linger after pairing')
@click.option(
'--io',
type=click.Choice(
@@ -421,6 +468,14 @@ class LogHandler(logging.Handler):
default='display+keyboard',
show_default=True,
)
@click.option(
'--oob',
metavar='<oob-data-hex>',
help=(
'Use OOB pairing with this data from the peer '
'(use "-" to enable OOB without peer data)'
),
)
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
@click.option(
'--request', is_flag=True, help='Request that the connecting peer initiate pairing'
@@ -440,7 +495,9 @@ def main(
mitm,
bond,
ctkd,
linger,
io,
oob,
prompt,
request,
print_keys,
@@ -463,7 +520,9 @@ def main(
mitm,
bond,
ctkd,
linger,
io,
oob,
prompt,
request,
print_keys,

View File

@@ -15,9 +15,13 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import dataclasses
import struct
import logging
from collections import namedtuple
from collections.abc import AsyncGenerator
from typing import List, Callable, Awaitable
from .company_ids import COMPANY_IDENTIFIERS
from .sdp import (
@@ -239,24 +243,20 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
# -----------------------------------------------------------------------------
class SbcMediaCodecInformation(
namedtuple(
'SbcMediaCodecInformation',
[
'sampling_frequency',
'channel_mode',
'block_length',
'subbands',
'allocation_method',
'minimum_bitpool_value',
'maximum_bitpool_value',
],
)
):
@dataclasses.dataclass
class SbcMediaCodecInformation:
'''
A2DP spec - 4.3.2 Codec Specific Information Elements
'''
sampling_frequency: int
channel_mode: int
block_length: int
subbands: int
allocation_method: int
minimum_bitpool_value: int
maximum_bitpool_value: int
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
CHANNEL_MODE_BITS = {
SBC_MONO_CHANNEL_MODE: 1 << 3,
@@ -272,7 +272,7 @@ class SbcMediaCodecInformation(
}
@staticmethod
def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
def from_bytes(data: bytes) -> SbcMediaCodecInformation:
sampling_frequency = (data[0] >> 4) & 0x0F
channel_mode = (data[0] >> 0) & 0x0F
block_length = (data[1] >> 4) & 0x0F
@@ -293,14 +293,14 @@ class SbcMediaCodecInformation(
@classmethod
def from_discrete_values(
cls,
sampling_frequency,
channel_mode,
block_length,
subbands,
allocation_method,
minimum_bitpool_value,
maximum_bitpool_value,
):
sampling_frequency: int,
channel_mode: int,
block_length: int,
subbands: int,
allocation_method: int,
minimum_bitpool_value: int,
maximum_bitpool_value: int,
) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation(
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
@@ -314,14 +314,14 @@ class SbcMediaCodecInformation(
@classmethod
def from_lists(
cls,
sampling_frequencies,
channel_modes,
block_lengths,
subbands,
allocation_methods,
minimum_bitpool_value,
maximum_bitpool_value,
):
sampling_frequencies: List[int],
channel_modes: List[int],
block_lengths: List[int],
subbands: List[int],
allocation_methods: List[int],
minimum_bitpool_value: int,
maximum_bitpool_value: int,
) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation(
sampling_frequency=sum(
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
@@ -348,7 +348,7 @@ class SbcMediaCodecInformation(
]
)
def __str__(self):
def __str__(self) -> str:
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
allocation_methods = ['SNR', 'Loudness']
return '\n'.join(
@@ -367,16 +367,19 @@ class SbcMediaCodecInformation(
# -----------------------------------------------------------------------------
class AacMediaCodecInformation(
namedtuple(
'AacMediaCodecInformation',
['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
)
):
@dataclasses.dataclass
class AacMediaCodecInformation:
'''
A2DP spec - 4.5.2 Codec Specific Information Elements
'''
object_type: int
sampling_frequency: int
channels: int
rfa: int
vbr: int
bitrate: int
OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -400,7 +403,7 @@ class AacMediaCodecInformation(
CHANNELS_BITS = {1: 1 << 1, 2: 1}
@staticmethod
def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
def from_bytes(data: bytes) -> AacMediaCodecInformation:
object_type = data[0]
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
channels = (data[2] >> 2) & 0x03
@@ -413,8 +416,13 @@ class AacMediaCodecInformation(
@classmethod
def from_discrete_values(
cls, object_type, sampling_frequency, channels, vbr, bitrate
):
cls,
object_type: int,
sampling_frequency: int,
channels: int,
vbr: int,
bitrate: int,
) -> AacMediaCodecInformation:
return AacMediaCodecInformation(
object_type=cls.OBJECT_TYPE_BITS[object_type],
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
@@ -425,7 +433,14 @@ class AacMediaCodecInformation(
)
@classmethod
def from_lists(cls, object_types, sampling_frequencies, channels, vbr, bitrate):
def from_lists(
cls,
object_types: List[int],
sampling_frequencies: List[int],
channels: List[int],
vbr: int,
bitrate: int,
) -> AacMediaCodecInformation:
return AacMediaCodecInformation(
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
sampling_frequency=sum(
@@ -449,7 +464,7 @@ class AacMediaCodecInformation(
]
)
def __str__(self):
def __str__(self) -> str:
object_types = [
'MPEG_2_AAC_LC',
'MPEG_4_AAC_LC',
@@ -474,26 +489,26 @@ class AacMediaCodecInformation(
)
@dataclasses.dataclass
# -----------------------------------------------------------------------------
class VendorSpecificMediaCodecInformation:
'''
A2DP spec - 4.7.2 Codec Specific Information Elements
'''
vendor_id: int
codec_id: int
value: bytes
@staticmethod
def from_bytes(data):
def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation:
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
def __init__(self, vendor_id, codec_id, value):
self.vendor_id = vendor_id
self.codec_id = codec_id
self.value = value
def __bytes__(self):
def __bytes__(self) -> bytes:
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
def __str__(self):
def __str__(self) -> str:
# pylint: disable=line-too-long
return '\n'.join(
[
@@ -506,29 +521,27 @@ class VendorSpecificMediaCodecInformation:
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class SbcFrame:
def __init__(
self, sampling_frequency, block_count, channel_mode, subband_count, payload
):
self.sampling_frequency = sampling_frequency
self.block_count = block_count
self.channel_mode = channel_mode
self.subband_count = subband_count
self.payload = payload
sampling_frequency: int
block_count: int
channel_mode: int
subband_count: int
payload: bytes
@property
def sample_count(self):
def sample_count(self) -> int:
return self.subband_count * self.block_count
@property
def bitrate(self):
def bitrate(self) -> int:
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
@property
def duration(self):
def duration(self) -> float:
return self.sample_count / self.sampling_frequency
def __str__(self):
def __str__(self) -> str:
return (
f'SBC(sf={self.sampling_frequency},'
f'cm={self.channel_mode},'
@@ -540,12 +553,12 @@ class SbcFrame:
# -----------------------------------------------------------------------------
class SbcParser:
def __init__(self, read):
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
self.read = read
@property
def frames(self):
async def generate_frames():
def frames(self) -> AsyncGenerator[SbcFrame, None]:
async def generate_frames() -> AsyncGenerator[SbcFrame, None]:
while True:
# Read 4 bytes of header
header = await self.read(4)
@@ -589,7 +602,9 @@ class SbcParser:
# -----------------------------------------------------------------------------
class SbcPacketSource:
def __init__(self, read, mtu, codec_capabilities):
def __init__(
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
) -> None:
self.read = read
self.mtu = mtu
self.codec_capabilities = codec_capabilities

View File

@@ -250,15 +250,15 @@ async def find_avdtp_service_with_sdp_client(
# -----------------------------------------------------------------------------
async def find_avdtp_service_with_connection(
device: device.Device, connection: device.Connection
connection: device.Connection,
) -> Optional[Tuple[int, int]]:
'''
Find an AVDTP service, for a connection, and return its version,
or None if none is found
'''
sdp_client = sdp.Client(device)
await sdp_client.connect(connection)
sdp_client = sdp.Client(connection)
await sdp_client.connect()
service_version = await find_avdtp_service_with_sdp_client(sdp_client)
await sdp_client.disconnect()

View File

@@ -1000,6 +1000,9 @@ class Controller:
'''
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
'''
if self.le_scan_enable:
return bytes([HCI_COMMAND_DISALLOWED_ERROR])
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
self.le_scan_window = command.le_scan_window

View File

@@ -16,6 +16,7 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import enum
import struct
from typing import List, Optional, Tuple, Union, cast, Dict
@@ -1051,3 +1052,13 @@ class ConnectionPHY:
def __str__(self):
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
# -----------------------------------------------------------------------------
# LE Role
# -----------------------------------------------------------------------------
class LeRole(enum.IntEnum):
PERIPHERAL_ONLY = 0x00
CENTRAL_ONLY = 0x01
BOTH_PERIPHERAL_PREFERRED = 0x02
BOTH_CENTRAL_PREFERRED = 0x03

View File

@@ -103,6 +103,7 @@ from .hci import (
HCI_LE_Set_Advertising_Data_Command,
HCI_LE_Set_Advertising_Enable_Command,
HCI_LE_Set_Advertising_Parameters_Command,
HCI_LE_Set_Data_Length_Command,
HCI_LE_Set_Default_PHY_Command,
HCI_LE_Set_Extended_Scan_Enable_Command,
HCI_LE_Set_Extended_Scan_Parameters_Command,
@@ -736,6 +737,9 @@ class Connection(CompositeEventEmitter):
self.remove_listener('disconnection', abort.set_result)
self.remove_listener('disconnection_failure', abort.set_exception)
async def set_data_length(self, tx_octets, tx_time) -> None:
return await self.device.set_data_length(self, tx_octets, tx_time)
async def update_parameters(
self,
connection_interval_min,
@@ -2193,6 +2197,22 @@ class Device(CompositeEventEmitter):
)
self.disconnecting = False
async def set_data_length(self, connection, tx_octets, tx_time) -> None:
if tx_octets < 0x001B or tx_octets > 0x00FB:
raise ValueError('tx_octets must be between 0x001B and 0x00FB')
if tx_time < 0x0148 or tx_time > 0x4290:
raise ValueError('tx_time must be between 0x0148 and 0x4290')
return await self.send_command(
HCI_LE_Set_Data_Length_Command(
connection_handle=connection.handle,
tx_octets=tx_octets,
tx_time=tx_time,
), # type: ignore[call-arg]
check_result=True,
)
async def update_connection_parameters(
self,
connection,

View File

@@ -17,10 +17,11 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import collections
import enum
import functools
import logging
import struct
from typing import Any, Dict, Callable, Optional, Type, Union
from typing import Any, Dict, Callable, Optional, Type, Union, List
from .colors import color
from .core import (
@@ -148,6 +149,7 @@ HCI_COMMAND_PACKET = 0x01
HCI_ACL_DATA_PACKET = 0x02
HCI_SYNCHRONOUS_DATA_PACKET = 0x03
HCI_EVENT_PACKET = 0x04
HCI_ISO_DATA_PACKET = 0x05
# HCI Event Codes
HCI_INQUIRY_COMPLETE_EVENT = 0x01
@@ -1370,6 +1372,7 @@ HCI_LE_SUPPORTED_FEATURES_NAMES = {
if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
}
# fmt: on
# pylint: enable=line-too-long
# pylint: disable=invalid-name
@@ -1925,6 +1928,9 @@ class HCI_Packet:
if packet_type == HCI_ACL_DATA_PACKET:
return HCI_AclDataPacket.from_bytes(packet)
if packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
return HCI_SynchronousDataPacket.from_bytes(packet)
if packet_type == HCI_EVENT_PACKET:
return HCI_Event.from_bytes(packet)
@@ -2293,6 +2299,19 @@ class HCI_Read_Clock_Offset_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
],
)
class HCI_Reject_Synchronous_Connection_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.28 Reject Synchronous Connection Request Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
@@ -2454,6 +2473,51 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
'''
class CodingFormat(enum.IntEnum):
U_LOG = 0x00
A_LOG = 0x01
CVSD = 0x02
TRANSPARENT = 0x03
PCM = 0x04
MSBC = 0x05
LC3 = 0x06
G729A = 0x07
def to_bytes(self):
return self.value.to_bytes(5, 'little')
def __bytes__(self):
return self.to_bytes()
class PcmDataFormat(enum.IntEnum):
NA = 0x00
ONES_COMPLEMENT = 0x01
TWOS_COMPLEMENT = 0x02
SIGN_MAGNITUDE = 0x03
UNSIGNED = 0x04
class DataPath(enum.IntEnum):
HCI = 0x00
PCM = 0x01
class RetransmissionEffort(enum.IntEnum):
NO_RETRANSMISSION = 0x00
OPTIMIZE_FOR_POWER = 0x01
OPTIMIZE_FOR_QUALITY = 0x02
DONT_CARE = 0xFF
class PacketType(enum.IntFlag):
HV1 = 0x0001
HV2 = 0x0002
HV3 = 0x0004
EV3 = 0x0008
EV4 = 0x0010
EV5 = 0x0020
NO_2_EV3 = 0x0040
NO_3_EV3 = 0x0080
NO_2_EV5 = 0x0100
NO_3_EV5 = 0x0200
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -4323,6 +4387,158 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('cig_id', 1),
('sdu_interval_c_to_p', 3),
('sdu_interval_p_to_c', 3),
('worst_case_sca', 1),
('packing', 1),
('framing', 1),
('max_transport_latency_c_to_p', 2),
('max_transport_latency_p_to_c', 2),
[
('cis_id', 1),
('max_sdu_c_to_p', 2),
('max_sdu_p_to_c', 2),
('phy_c_to_p', 1),
('phy_p_to_c', 1),
('rtn_c_to_p', 1),
('rtn_p_to_c', 1),
],
],
return_parameters_fields=[
('status', STATUS_SPEC),
('cig_id', 1),
[('connection_handle', 2)],
],
)
class HCI_LE_Set_CIG_Parameters_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.97 LE Set CIG Parameters Command
'''
cig_id: int
sdu_interval_c_to_p: int
sdu_interval_p_to_c: int
worst_case_sca: int
packing: int
framing: int
max_transport_latency_c_to_p: int
max_transport_latency_p_to_c: int
cis_id: List[int]
max_sdu_c_to_p: List[int]
max_sdu_p_to_c: List[int]
phy_c_to_p: List[int]
phy_p_to_c: List[int]
rtn_c_to_p: List[int]
rtn_p_to_c: List[int]
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
[
('cis_connection_handle', 2),
('acl_connection_handle', 2),
],
],
)
class HCI_LE_Create_CIS_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.99 LE Create CIS command
'''
cis_connection_handle: List[int]
acl_connection_handle: List[int]
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('cig_id', 1)],
return_parameters_fields=[('status', STATUS_SPEC), ('cig_id', 1)],
)
class HCI_LE_Remove_CIG_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.100 LE Remove CIG command
'''
cig_id: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('connection_handle', 2)],
)
class HCI_LE_Accept_CIS_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.101 LE Accept CIS Request command
'''
connection_handle: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('connection_handle', 2)],
)
class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.102 LE Reject CIS Request command
'''
connection_handle: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('data_path_direction', 1),
('data_path_id', 1),
('codec_id', 5),
('controller_delay', 3),
('codec_configuration', '*'),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command
'''
connection_handle: int
data_path_direction: int
data_path_id: int
codec_id: int
controller_delay: int
codec_configuration: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('data_path_direction', 1),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.110 LE Remove ISO Data Path command
'''
connection_handle: int
data_path_direction: int
# -----------------------------------------------------------------------------
# HCI Events
# -----------------------------------------------------------------------------
@@ -4942,6 +5158,48 @@ class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('cig_sync_delay', 3),
('cis_sync_delay', 3),
('transport_latency_c_to_p', 3),
('transport_latency_p_to_c', 3),
('phy_c_to_p', 1),
('phy_p_to_c', 1),
('nse', 1),
('bn_c_to_p', 1),
('bn_p_to_c', 1),
('ft_c_to_p', 1),
('ft_p_to_c', 1),
('max_pdu_c_to_p', 2),
('max_pdu_p_to_c', 2),
('iso_interval', 2),
]
)
class HCI_LE_CIS_Established_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.25 LE CIS Established Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('acl_connection_handle', 2),
('cis_connection_handle', 2),
('cig_id', 1),
('cis_id', 1),
]
)
class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.26 LE CIS Request Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC)])
class HCI_Inquiry_Complete_Event(HCI_Event):
@@ -5738,6 +5996,168 @@ class HCI_AclDataPacket(HCI_Packet):
)
# -----------------------------------------------------------------------------
class HCI_SynchronousDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
'''
hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
@staticmethod
def from_bytes(packet: bytes) -> HCI_SynchronousDataPacket:
# Read the header
h, data_total_length = struct.unpack_from('<HB', packet, 1)
connection_handle = h & 0xFFF
packet_status = (h >> 12) & 0b11
data = packet[4:]
if len(data) != data_total_length:
raise ValueError(
f'invalid packet length {len(data)} != {data_total_length}'
)
return HCI_SynchronousDataPacket(
connection_handle, packet_status, data_total_length, data
)
def to_bytes(self) -> bytes:
h = (self.packet_status << 12) | self.connection_handle
return (
struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
+ self.data
)
def __init__(
self,
connection_handle: int,
packet_status: int,
data_total_length: int,
data: bytes,
) -> None:
self.connection_handle = connection_handle
self.packet_status = packet_status
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")}: '
f'handle=0x{self.connection_handle:04x}, '
f'ps={self.packet_status}, '
f'data_total_length={self.data_total_length}, '
f'data={self.data.hex()}'
)
# -----------------------------------------------------------------------------
class HCI_IsoDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
'''
hci_packet_type = HCI_ISO_DATA_PACKET
@staticmethod
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
time_stamp: Optional[int] = None
packet_sequence_number: Optional[int] = None
iso_sdu_length: Optional[int] = None
packet_status_flag: Optional[int] = None
pos = 1
pdu_info, data_total_length = struct.unpack_from('<HH', packet, pos)
connection_handle = pdu_info & 0xFFF
pb_flag = (pdu_info >> 12) & 0b11
ts_flag = (pdu_info >> 14) & 0b01
pos += 4
# pb_flag in (0b00, 0b10) but faster
should_include_sdu_info = not (pb_flag & 0b01)
if ts_flag:
if not should_include_sdu_info:
logger.warn(f'Timestamp included when pb_flag={bin(pb_flag)}')
time_stamp, _ = struct.unpack_from('<I', packet, pos)
pos += 4
if should_include_sdu_info:
packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
iso_sdu_length = sdu_info & 0xFFF
packet_status_flag = sdu_info >> 14
pos += 4
iso_sdu_fragment = packet[pos:]
return HCI_IsoDataPacket(
connection_handle=connection_handle,
pb_flag=pb_flag,
ts_flag=ts_flag,
data_total_length=data_total_length,
time_stamp=time_stamp,
packet_sequence_number=packet_sequence_number,
iso_sdu_length=iso_sdu_length,
packet_status_flag=packet_status_flag,
iso_sdu_fragment=iso_sdu_fragment,
)
def __init__(
self,
connection_handle: int,
pb_flag: int,
ts_flag: int,
data_total_length: int,
time_stamp: Optional[int],
packet_sequence_number: Optional[int],
iso_sdu_length: Optional[int],
packet_status_flag: Optional[int],
iso_sdu_fragment: bytes,
) -> None:
self.connection_handle = connection_handle
self.pb_flag = pb_flag
self.ts_flag = ts_flag
self.data_total_length = data_total_length
self.time_stamp = time_stamp
self.packet_sequence_number = packet_sequence_number
self.iso_sdu_length = iso_sdu_length
self.packet_status_flag = packet_status_flag
self.iso_sdu_fragment = iso_sdu_fragment
def __bytes__(self) -> bytes:
return self.to_bytes()
def to_bytes(self) -> bytes:
fmt = '<BHH'
args = [
HCI_ISO_DATA_PACKET,
self.ts_flag << 14 | self.pb_flag << 12 | self.connection_handle,
self.data_total_length,
]
if self.time_stamp is not None:
fmt += 'I'
args.append(self.time_stamp)
if (
self.packet_sequence_number is not None
and self.iso_sdu_length is not None
and self.packet_status_flag is not None
):
fmt += 'HH'
args += [
self.packet_sequence_number,
self.iso_sdu_length | self.packet_status_flag << 14,
]
return struct.pack(fmt, args) + self.iso_sdu_fragment
def __str__(self) -> str:
return (
f'{color("ISO", "blue")}: '
f'handle=0x{self.connection_handle:04x}, '
f'ps={self.packet_status_flag}, '
f'data_total_length={self.data_total_length}, '
f'sdu={self.iso_sdu_fragment.hex()}'
)
# -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler:
current_data: Optional[bytes]

View File

@@ -35,6 +35,7 @@ from bumble.core import (
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
from bumble.sdp import (
DataElement,
ServiceAttribute,
@@ -819,3 +820,170 @@ def sdp_records(
DataElement.unsigned_integer_16(hf_supported_features),
),
]
# -----------------------------------------------------------------------------
# ESCO Codec Default Parameters
# -----------------------------------------------------------------------------
# Hands-Free Profile v1.8, 5.7 Codec Interoperability Requirements
class DefaultCodecParameters(enum.IntEnum):
SCO_CVSD_D0 = enum.auto()
SCO_CVSD_D1 = enum.auto()
ESCO_CVSD_S1 = enum.auto()
ESCO_CVSD_S2 = enum.auto()
ESCO_CVSD_S3 = enum.auto()
ESCO_CVSD_S4 = enum.auto()
ESCO_MSBC_T1 = enum.auto()
ESCO_MSBC_T2 = enum.auto()
@dataclasses.dataclass
class EscoParameters:
# Codec specific
transmit_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat
receive_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat
packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
max_latency: int
# Common
input_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.TRANSPARENT
)
output_coding_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.TRANSPARENT
)
input_coded_data_size: int = 16
output_coded_data_size: int = 16
input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
)
output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
)
input_pcm_sample_payload_msb_position: int = 0
output_pcm_sample_payload_msb_position: int = 0
input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
)
output_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
)
input_transport_unit_size: int = 0
output_transport_unit_size: int = 0
input_bandwidth: int = 16000
output_bandwidth: int = 16000
transmit_bandwidth: int = 8000
receive_bandwidth: int = 8000
transmit_codec_frame_size: int = 60
receive_codec_frame_size: int = 60
_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0xFFFF,
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV1,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
)
_ESCO_PARAMETERS_CVSD_D1 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0xFFFF,
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV3,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
)
_ESCO_PARAMETERS_CVSD_S1 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0x0007,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S2 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0x0007,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S3 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0x000A,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S4 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.CVSD,
max_latency=0x000C,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
_ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
max_latency=0x0008,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
transmit_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
receive_coding_format=HCI_Enhanced_Setup_Synchronous_Connection_Command.CodingFormat.MSBC,
max_latency=0x000D,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
ESCO_PERAMETERS = {
DefaultCodecParameters.SCO_CVSD_D0: _ESCO_PARAMETERS_CVSD_D0,
DefaultCodecParameters.SCO_CVSD_D1: _ESCO_PARAMETERS_CVSD_D1,
DefaultCodecParameters.ESCO_CVSD_S1: _ESCO_PARAMETERS_CVSD_S1,
DefaultCodecParameters.ESCO_CVSD_S2: _ESCO_PARAMETERS_CVSD_S2,
DefaultCodecParameters.ESCO_CVSD_S3: _ESCO_PARAMETERS_CVSD_S3,
DefaultCodecParameters.ESCO_CVSD_S4: _ESCO_PARAMETERS_CVSD_S4,
DefaultCodecParameters.ESCO_MSBC_T1: _ESCO_PARAMETERS_MSBC_T1,
DefaultCodecParameters.ESCO_MSBC_T2: _ESCO_PARAMETERS_MSBC_T2,
}

View File

@@ -18,15 +18,14 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
import asyncio
import enum
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING
from . import core, l2cap # type: ignore
from .colors import color # type: ignore
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError # type: ignore
from bumble import l2cap
from bumble.colors import color
from bumble.core import InvalidStateError, ProtocolError
if TYPE_CHECKING:
from bumble.device import Device, Connection
@@ -302,10 +301,12 @@ class Host(EventEmitter):
self.send_pdu_on_ctrl(hid_message)
def send_pdu_on_ctrl(self, msg: bytes) -> None:
self.l2cap_ctrl_channel.send_pdu(msg) # type: ignore
assert self.l2cap_ctrl_channel
self.l2cap_ctrl_channel.send_pdu(msg)
def send_pdu_on_intr(self, msg: bytes) -> None:
self.l2cap_intr_channel.send_pdu(msg) # type: ignore
assert self.l2cap_intr_channel
self.l2cap_intr_channel.send_pdu(msg)
def send_data(self, data):
msg = SendData(data)

View File

@@ -21,7 +21,7 @@ import collections
import logging
import struct
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable, cast
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
@@ -43,6 +43,7 @@ from .hci import (
HCI_RESET_COMMAND,
HCI_SUCCESS,
HCI_SUPPORTED_COMMANDS_FLAGS,
HCI_SYNCHRONOUS_DATA_PACKET,
HCI_VERSION_BLUETOOTH_CORE_4_0,
HCI_AclDataPacket,
HCI_AclDataPacketAssembler,
@@ -67,6 +68,7 @@ from .hci import (
HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command,
HCI_Set_Event_Mask_Command,
HCI_SynchronousDataPacket,
)
from .core import (
BT_BR_EDR_TRANSPORT,
@@ -485,12 +487,14 @@ class Host(AbortableEventEmitter):
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet
if isinstance(packet, HCI_Command):
self.on_hci_command_packet(packet)
elif isinstance(packet, HCI_Event):
self.on_hci_event_packet(packet)
elif isinstance(packet, HCI_AclDataPacket):
self.on_hci_acl_data_packet(packet)
if packet.hci_packet_type == HCI_COMMAND_PACKET:
self.on_hci_command_packet(cast(HCI_Command, packet))
elif packet.hci_packet_type == HCI_EVENT_PACKET:
self.on_hci_event_packet(cast(HCI_Event, packet))
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
self.on_hci_acl_data_packet(cast(HCI_AclDataPacket, packet))
elif packet.hci_packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
self.on_hci_sco_data_packet(cast(HCI_SynchronousDataPacket, packet))
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
@@ -507,6 +511,10 @@ class Host(AbortableEventEmitter):
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
def on_hci_sco_data_packet(self, packet: HCI_SynchronousDataPacket) -> None:
# Experimental
self.emit('sco_packet', packet.connection_handle, packet)
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu)
@@ -760,7 +768,25 @@ class Host(AbortableEventEmitter):
asyncio.create_task(send_long_term_key())
def on_hci_synchronous_connection_complete_event(self, event):
pass
if event.status == HCI_SUCCESS:
# Create/update the connection
logger.debug(
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
f'{event.bd_addr}'
)
# Notify the client
self.emit(
'sco_connection',
event.bd_addr,
event.connection_handle,
event.link_type,
)
else:
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
# Notify the client
self.emit('sco_connection_failure', event.bd_addr, event.status)
def on_hci_synchronous_connection_changed_event(self, event):
pass

View File

@@ -15,7 +15,9 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import enum
from dataclasses import dataclass
from typing import Optional, Tuple
from .hci import (
@@ -35,7 +37,60 @@ from .smp import (
SMP_ID_KEY_DISTRIBUTION_FLAG,
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
SMP_LINK_KEY_DISTRIBUTION_FLAG,
OobContext,
OobLegacyContext,
OobSharedData,
)
from .core import AdvertisingData, LeRole
# -----------------------------------------------------------------------------
@dataclass
class OobData:
"""OOB data that can be sent from one device to another."""
address: Optional[Address] = None
role: Optional[LeRole] = None
shared_data: Optional[OobSharedData] = None
legacy_context: Optional[OobLegacyContext] = None
@classmethod
def from_ad(cls, ad: AdvertisingData) -> OobData:
instance = cls()
shared_data_c: Optional[bytes] = None
shared_data_r: Optional[bytes] = None
for ad_type, ad_data in ad.ad_structures:
if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
instance.address = Address(ad_data)
elif ad_type == AdvertisingData.LE_ROLE:
instance.role = LeRole(ad_data[0])
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
shared_data_c = ad_data
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE:
shared_data_r = ad_data
elif ad_type == AdvertisingData.SECURITY_MANAGER_TK_VALUE:
instance.legacy_context = OobLegacyContext(tk=ad_data)
if shared_data_c and shared_data_r:
instance.shared_data = OobSharedData(c=shared_data_c, r=shared_data_r)
return instance
def to_ad(self) -> AdvertisingData:
ad_structures = []
if self.address is not None:
ad_structures.append(
(AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
)
if self.role is not None:
ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
if self.shared_data is not None:
ad_structures.extend(self.shared_data.to_ad().ad_structures)
if self.legacy_context is not None:
ad_structures.append(
(AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
)
return AdvertisingData(ad_structures)
# -----------------------------------------------------------------------------
@@ -173,6 +228,14 @@ class PairingConfig:
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
RANDOM = Address.RANDOM_DEVICE_ADDRESS
@dataclass
class OobConfig:
"""Config for OOB pairing."""
our_context: Optional[OobContext]
peer_data: Optional[OobSharedData]
legacy_context: Optional[OobLegacyContext]
def __init__(
self,
sc: bool = True,
@@ -180,17 +243,20 @@ class PairingConfig:
bonding: bool = True,
delegate: Optional[PairingDelegate] = None,
identity_address_type: Optional[AddressType] = None,
oob: Optional[OobConfig] = None,
) -> None:
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
self.identity_address_type = identity_address_type
self.oob = oob
def __str__(self) -> str:
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
f'identity_address_type={self.identity_address_type}, '
f'delegate[{self.delegate.io_capability}])'
f'delegate[{self.delegate.io_capability}]), '
f'oob[{self.oob}])'
)

View File

@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict

View File

@@ -14,6 +14,7 @@
"""Generic & dependency free Bumble (reference) device."""
from __future__ import annotations
from bumble import transport
from bumble.core import (
BT_GENERIC_AUDIO_SERVICE,

View File

@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import bumble.device
import grpc

View File

@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextlib
import grpc

View File

@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import contextlib
import functools
import grpc

View File

@@ -889,8 +889,7 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
def __init__(self, connection: Connection) -> None:
self.connection = connection
self.l2cap_channel = None
self.multiplexer = None
@@ -906,7 +905,7 @@ class Client:
raise
assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server
# Create a multiplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer

View File

@@ -760,13 +760,13 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
class Client:
channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device) -> None:
self.device = device
def __init__(self, connection: Connection) -> None:
self.connection = connection
self.pending_request = None
self.channel = None
async def connect(self, connection: Connection) -> None:
self.channel = await connection.create_l2cap_channel(
async def connect(self) -> None:
self.channel = await self.connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(SDP_PSM)
)

View File

@@ -27,6 +27,7 @@ import logging
import asyncio
import enum
import secrets
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
@@ -53,6 +54,7 @@ from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
AdvertisingData,
ProtocolError,
name_or_number,
)
@@ -563,6 +565,54 @@ class PairingMethod(enum.IntEnum):
CTKD_OVER_CLASSIC = 4
# -----------------------------------------------------------------------------
class OobContext:
"""Cryptographic context for LE SC OOB pairing."""
ecc_key: crypto.EccKey
r: bytes
def __init__(
self, ecc_key: Optional[crypto.EccKey] = None, r: Optional[bytes] = None
) -> None:
self.ecc_key = crypto.EccKey.generate() if ecc_key is None else ecc_key
self.r = crypto.r() if r is None else r
def share(self) -> OobSharedData:
pkx = bytes(reversed(self.ecc_key.x))
return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
# -----------------------------------------------------------------------------
class OobLegacyContext:
"""Cryptographic context for LE Legacy OOB pairing."""
tk: bytes
def __init__(self, tk: Optional[bytes] = None) -> None:
self.tk = crypto.r() if tk is None else tk
# -----------------------------------------------------------------------------
@dataclass
class OobSharedData:
"""Shareable data for LE SC OOB pairing."""
c: bytes
r: bytes
def to_ad(self) -> AdvertisingData:
return AdvertisingData(
[
(AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE, self.c),
(AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE, self.r),
]
)
def __str__(self) -> str:
return f'OOB(C={self.c.hex()}, R={self.r.hex()})'
# -----------------------------------------------------------------------------
class Session:
# I/O Capability to pairing method decision matrix
@@ -640,8 +690,6 @@ class Session:
self.pres: Optional[bytes] = None
self.ea = None
self.eb = None
self.tk = bytes(16)
self.r = bytes(16)
self.stk = None
self.ltk = None
self.ltk_ediv = 0
@@ -659,7 +707,7 @@ class Session:
self.peer_bd_addr: Optional[Address] = None
self.peer_signature_key = None
self.peer_expected_distributions: List[Type[SMP_Command]] = []
self.dh_key = None
self.dh_key = b''
self.confirm_value = None
self.passkey: Optional[int] = None
self.passkey_ready = asyncio.Event()
@@ -712,8 +760,8 @@ class Session:
self.io_capability = pairing_config.delegate.io_capability
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
# OOB (not supported yet)
self.oob = False
# OOB
self.oob_data_flag = 0 if pairing_config.oob is None else 1
# Set up addresses
self_address = connection.self_address
@@ -729,9 +777,37 @@ class Session:
self.ia = bytes(peer_address)
self.iat = 1 if peer_address.is_random else 0
# Select the ECC key, TK and r initial value
if pairing_config.oob:
self.peer_oob_data = pairing_config.oob.peer_data
if pairing_config.sc:
if pairing_config.oob.our_context is None:
raise ValueError(
"oob pairing config requires a context when sc is True"
)
self.r = pairing_config.oob.our_context.r
self.ecc_key = pairing_config.oob.our_context.ecc_key
if pairing_config.oob.legacy_context is None:
self.tk = None
else:
self.tk = pairing_config.oob.legacy_context.tk
else:
if pairing_config.oob.legacy_context is None:
raise ValueError(
"oob pairing config requires a legacy context when sc is False"
)
self.r = bytes(16)
self.ecc_key = manager.ecc_key
self.tk = pairing_config.oob.legacy_context.tk
else:
self.peer_oob_data = None
self.r = bytes(16)
self.ecc_key = manager.ecc_key
self.tk = bytes(16)
@property
def pkx(self) -> Tuple[bytes, bytes]:
return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x)
return (bytes(reversed(self.ecc_key.x)), self.peer_public_key_x)
@property
def pka(self) -> bytes:
@@ -768,7 +844,10 @@ class Session:
return None
def decide_pairing_method(
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
self,
auth_req: int,
initiator_io_capability: int,
responder_io_capability: int,
) -> None:
if self.connection.transport == BT_BR_EDR_TRANSPORT:
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
@@ -909,7 +988,7 @@ class Session:
command = SMP_Pairing_Request_Command(
io_capability=self.io_capability,
oob_data_flag=0,
oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req,
maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution,
@@ -921,7 +1000,7 @@ class Session:
def send_pairing_response_command(self) -> None:
response = SMP_Pairing_Response_Command(
io_capability=self.io_capability,
oob_data_flag=0,
oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req,
maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution,
@@ -982,8 +1061,8 @@ class Session:
def send_public_key_command(self) -> None:
self.send_command(
SMP_Pairing_Public_Key_Command(
public_key_x=bytes(reversed(self.manager.ecc_key.x)),
public_key_y=bytes(reversed(self.manager.ecc_key.y)),
public_key_x=bytes(reversed(self.ecc_key.x)),
public_key_y=bytes(reversed(self.ecc_key.y)),
)
)
@@ -1030,7 +1109,6 @@ class Session:
self.ltk = crypto.h6(ilk, b'brle')
def distribute_keys(self) -> None:
# Distribute the keys as required
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
@@ -1296,7 +1374,7 @@ class Session:
try:
handler(command)
except Exception as error:
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
response = SMP_Pairing_Failed_Command(
reason=SMP_UNSPECIFIED_REASON_ERROR
)
@@ -1333,15 +1411,28 @@ class Session:
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
# Check for OOB
if command.oob_data_flag != 0:
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
# Infer the pairing method
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
):
# Use OOB
self.pairing_method = PairingMethod.OOB
if not self.sc and self.tk is None:
# For legacy OOB, TK is required.
logger.warning("legacy OOB without TK")
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
if command.oob_data_flag == 0:
# The peer doesn't have OOB data, use r=0
self.r = bytes(16)
else:
# Decide which pairing method to use from the IO capability
self.decide_pairing_method(
command.auth_req,
command.io_capability,
self.io_capability,
)
# Decide which pairing method to use
self.decide_pairing_method(
command.auth_req, command.io_capability, self.io_capability
)
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
@@ -1390,15 +1481,26 @@ class Session:
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
# Check for OOB
if self.sc and command.oob_data_flag:
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
# Infer the pairing method
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
):
# Use OOB
self.pairing_method = PairingMethod.OOB
if not self.sc and self.tk is None:
# For legacy OOB, TK is required.
logger.warning("legacy OOB without TK")
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
if command.oob_data_flag == 0:
# The peer doesn't have OOB data, use r=0
self.r = bytes(16)
else:
# Decide which pairing method to use from the IO capability
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
# Decide which pairing method to use
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
@@ -1549,12 +1651,13 @@ class Session:
if self.passkey_step < 20:
self.send_pairing_confirm_command()
return
else:
elif self.pairing_method != PairingMethod.OOB:
return
else:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
):
self.send_pairing_random_command()
elif self.pairing_method == PairingMethod.PASSKEY:
@@ -1591,6 +1694,7 @@ class Session:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
):
ra = bytes(16)
rb = ra
@@ -1599,7 +1703,6 @@ class Session:
ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra
else:
# OOB not implemented yet
return
assert self.preq and self.pres
@@ -1653,7 +1756,7 @@ class Session:
# Compute the DH key
self.dh_key = bytes(
reversed(
self.manager.ecc_key.dh(
self.ecc_key.dh(
bytes(reversed(command.public_key_x)),
bytes(reversed(command.public_key_y)),
)
@@ -1661,8 +1764,27 @@ class Session:
)
logger.debug(f'DH key: {self.dh_key.hex()}')
if self.pairing_method == PairingMethod.OOB:
# Check against shared OOB data
if self.peer_oob_data:
confirm_verifier = crypto.f4(
self.peer_public_key_x,
self.peer_public_key_x,
self.peer_oob_data.r,
bytes(1),
)
if not self.check_expected_value(
self.peer_oob_data.c,
confirm_verifier,
SMP_CONFIRM_VALUE_FAILED_ERROR,
):
return
if self.is_initiator:
self.send_pairing_confirm_command()
if self.pairing_method == PairingMethod.OOB:
self.send_pairing_random_command()
else:
self.send_pairing_confirm_command()
else:
if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
@@ -1673,6 +1795,7 @@ class Session:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
):
# We can now send the confirmation value
self.send_pairing_confirm_command()

View File

@@ -70,6 +70,7 @@ nav:
- Extras:
- extras/index.md
- Android Remote HCI: extras/android_remote_hci.md
- Android BT Bench: extras/android_bt_bench.md
- Hive:
- hive/index.md
- Speaker: hive/web/speaker/speaker.html

View File

@@ -0,0 +1,64 @@
ANDROID BENCH APP
=================
This Android app that is compatible with the Bumble `bench` command line app.
This app can be used to test the throughput and latency between two Android
devices, or between an Android device and another device running the Bumble
`bench` app.
Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
supported.
Building
--------
You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
If the build succeeds, you can find the app APKs (debug and release) at:
* [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
* [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
Running
-------
### Starting the app
You can start the app from the Android launcher, from Android Studio, or with `adb`
#### Launching from the launcher
Just tap the app icon on the launcher, check the parameters, and tap
one of the benchmark action buttons.
#### Launching with `adb`
Using the `am` command, you can start the activity, and pass it arguments so that you can
automatically start the benchmark test, and/or set the parameters.
| Parameter Name | Parameter Type | Description
|------------------------|----------------|------------
| autostart | String | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
| packet-count | Integer | Number of packets to send (rfcomm-client and l2cap-client only)
| packet-size | Integer | Number of bytes per packet (rfcomm-client and l2cap-client only)
| peer-bluetooth-address | Integer | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
!!! tip "Launching from adb with auto-start"
In this example, we auto-start the Rfcomm Server bench action.
```bash
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
```
!!! tip "Launching from adb with auto-start and some parameters"
In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
and the packet size to 1024, and connect to DA:4C:10:DE:17:02
```bash
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
```
#### Selecting a Peer Bluetooth Address
The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
!!! note "Bluetooth Address for L2CAP vs RFComm"
For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)

View File

@@ -8,4 +8,12 @@ Android Remote HCI
Allows using an Android phone's built-in Bluetooth controller with a Bumble
stack running on a development machine.
See [Android Remote HCI](android_remote_hci.md) for details.
See [Android Remote HCI](android_remote_hci.md) for details.
Android BT Bench
----------------
An Android app that is compatible with the Bumble `bench` command line app.
This app can be used to test the throughput and latency between two Android
devices, or between an Android device and another device running the Bumble
`bench` app.

View File

@@ -53,10 +53,10 @@ def sdp_records():
# -----------------------------------------------------------------------------
# pylint: disable-next=too-many-nested-blocks
async def find_a2dp_service(device, connection):
async def find_a2dp_service(connection):
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
sdp_client = SDP_Client(connection)
await sdp_client.connect()
# Search for services with an Audio Sink service class
search_result = await sdp_client.search_attributes(
@@ -177,7 +177,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
avdtp_version = await find_a2dp_service(device, connection)
avdtp_version = await find_a2dp_service(connection)
if not avdtp_version:
print(color('!!! no AVDTP service found'))
return

View File

@@ -165,9 +165,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
avdtp_version = await find_avdtp_service_with_connection(
device, connection
)
avdtp_version = await find_avdtp_service_with_connection(connection)
if not avdtp_version:
print(color('!!! no A2DP service found'))
return

View File

@@ -63,8 +63,8 @@ async def main():
print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
sdp_client = SDP_Client(connection)
await sdp_client.connect()
# List all services in the root browse group
service_record_handles = await sdp_client.search_services(

View File

@@ -31,6 +31,7 @@ from bumble.core import (
BT_BR_EDR_TRANSPORT,
)
from bumble import rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
from bumble.sdp import (
Client as SDP_Client,
DataElement,
@@ -48,8 +49,8 @@ logger = logging.getLogger(__name__)
# pylint: disable-next=too-many-nested-blocks
async def list_rfcomm_channels(device, connection):
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
sdp_client = SDP_Client(connection)
await sdp_client.connect()
# Search for services that support the Handsfree Profile
search_result = await sdp_client.search_attributes(
@@ -183,7 +184,7 @@ async def main():
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
rfcomm_client = rfcomm.Client(device, connection)
rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
@@ -197,6 +198,13 @@ async def main():
print('@@@ Disconnected from RFCOMM server')
return
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
# Reset packet and loopback
packet.packet_status = 0
device.host.send_hci_packet(packet)
device.host.on('sco_packet', on_sco)
# Protocol loop (just for testing at this point)
protocol = hfp.HfpProtocol(session)
while True:

View File

@@ -22,12 +22,9 @@ import logging
from bumble.colors import color
import bumble.core
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
BT_L2CAP_PROTOCOL_ID,
BT_HIDP_PROTOCOL_ID,
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
BT_BR_EDR_TRANSPORT,
)
@@ -35,8 +32,6 @@ from bumble.hci import Address
from bumble.hid import Host, Message
from bumble.sdp import (
Client as SDP_Client,
DataElement,
ServiceAttribute,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -75,11 +70,11 @@ SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
# -----------------------------------------------------------------------------
async def get_hid_device_sdp_record(device, connection):
async def get_hid_device_sdp_record(connection):
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
sdp_client = SDP_Client(connection)
await sdp_client.connect()
if sdp_client:
print(color('Connected to SDP Server', 'blue'))
else:
@@ -348,7 +343,7 @@ async def main():
await connection.encrypt()
print('*** Encryption on')
await get_hid_device_sdp_record(device, connection)
await get_hid_device_sdp_record(connection)
# Create HID host and start it
print('@@@ Starting HID Host...')

View File

@@ -42,10 +42,10 @@ from bumble.sdp import (
# -----------------------------------------------------------------------------
async def list_rfcomm_channels(device, connection):
async def list_rfcomm_channels(connection):
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
sdp_client = SDP_Client(connection)
await sdp_client.connect()
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
@@ -194,7 +194,7 @@ async def main():
channel = sys.argv[4]
if channel == 'discover':
await list_rfcomm_channels(device, connection)
await list_rfcomm_channels(connection)
return
# Request authentication
@@ -209,7 +209,7 @@ async def main():
# Create a client and start it
print('@@@ Starting RFCOMM client...')
rfcomm_client = Client(device, connection)
rfcomm_client = Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')

15
extras/android/BtBench/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
extras/android/BtBench/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,70 @@
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
}
android {
namespace = "com.github.google.bumble.btbench"
compileSdk = 34
defaultConfig {
applicationId = "com.github.google.bumble.btbench"
minSdk = 30
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.ui)
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.ui.test.junit4)
debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest)
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BTBench"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BTBench">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- <profileable android:shell="true"/>-->
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,35 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.l2cap-client")
class L2capClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission")
fun run() {
viewModel.running = true
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
val client = SocketClient(viewModel, socket)
client.run()
}
}

View File

@@ -0,0 +1,62 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
import android.os.Build
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.l2cap-server")
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission")
fun run() {
// Advertise to that the peer can find us and connect.
val callback = object: AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.warning("failed to start advertising: $errorCode")
}
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.info("advertising started: $settingsInEffect")
}
}
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
advertiseSettingsBuilder.setDiscoverable(true)
}
val advertiseSettings = advertiseSettingsBuilder.build()
val advertiseData = AdvertiseData.Builder().build()
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback)
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
viewModel.l2capPsm = serverSocket.psm
Log.info("psm = $serverSocket.psm")
val server = SocketServer(viewModel, serverSocket)
server.run({ advertiser.stopAdvertising(callback) })
}
}

View File

@@ -0,0 +1,307 @@
// Copyright 2023 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.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
import java.util.logging.Logger
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"
class MainActivity : ComponentActivity() {
private val appViewModel = AppViewModel()
private var bluetoothAdapter: BluetoothAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
checkPermissions()
}
private fun checkPermissions() {
val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
val missingPermissions = neededPermissions.filter {
ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isEmpty()) {
start()
return
}
val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
permissions.entries.forEach {
Log.info("permission: ${it.key} = ${it.value}")
}
val grantCount = permissions.count { it.value }
if (grantCount == neededPermissions.size) {
// We have all the permissions we need.
start()
} else {
Log.warning("not all permissions granted")
}
}
requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
return
}
@SuppressLint("MissingPermission")
private fun initBluetooth() {
val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
bluetoothAdapter = bluetoothManager?.adapter
if (bluetoothAdapter == null) {
Log.warning("no bluetooth adapter")
return
}
if (!bluetoothAdapter!!.isEnabled) {
Log.warning("bluetooth not enabled")
return
}
}
private fun start() {
initBluetooth()
setContent {
MainView(
appViewModel,
::becomeDiscoverable,
::runRfcommClient,
::runRfcommServer,
::runL2capClient,
::runL2capServer
)
}
// Process intent parameters, if any.
intent.getStringExtra("peer-bluetooth-address")?.let {
appViewModel.peerBluetoothAddress = it
}
val packetCount = intent.getIntExtra("packet-count", 0)
if (packetCount > 0) {
appViewModel.senderPacketCount = packetCount
}
appViewModel.updateSenderPacketCountSlider()
val packetSize = intent.getIntExtra("packet-size", 0)
if (packetSize > 0) {
appViewModel.senderPacketSize = packetSize
}
appViewModel.updateSenderPacketSizeSlider()
intent.getStringExtra("autostart")?.let {
when (it) {
"rfcomm-client" -> runRfcommClient()
"rfcomm-server" -> runRfcommServer()
"l2cap-client" -> runL2capClient()
"l2cap-server" -> runL2capServer()
}
}
}
private fun runRfcommClient() {
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
rfcommClient?.run()
}
private fun runRfcommServer() {
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
rfcommServer?.run()
}
private fun runL2capClient() {
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it) }
l2capClient?.run()
}
private fun runL2capServer() {
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
l2capServer?.run()
}
@SuppressLint("MissingPermission")
fun becomeDiscoverable() {
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
startActivity(discoverableIntent)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MainView(
appViewModel: AppViewModel,
becomeDiscoverable: () -> Unit,
runRfcommClient: () -> Unit,
runRfcommServer: () -> Unit,
runL2capClient: () -> Unit,
runL2capServer: () -> Unit
) {
BTBenchTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
text = "Bumble Bench",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Divider()
val keyboardController = LocalSoftwareKeyboardController.current
TextField(label = {
Text(text = "Peer Bluetooth Address")
},
value = appViewModel.peerBluetoothAddress,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
),
onValueChange = {
appViewModel.updatePeerBluetoothAddress(it)
},
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
)
Divider()
TextField(label = {
Text(text = "L2CAP PSM")
},
value = appViewModel.l2capPsm.toString(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
onValueChange = {
if (it.isNotEmpty()) {
val psm = it.toIntOrNull()
if (psm != null) {
appViewModel.l2capPsm = psm
}
}
},
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
Divider()
Slider(
value = appViewModel.senderPacketCountSlider, onValueChange = {
appViewModel.senderPacketCountSlider = it
appViewModel.updateSenderPacketCount()
}, steps = 4
)
Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
Divider()
Slider(
value = appViewModel.senderPacketSizeSlider, onValueChange = {
appViewModel.senderPacketSizeSlider = it
appViewModel.updateSenderPacketSize()
}, steps = 4
)
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
Divider()
ActionButton(
text = "Become Discoverable", onClick = becomeDiscoverable, true
)
Row() {
ActionButton(
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
)
ActionButton(
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
)
}
Row() {
ActionButton(
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
)
ActionButton(
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
)
}
Divider()
Text(
text = "Packets Sent: ${appViewModel.packetsSent}"
)
Text(
text = "Packets Received: ${appViewModel.packetsReceived}"
)
Text(
text = "Throughput: ${appViewModel.throughput}"
)
Divider()
ActionButton(
text = "Abort", onClick = appViewModel::abort, appViewModel.running
)
}
}
}
}
@Composable
fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
Button(onClick = onClick, enabled = enabled) {
Text(text = text)
}
}

View File

@@ -0,0 +1,163 @@
// Copyright 2023 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.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import java.util.UUID
val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
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
class AppViewModel : ViewModel() {
private var preferences: SharedPreferences? = null
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
var l2capPsm by mutableStateOf(0)
var senderPacketCountSlider by mutableFloatStateOf(0.0F)
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
var packetsSent by mutableIntStateOf(0)
var packetsReceived by mutableIntStateOf(0)
var throughput by mutableIntStateOf(0)
var running by mutableStateOf(false)
var aborter: (() -> Unit)? = null
fun loadPreferences(preferences: SharedPreferences) {
this.preferences = preferences
val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
if (savedPeerBluetoothAddress != null) {
peerBluetoothAddress = savedPeerBluetoothAddress
}
val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
if (savedSenderPacketCount != 0) {
senderPacketCount = savedSenderPacketCount
}
updateSenderPacketCountSlider()
val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
if (savedSenderPacketSize != 0) {
senderPacketSize = savedSenderPacketSize
}
updateSenderPacketSizeSlider()
}
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
this.peerBluetoothAddress = peerBluetoothAddress
// Save the address to the preferences
with(preferences!!.edit()) {
putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, peerBluetoothAddress)
apply()
}
}
fun updateSenderPacketCountSlider() {
if (senderPacketCount <= 10) {
senderPacketCountSlider = 0.0F
} else if (senderPacketCount <= 50) {
senderPacketCountSlider = 0.2F
} else if (senderPacketCount <= 100) {
senderPacketCountSlider = 0.4F
} else if (senderPacketCount <= 500) {
senderPacketCountSlider = 0.6F
} else if (senderPacketCount <= 1000) {
senderPacketCountSlider = 0.8F
} else {
senderPacketCountSlider = 1.0F
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
apply()
}
}
fun updateSenderPacketCount() {
if (senderPacketCountSlider < 0.1F) {
senderPacketCount = 10
} else if (senderPacketCountSlider < 0.3F) {
senderPacketCount = 50
} else if (senderPacketCountSlider < 0.5F) {
senderPacketCount = 100
} else if (senderPacketCountSlider < 0.7F) {
senderPacketCount = 500
} else if (senderPacketCountSlider < 0.9F) {
senderPacketCount = 1000
} else {
senderPacketCount = 10000
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
apply()
}
}
fun updateSenderPacketSizeSlider() {
if (senderPacketSize <= 1) {
senderPacketSizeSlider = 0.0F
} else if (senderPacketSize <= 256) {
senderPacketSizeSlider = 0.02F
} else if (senderPacketSize <= 512) {
senderPacketSizeSlider = 0.4F
} else if (senderPacketSize <= 1024) {
senderPacketSizeSlider = 0.6F
} else if (senderPacketSize <= 2048) {
senderPacketSizeSlider = 0.8F
} else {
senderPacketSizeSlider = 1.0F
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
apply()
}
}
fun updateSenderPacketSize() {
if (senderPacketSizeSlider < 0.1F) {
senderPacketSize = 1
} else if (senderPacketSizeSlider < 0.3F) {
senderPacketSize = 256
} else if (senderPacketSizeSlider < 0.5F) {
senderPacketSize = 512
} else if (senderPacketSizeSlider < 0.7F) {
senderPacketSize = 1024
} else if (senderPacketSizeSlider < 0.9F) {
senderPacketSize = 2048
} else {
senderPacketSize = 4096
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
apply()
}
}
fun abort() {
aborter?.let { it() }
}
}

View File

@@ -0,0 +1,178 @@
// Copyright 2023 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.BluetoothSocket
import java.io.IOException
import java.nio.ByteBuffer
import java.util.logging.Logger
import kotlin.math.min
private val Log = Logger.getLogger("btbench.packet")
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
companion object {
const val RESET = 0
const val SEQUENCE = 1
const val ACK = 2
const val LAST_FLAG = 1
fun from(data: ByteArray): Packet {
return when (data[0].toInt()) {
RESET -> ResetPacket()
SEQUENCE -> SequencePacket(
data[1].toInt(),
ByteBuffer.wrap(data, 2, 4).getInt(),
data.sliceArray(6..<data.size)
)
ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
}
}
}
open fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + payload.size).put(type.toByte()).put(payload).array()
}
}
class GenericPacket(type: Int, payload: ByteArray) : Packet(type, payload)
class ResetPacket : Packet(RESET)
class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).array()
}
}
class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
Packet(SEQUENCE, payload) {
override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).put(payload).array()
}
}
abstract class PacketSink {
fun onPacket(packet: Packet) {
when (packet) {
is ResetPacket -> onResetPacket()
is AckPacket -> onAckPacket()
is SequencePacket -> onSequencePacket(packet)
}
}
abstract fun onResetPacket()
abstract fun onAckPacket()
abstract fun onSequencePacket(packet: SequencePacket)
}
interface DataSink {
fun onData(data: ByteArray)
}
interface PacketIO {
var packetSink: PacketSink?
fun sendPacket(packet: Packet)
}
class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
private var bytesNeeded: Int = 0
private var rxPacket: ByteBuffer? = null
private var rxHeader = ByteBuffer.allocate(2)
override var packetSink: PacketSink? = null
fun onData(data: ByteArray) {
var current = data
while (current.isNotEmpty()) {
if (bytesNeeded > 0) {
val chunk = current.sliceArray(0..<min(bytesNeeded, current.size))
rxPacket!!.put(chunk)
current = current.sliceArray(chunk.size..<current.size)
bytesNeeded -= chunk.size
if (bytesNeeded == 0) {
// Packet completed.
//Log.fine("packet complete: ${current.toHex()}")
packetSink?.onPacket(Packet.from(rxPacket!!.array()))
// Reset.
reset()
}
} else {
val headerBytesNeeded = 2 - rxHeader.position()
val headerBytes = current.sliceArray(0..<min(headerBytesNeeded, current.size))
current = current.sliceArray(headerBytes.size..<current.size)
rxHeader.put(headerBytes)
if (rxHeader.position() != 2) {
return
}
bytesNeeded = rxHeader.getShort(0).toInt()
if (bytesNeeded == 0) {
Log.warning("found 0 size packet!")
reset()
return
}
rxPacket = ByteBuffer.allocate(bytesNeeded)
}
}
}
private fun reset() {
rxPacket = null
rxHeader.position(0)
}
override fun sendPacket(packet: Packet) {
val packetBytes = packet.toBytes()
val packetData =
ByteBuffer.allocate(2 + packetBytes.size).putShort(packetBytes.size.toShort())
.put(packetBytes).array()
dataSink.onData(packetData)
}
}
class SocketDataSink(private val socket: BluetoothSocket) : DataSink {
override fun onData(data: ByteArray) {
socket.outputStream.write(data)
}
}
class SocketDataSource(
private val socket: BluetoothSocket,
private val onData: (data: ByteArray) -> Unit
) {
fun receive() {
val buffer = ByteArray(4096)
do {
try {
val bytesRead = socket.inputStream.read(buffer)
if (bytesRead <= 0) {
break
}
onData(buffer.sliceArray(0..<bytesRead))
} catch (error: IOException) {
Log.warning("IO Exception: $error")
break
}
} while (true)
Log.info("end of stream")
}
}

View File

@@ -0,0 +1,60 @@
// Copyright 2023 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.DurationUnit
import kotlin.time.TimeSource
private val Log = Logger.getLogger("btbench.receiver")
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var bytesReceived = 0
init {
packetIO.packetSink = this
}
override fun onResetPacket() {
startTime = TimeSource.Monotonic.markNow()
lastPacketTime = startTime
bytesReceived = 0
viewModel.throughput = 0
viewModel.packetsSent = 0
viewModel.packetsReceived = 0
}
override fun onAckPacket() {
}
override fun onSequencePacket(packet: SequencePacket) {
val received = packet.payload.size + 6
bytesReceived += received
val now = TimeSource.Monotonic.markNow()
lastPacketTime = now
viewModel.packetsReceived += 1
if (packet.flags and Packet.LAST_FLAG != 0) {
Log.info("received last packet")
val elapsed = now - startTime
val throughput = (bytesReceived / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
Log.info("throughput: $throughput")
viewModel.throughput = throughput
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
}
}
}

View File

@@ -0,0 +1,36 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import 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) {
@SuppressLint("MissingPermission")
fun run() {
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
DEFAULT_RFCOMM_UUID
)
val client = SocketClient(viewModel, socket)
client.run()
}
}

View File

@@ -0,0 +1,35 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import 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) {
@SuppressLint("MissingPermission")
fun run() {
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
"BumbleBench", DEFAULT_RFCOMM_UUID
)
val server = SocketServer(viewModel, serverSocket)
server.run({})
}
}

View File

@@ -0,0 +1,84 @@
// Copyright 2023 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.DurationUnit
import kotlin.time.TimeSource
private val Log = Logger.getLogger("btbench.sender")
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var bytesSent = 0
private val done = Semaphore(0)
init {
packetIO.packetSink = this
}
fun run() {
viewModel.packetsSent = 0
viewModel.packetsReceived = 0
viewModel.throughput = 0
Log.info("sending reset")
packetIO.sendPacket(ResetPacket())
startTime = TimeSource.Monotonic.markNow()
val packetCount = viewModel.senderPacketCount
val packetSize = viewModel.senderPacketSize
for (i in 0..<packetCount - 1) {
packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6)))
bytesSent += packetSize
viewModel.packetsSent = i + 1
}
packetIO.sendPacket(
SequencePacket(
Packet.LAST_FLAG,
packetCount - 1,
ByteArray(packetSize - 6)
)
)
bytesSent += packetSize
viewModel.packetsSent = packetCount
// Wait for the ACK
Log.info("waiting for ACK")
done.acquire()
Log.info("got ACK")
}
fun abort() {
done.release()
}
override fun onResetPacket() {
}
override fun onAckPacket() {
Log.info("received ACK")
val elapsed = TimeSource.Monotonic.markNow() - startTime
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
Log.info("throughput: $throughput")
viewModel.throughput = throughput
done.release()
}
override fun onSequencePacket(packet: SequencePacket) {
}
}

View File

@@ -0,0 +1,63 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothSocket
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.socket-client")
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)
fun cleanup() {
socket.close()
viewModel.aborter = {}
viewModel.running = false
}
thread(name = "SocketClient") {
viewModel.aborter = {
sender.abort()
socket.close()
}
Log.info("connecting to remote")
try {
socket.connect()
} catch (error: IOException) {
Log.warning("connection failed")
cleanup()
return@thread
}
Log.info("connected")
thread {
socketDataSource.receive()
}
sender.run()
cleanup()
}
}
}

View File

@@ -0,0 +1,66 @@
// Copyright 2023 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.BluetoothServerSocket
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.socket-server")
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
fun run(onTerminate: () -> Unit) {
var aborted = false
viewModel.running = true
fun cleanup() {
serverSocket.close()
viewModel.running = false
onTerminate()
}
thread(name = "SocketServer") {
while (!aborted) {
viewModel.aborter = {
serverSocket.close()
}
Log.info("waiting for connection...")
val socket = try {
serverSocket.accept()
} catch (error: IOException) {
Log.warning("server socket closed")
cleanup()
return@thread
}
Log.info("got connection")
viewModel.aborter = {
aborted = true
socket.close()
}
viewModel.peerBluetoothAddress = socket.remoteDevice.address
val socketDataSink = SocketDataSink(socket)
val streamIO = StreamedPacketIO(socketDataSink)
val socketDataSource = SocketDataSource(socket, streamIO::onData)
val receiver = Receiver(viewModel, streamIO)
socketDataSource.receive()
socket.close()
}
cleanup()
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.google.bumble.btbench.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,63 @@
package com.github.google.bumble.btbench.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun BTBenchTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true, content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme, typography = Typography, content = content
)
}

View File

@@ -0,0 +1,33 @@
package com.github.google.bumble.btbench.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BT Bench</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BTBench" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.kotlinAndroid) apply false
}
true // Needed to make the Suppress annotation work for the plugins block

View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,31 @@
[versions]
agp = "8.3.0-alpha11"
kotlin = "1.9.0"
core-ktx = "1.12.0"
junit = "4.13.2"
androidx-test-ext-junit = "1.1.5"
espresso-core = "3.5.1"
lifecycle-runtime-ktx = "2.6.2"
activity-compose = "1.7.2"
compose-bom = "2023.08.00"
[libraries]
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
ui = { group = "androidx.compose.ui", name = "ui" }
ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
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" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

Binary file not shown.

View File

@@ -0,0 +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
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
extras/android/BtBench/gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# 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.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
extras/android/BtBench/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BT Bench"
include(":app")

View File

@@ -113,7 +113,7 @@ class MainActivity : ComponentActivity() {
val tcpPort = intent.getIntExtra("port", -1)
if (tcpPort >= 0) {
appViewModel.tcpPort = tcpPport
appViewModel.tcpPort = tcpPort
}
setContent {

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.3.0-alpha05"
agp = "8.3.0-alpha11"
kotlin = "1.8.10"
core-ktx = "1.9.0"
junit = "4.13.2"

View File

@@ -1,6 +1,6 @@
#Sun Aug 06 12:53:26 PDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -12,6 +12,13 @@ keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"]
rust-version = "1.70.0"
# https://github.com/frewsxcv/cargo-all-features#options
[package.metadata.cargo-all-features]
# We are interested in testing subset combinations of this feature, so this is redundant
denylist = ["unstable"]
# To exercise combinations of any of these features, remove from `always_include_features`
always_include_features = ["anyhow", "pyo3-asyncio-attributes", "dev-tools", "bumble-tools"]
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
@@ -26,6 +33,7 @@ thiserror = "1.0.41"
bytes = "1.5.0"
pdl-derive = "0.2.0"
pdl-runtime = "0.2.0"
futures = "0.3.28"
# Dev tools
file-header = { version = "0.1.2", optional = true }
@@ -36,7 +44,6 @@ anyhow = { version = "1.0.71", optional = true }
clap = { version = "4.3.3", features = ["derive"], optional = true }
directories = { version = "5.0.1", optional = true }
env_logger = { version = "0.10.0", optional = true }
futures = { version = "0.3.28", optional = true }
log = { version = "0.4.19", optional = true }
owo-colors = { version = "3.5.0", optional = true }
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
@@ -74,6 +81,11 @@ name = "bumble"
path = "src/main.rs"
required-features = ["bumble-tools"]
[[example]]
name = "broadcast"
path = "examples/broadcast.rs"
required-features = ["unstable_extended_adv"]
# test entry point that uses pyo3_asyncio's test harness
[[test]]
name = "pytests"
@@ -85,5 +97,10 @@ anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
# separate feature for CLI so that dependencies don't spend time building these
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"]
# all the unstable features
unstable = ["unstable_extended_adv"]
unstable_extended_adv = []
default = []

View File

@@ -33,6 +33,7 @@
use bumble::wrapper::{
device::{Device, Peer},
hci::{packets::AddressType, Address},
profile::BatteryServiceProxy,
transport::Transport,
PyObjectExt,
@@ -52,12 +53,8 @@ async fn main() -> PyResult<()> {
let transport = Transport::open(cli.transport).await?;
let device = Device::with_hci(
"Bumble",
"F0:F1:F2:F3:F4:F5",
transport.source()?,
transport.sink()?,
)?;
let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
let device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
device.power_on().await?;

View File

@@ -63,17 +63,28 @@ async fn main() -> PyResult<()> {
)
.map_err(|e| anyhow!(e))?;
device.set_advertising_data(adv_data)?;
device.power_on().await?;
println!("Advertising...");
device.start_advertising(true).await?;
if cli.extended {
println!("Starting extended advertisement...");
device.start_advertising_extended(adv_data).await?;
} else {
device.set_advertising_data(adv_data)?;
println!("Starting legacy advertisement...");
device.start_advertising(true).await?;
}
// wait until user kills the process
tokio::signal::ctrl_c().await?;
println!("Stopping...");
device.stop_advertising().await?;
if cli.extended {
println!("Stopping extended advertisement...");
device.stop_advertising_extended().await?;
} else {
println!("Stopping legacy advertisement...");
device.stop_advertising().await?;
}
Ok(())
}
@@ -86,12 +97,17 @@ struct Cli {
/// See, for instance, `examples/device1.json` in the Python project.
#[arg(long)]
device_config: path::PathBuf,
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Whether to perform an extended (BT 5.0) advertisement
#[arg(long)]
extended: bool,
/// Log HCI commands
#[arg(long)]
log_hci: bool,

View File

@@ -20,7 +20,9 @@
use bumble::{
adv::CommonDataType,
wrapper::{
core::AdvertisementDataUnit, device::Device, hci::packets::AddressType,
core::AdvertisementDataUnit,
device::Device,
hci::{packets::AddressType, Address},
transport::Transport,
},
};
@@ -44,12 +46,8 @@ async fn main() -> PyResult<()> {
let transport = Transport::open(cli.transport).await?;
let mut device = Device::with_hci(
"Bumble",
"F0:F1:F2:F3:F4:F5",
transport.source()?,
transport.sink()?,
)?;
let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
let mut device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
// in practice, devices can send multiple advertisements from the same address, so we keep
// track of a timestamp for each set of data

View File

@@ -1,77 +0,0 @@
// Copyright 2023 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
//
// http://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.
use bumble::wrapper::{
controller::Controller,
device::Device,
drivers::rtk::DriverInfo,
hci::{
packets::{
AddressType, ErrorCode, ReadLocalVersionInformationBuilder,
ReadLocalVersionInformationComplete,
},
Address, Error,
},
host::Host,
link::Link,
transport::Transport,
};
use nix::sys::stat::Mode;
use pyo3::{
exceptions::PyException,
{PyErr, PyResult},
};
#[pyo3_asyncio::tokio::test]
async fn fifo_transport_can_open() -> PyResult<()> {
let dir = tempfile::tempdir().unwrap();
let mut fifo = dir.path().to_path_buf();
fifo.push("bumble-transport-fifo");
nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
t.close().await?;
Ok(())
}
#[pyo3_asyncio::tokio::test]
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}
#[pyo3_asyncio::tokio::test]
async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> {
let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?;
let link = Link::new_local_link()?;
let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
let host = Host::new(controller.clone().into(), controller.into()).await?;
let device = Device::new(None, Some(address), None, Some(host), None)?;
device.power_on().await?;
// Send some simple command. A successful response means [HciCommandWrapper] has the minimum
// required interface for the Python code to think its an [HCI_Command] object.
let command = ReadLocalVersionInformationBuilder {};
let event: ReadLocalVersionInformationComplete = device
.send_command(&command.into(), true)
.await?
.try_into()
.map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::Success, event.get_status());
Ok(())
}

View File

@@ -0,0 +1,22 @@
// Copyright 2023 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
//
// http://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.
use bumble::wrapper::drivers::rtk::DriverInfo;
use pyo3::PyResult;
#[pyo3_asyncio::tokio::test]
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}

View File

@@ -0,0 +1,86 @@
// Copyright 2023 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
//
// http://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.
use bumble::wrapper::{
controller::Controller,
device::Device,
hci::{
packets::{
AddressType, Enable, ErrorCode, LeScanType, LeScanningFilterPolicy,
LeSetScanEnableBuilder, LeSetScanEnableComplete, LeSetScanParametersBuilder,
LeSetScanParametersComplete, OwnAddressType,
},
Address, Error,
},
host::Host,
link::Link,
};
use pyo3::{
exceptions::PyException,
{PyErr, PyResult},
};
#[pyo3_asyncio::tokio::test]
async fn test_hci_roundtrip_success_and_failure() -> PyResult<()> {
let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
let device = create_local_device(address).await?;
device.power_on().await?;
// BLE Spec Core v5.3
// 7.8.9 LE Set Scan Parameters command
// ...
// The Host shall not issue this command when scanning is enabled in the
// Controller; if it is the Command Disallowed error code shall be used.
// ...
let command = LeSetScanEnableBuilder {
filter_duplicates: Enable::Disabled,
// will cause failure later
le_scan_enable: Enable::Enabled,
};
let event: LeSetScanEnableComplete = device
.send_command(command.into(), false)
.await?
.try_into()
.map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::Success, event.get_status());
let command = LeSetScanParametersBuilder {
le_scan_type: LeScanType::Passive,
le_scan_interval: 0,
le_scan_window: 0,
own_address_type: OwnAddressType::RandomDeviceAddress,
scanning_filter_policy: LeScanningFilterPolicy::AcceptAll,
};
let event: LeSetScanParametersComplete = device
.send_command(command.into(), false)
.await?
.try_into()
.map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
assert_eq!(ErrorCode::CommandDisallowed, event.get_status());
Ok(())
}
async fn create_local_device(address: Address) -> PyResult<Device> {
let link = Link::new_local_link()?;
let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
let host = Host::new(controller.clone().into(), controller.into()).await?;
Device::new(None, Some(address), None, Some(host), None)
}

View File

@@ -0,0 +1,17 @@
// Copyright 2023 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
//
// http://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.
mod drivers;
mod hci;
mod transport;

View File

@@ -0,0 +1,31 @@
// Copyright 2023 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
//
// http://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.
use bumble::wrapper::transport::Transport;
use nix::sys::stat::Mode;
use pyo3::PyResult;
#[pyo3_asyncio::tokio::test]
async fn fifo_transport_can_open() -> PyResult<()> {
let dir = tempfile::tempdir().unwrap();
let mut fifo = dir.path().to_path_buf();
fifo.push("bumble-transport-fifo");
nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
t.close().await?;
Ok(())
}

View File

@@ -94,7 +94,7 @@ impl From<Error> for PacketTypeParseError {
impl WithPacketType<Self> for Command {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Command, self.to_vec())
prepend_packet_type(PacketType::Command, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -104,7 +104,7 @@ impl WithPacketType<Self> for Command {
impl WithPacketType<Self> for Acl {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Acl, self.to_vec())
prepend_packet_type(PacketType::Acl, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -114,7 +114,7 @@ impl WithPacketType<Self> for Acl {
impl WithPacketType<Self> for Sco {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Sco, self.to_vec())
prepend_packet_type(PacketType::Sco, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -124,7 +124,7 @@ impl WithPacketType<Self> for Sco {
impl WithPacketType<Self> for Event {
fn to_vec_with_packet_type(self) -> Vec<u8> {
prepend_packet_type(PacketType::Event, self.to_vec())
prepend_packet_type(PacketType::Event, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -132,7 +132,9 @@ impl WithPacketType<Self> for Event {
}
}
fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
fn prepend_packet_type<T: Packet>(packet_type: PacketType, packet: T) -> Vec<u8> {
// TODO: refactor if `pdl` crate adds API for writing into buffer (github.com/google/pdl/issues/74)
let mut packet_bytes = packet.to_vec();
packet_bytes.insert(0, packet_type.into());
packet_bytes
}

View File

@@ -22,9 +22,8 @@ use bytes::Bytes;
#[test]
fn prepends_packet_type() {
let packet_type = PacketType::Event;
let packet_bytes = vec![0x00, 0x00, 0x00, 0x00];
let actual = prepend_packet_type(packet_type, packet_bytes);
assert_eq!(vec![0x04, 0x00, 0x00, 0x00, 0x00], actual);
let actual = prepend_packet_type(packet_type, FakePacket { bytes: vec![0xFF] });
assert_eq!(vec![0x04, 0xFF], actual);
}
#[test]
@@ -75,11 +74,15 @@ fn test_packet_roundtrip_with_type() {
}
#[derive(Debug, PartialEq)]
struct FakePacket;
struct FakePacket {
bytes: Vec<u8>,
}
impl FakePacket {
fn parse(_bytes: &[u8]) -> Result<Self, Error> {
Ok(Self)
fn parse(bytes: &[u8]) -> Result<Self, Error> {
Ok(Self {
bytes: bytes.to_vec(),
})
}
}
@@ -89,6 +92,6 @@ impl Packet for FakePacket {
}
fn to_vec(self) -> Vec<u8> {
Vec::new()
self.bytes
}
}

View File

@@ -14,7 +14,17 @@
//! Devices and connections to them
use crate::internal::hci::WithPacketType;
#[cfg(feature = "unstable_extended_adv")]
use crate::wrapper::{
hci::packets::{
self, AdvertisingEventProperties, AdvertisingFilterPolicy, Enable, EnabledSet,
FragmentPreference, LeSetAdvertisingSetRandomAddressBuilder,
LeSetExtendedAdvertisingDataBuilder, LeSetExtendedAdvertisingEnableBuilder,
LeSetExtendedAdvertisingParametersBuilder, Operation, OwnAddressType, PeerAddressType,
PrimaryPhyType, SecondaryPhyType,
},
ConversionError,
};
use crate::{
adv::AdvertisementDataBuilder,
wrapper::{
@@ -22,7 +32,7 @@ use crate::{
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::{
packets::{Command, ErrorCode, Event},
Address, HciCommandWrapper,
Address, HciCommand, WithPacketType,
},
host::Host,
l2cap::LeConnectionOrientedChannel,
@@ -39,6 +49,9 @@ use pyo3::{
use pyo3_asyncio::tokio::into_future;
use std::path;
#[cfg(test)]
mod tests;
/// Represents the various properties of some device
pub struct DeviceConfiguration(PyObject);
@@ -69,11 +82,24 @@ impl ToPyObject for DeviceConfiguration {
}
}
/// Used for tracking what advertising state a device might be in
#[derive(PartialEq)]
enum AdvertisingStatus {
AdvertisingLegacy,
AdvertisingExtended,
NotAdvertising,
}
/// A device that can send/receive HCI frames.
#[derive(Clone)]
pub struct Device(PyObject);
pub struct Device {
obj: PyObject,
advertising_status: AdvertisingStatus,
}
impl Device {
#[cfg(feature = "unstable_extended_adv")]
const ADVERTISING_HANDLE_EXTENDED: u8 = 0x00;
/// Creates a Device. When optional arguments are not specified, the Python object specifies the
/// defaults.
pub fn new(
@@ -94,7 +120,10 @@ impl Device {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Device"))?
.call((), Some(kwargs))
.map(|any| Self(any.into()))
.map(|any| Self {
obj: any.into(),
advertising_status: AdvertisingStatus::NotAdvertising,
})
})
}
@@ -111,28 +140,38 @@ impl Device {
intern!(py, "from_config_file_with_hci"),
(device_config, source.0, sink.0),
)
.map(|any| Self(any.into()))
.map(|any| Self {
obj: any.into(),
advertising_status: AdvertisingStatus::NotAdvertising,
})
})
}
/// Create a Device configured to communicate with a controller through an HCI source/sink
pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult<Self> {
pub fn with_hci(name: &str, address: Address, source: Source, sink: Sink) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Device"))?
.call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0))
.map(|any| Self(any.into()))
.call_method1(intern!(py, "with_hci"), (name, address.0, source.0, sink.0))
.map(|any| Self {
obj: any.into(),
advertising_status: AdvertisingStatus::NotAdvertising,
})
})
}
/// Sends an HCI command on this Device, returning the command's event result.
pub async fn send_command(&self, command: &Command, check_result: bool) -> PyResult<Event> {
///
/// When `check_result` is `true`, then an `Err` will be returned if the controller's response
/// did not have an event code of "success".
pub async fn send_command(&self, command: Command, check_result: bool) -> PyResult<Event> {
let bumble_hci_command = HciCommand::try_from(command)?;
Python::with_gil(|py| {
self.0
self.obj
.call_method1(
py,
intern!(py, "send_command"),
(HciCommandWrapper(command.clone()), check_result),
(bumble_hci_command, check_result),
)
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -151,7 +190,7 @@ impl Device {
/// Turn the device on
pub async fn power_on(&self) -> PyResult<()> {
Python::with_gil(|py| {
self.0
self.obj
.call_method0(py, intern!(py, "power_on"))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -162,7 +201,7 @@ impl Device {
/// Connect to a peer
pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
Python::with_gil(|py| {
self.0
self.obj
.call_method1(py, intern!(py, "connect"), (peer_addr,))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -180,7 +219,7 @@ impl Device {
});
Python::with_gil(|py| {
self.0
self.obj
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
})
.map(|_| ())
@@ -191,7 +230,7 @@ impl Device {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_item("filter_duplicates", filter_duplicates)?;
self.0
self.obj
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -209,7 +248,7 @@ impl Device {
});
Python::with_gil(|py| {
self.0
self.obj
.call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
})
.map(|_| ())
@@ -218,7 +257,7 @@ impl Device {
/// Set the advertisement data to be used when [Device::start_advertising] is called.
pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> {
Python::with_gil(|py| {
self.0.setattr(
self.obj.setattr(
py,
intern!(py, "advertising_data"),
adv_data.into_bytes().as_slice(),
@@ -230,35 +269,162 @@ impl Device {
/// Returns the host used by the device, if any
pub fn host(&mut self) -> PyResult<Option<Host>> {
Python::with_gil(|py| {
self.0
self.obj
.getattr(py, intern!(py, "host"))
.map(|obj| obj.into_option(Host::from))
})
}
/// Start advertising the data set with [Device.set_advertisement].
///
/// When `auto_restart` is set to `true`, then the device will automatically restart advertising
/// when a connected device is disconnected.
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
if self.advertising_status == AdvertisingStatus::AdvertisingExtended {
return Err(PyErr::new::<PyException, _>("Already advertising in extended mode. Stop the existing extended advertisement to start a legacy advertisement."));
}
// Bumble allows (and currently ignores) calling `start_advertising` when already
// advertising. Because that behavior may change in the future, we continue to delegate the
// handling to bumble.
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_item("auto_restart", auto_restart)?;
self.0
self.obj
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
.map(|_| ())?;
self.advertising_status = AdvertisingStatus::AdvertisingLegacy;
Ok(())
}
/// Start advertising the data set in extended mode, replacing any existing extended adv. The
/// advertisement will be non-connectable.
///
/// Fails if the device is already advertising in legacy mode.
#[cfg(feature = "unstable_extended_adv")]
pub async fn start_advertising_extended(
&mut self,
adv_data: AdvertisementDataBuilder,
) -> PyResult<()> {
// TODO: add tests when local controller object supports extended advertisement commands (github.com/google/bumble/pull/238)
match self.advertising_status {
AdvertisingStatus::AdvertisingLegacy => return Err(PyErr::new::<PyException, _>("Already advertising in legacy mode. Stop the existing legacy advertisement to start an extended advertisement.")),
// Stop the current extended advertisement before advertising with new data.
// We could just issue an LeSetExtendedAdvertisingData command, but this approach
// allows better future flexibility if `start_advertising_extended` were to change.
AdvertisingStatus::AdvertisingExtended => self.stop_advertising_extended().await?,
_ => {}
}
// set extended params
let properties = AdvertisingEventProperties {
connectable: 0,
scannable: 0,
directed: 0,
high_duty_cycle: 0,
legacy: 0,
anonymous: 0,
tx_power: 0,
};
let extended_advertising_params_cmd = LeSetExtendedAdvertisingParametersBuilder {
advertising_event_properties: properties,
advertising_filter_policy: AdvertisingFilterPolicy::AllDevices,
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
advertising_sid: 0,
advertising_tx_power: 0,
own_address_type: OwnAddressType::RandomDeviceAddress,
peer_address: default_ignored_peer_address(),
peer_address_type: PeerAddressType::PublicDeviceOrIdentityAddress,
primary_advertising_channel_map: 7,
primary_advertising_interval_max: 200,
primary_advertising_interval_min: 100,
primary_advertising_phy: PrimaryPhyType::Le1m,
scan_request_notification_enable: Enable::Disabled,
secondary_advertising_max_skip: 0,
secondary_advertising_phy: SecondaryPhyType::Le1m,
};
self.send_command(extended_advertising_params_cmd.into(), true)
.await?;
// set random address
let random_address: packets::Address =
self.random_address()?.try_into().map_err(|e| match e {
ConversionError::Python(pyerr) => pyerr,
ConversionError::Native(e) => PyErr::new::<PyException, _>(format!("{e:?}")),
})?;
let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder {
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
random_address,
};
self.send_command(random_address_cmd.into(), true).await?;
// set adv data
let advertising_data_cmd = LeSetExtendedAdvertisingDataBuilder {
advertising_data: adv_data.into_bytes(),
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
fragment_preference: FragmentPreference::ControllerMayFragment,
operation: Operation::CompleteAdvertisement,
};
self.send_command(advertising_data_cmd.into(), true).await?;
// enable adv
let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
enable: Enable::Enabled,
enabled_sets: vec![EnabledSet {
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
duration: 0,
max_extended_advertising_events: 0,
}],
};
self.send_command(extended_advertising_enable_cmd.into(), true)
.await?;
self.advertising_status = AdvertisingStatus::AdvertisingExtended;
Ok(())
}
/// Stop advertising.
pub async fn stop_advertising(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
self.0
self.obj
.call_method0(py, intern!(py, "stop_advertising"))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
.map(|_| ())?;
if self.advertising_status == AdvertisingStatus::AdvertisingLegacy {
self.advertising_status = AdvertisingStatus::NotAdvertising;
}
Ok(())
}
/// Stop advertising extended.
#[cfg(feature = "unstable_extended_adv")]
pub async fn stop_advertising_extended(&mut self) -> PyResult<()> {
if AdvertisingStatus::AdvertisingExtended != self.advertising_status {
return Ok(());
}
// disable adv
let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
enable: Enable::Disabled,
enabled_sets: vec![EnabledSet {
advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
duration: 0,
max_extended_advertising_events: 0,
}],
};
self.send_command(extended_advertising_enable_cmd.into(), true)
.await?;
self.advertising_status = AdvertisingStatus::NotAdvertising;
Ok(())
}
/// Registers an L2CAP connection oriented channel server. When a client connects to the server,
@@ -286,7 +452,7 @@ impl Device {
kwargs.set_opt_item("max_credits", max_credits)?;
kwargs.set_opt_item("mtu", mtu)?;
kwargs.set_opt_item("mps", mps)?;
self.0.call_method(
self.obj.call_method(
py,
intern!(py, "register_l2cap_channel_server"),
(),
@@ -295,6 +461,15 @@ impl Device {
})?;
Ok(())
}
/// Gets the Device's `random_address` property
pub fn random_address(&self) -> PyResult<Address> {
Python::with_gil(|py| {
self.obj
.getattr(py, intern!(py, "random_address"))
.map(Address)
})
}
}
/// A connection to a remote device.
@@ -451,3 +626,13 @@ impl Advertisement {
Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData))
}
}
/// Use this address when sending an HCI command that requires providing a peer address, but the
/// command is such that the peer address will be ignored.
///
/// Internal to bumble, this address might mean "any", but a packets::Address typically gets sent
/// directly to a controller, so we don't have to worry about it.
#[cfg(feature = "unstable_extended_adv")]
fn default_ignored_peer_address() -> packets::Address {
packets::Address::try_from(0x0000_0000_0000_u64).unwrap()
}

View File

@@ -0,0 +1,23 @@
// Copyright 2023 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
//
// http://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.
#[cfg(feature = "unstable_extended_adv")]
use crate::wrapper::device::default_ignored_peer_address;
#[test]
#[cfg(feature = "unstable_extended_adv")]
fn default_peer_address_does_not_panic() {
let result = std::panic::catch_unwind(default_ignored_peer_address);
assert!(result.is_ok())
}

View File

@@ -14,18 +14,19 @@
//! HCI
// re-export here, and internal usages of these imports should refer to this mod, not the internal
// mod
pub(crate) use crate::internal::hci::WithPacketType;
pub use crate::internal::hci::{packets, Error, Packet};
use crate::{
internal::hci::WithPacketType,
wrapper::hci::packets::{AddressType, Command, ErrorCode},
use crate::wrapper::{
hci::packets::{AddressType, Command, ErrorCode},
ConversionError,
};
use itertools::Itertools as _;
use pyo3::{
exceptions::PyException,
intern, pyclass, pymethods,
types::{PyBytes, PyModule},
FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject,
exceptions::PyException, intern, types::PyModule, FromPyObject, IntoPy, PyAny, PyErr, PyObject,
PyResult, Python, ToPyObject,
};
/// Provides helpers for interacting with HCI
@@ -43,17 +44,45 @@ impl HciConstant {
}
}
/// Bumble's representation of an HCI command.
pub(crate) struct HciCommand(pub(crate) PyObject);
impl HciCommand {
fn from_bytes(bytes: &[u8]) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.hci"))?
.getattr(intern!(py, "HCI_Command"))?
.call_method1(intern!(py, "from_bytes"), (bytes,))
.map(|obj| Self(obj.to_object(py)))
})
}
}
impl TryFrom<Command> for HciCommand {
type Error = PyErr;
fn try_from(value: Command) -> Result<Self, Self::Error> {
HciCommand::from_bytes(&value.to_vec_with_packet_type())
}
}
impl IntoPy<PyObject> for HciCommand {
fn into_py(self, _py: Python<'_>) -> PyObject {
self.0
}
}
/// A Bluetooth address
#[derive(Clone)]
pub struct Address(pub(crate) PyObject);
impl Address {
/// Creates a new [Address] object
pub fn new(address: &str, address_type: &AddressType) -> PyResult<Self> {
/// 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"))?
.getattr(intern!(py, "Address"))?
.call1((address, address_type.to_object(py)))
.call1((address, address_type))
.map(|any| Self(any.into()))
})
}
@@ -118,27 +147,31 @@ impl ToPyObject for Address {
}
}
/// Implements minimum necessary interface to be treated as bumble's [HCI_Command].
/// While pyo3's macros do not support generics, this could probably be refactored to allow multiple
/// implementations of the HCI_Command methods in the future, if needed.
#[pyclass]
pub(crate) struct HciCommandWrapper(pub(crate) Command);
/// An error meaning that the u64 value did not represent a valid BT address.
#[derive(Debug)]
pub struct InvalidAddress(u64);
#[pymethods]
impl HciCommandWrapper {
fn __bytes__(&self, py: Python) -> PyResult<PyObject> {
let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type());
Ok(bytes.into_py(py))
}
impl TryInto<packets::Address> for Address {
type Error = ConversionError<InvalidAddress>;
#[getter]
fn op_code(&self) -> u16 {
self.0.get_op_code().into()
fn try_into(self) -> Result<packets::Address, Self::Error> {
let addr_le_bytes = self.as_le_bytes().map_err(ConversionError::Python)?;
// packets::Address only supports converting from a u64 (TODO: update if/when it supports converting from [u8; 6] -- https://github.com/google/pdl/issues/75)
// So first we take the python `Address` little-endian bytes (6 bytes), copy them into a
// [u8; 8] in little-endian format, and finally convert it into a u64.
let mut buf = [0_u8; 8];
buf[0..6].copy_from_slice(&addr_le_bytes);
let address_u64 = u64::from_le_bytes(buf);
packets::Address::try_from(address_u64)
.map_err(InvalidAddress)
.map_err(ConversionError::Native)
}
}
impl ToPyObject for AddressType {
fn to_object(&self, py: Python<'_>) -> PyObject {
impl IntoPy<PyObject> for AddressType {
fn into_py(self, py: Python<'_>) -> PyObject {
u8::from(self).to_object(py)
}
}

View File

@@ -132,3 +132,12 @@ pub(crate) fn wrap_python_async<'a>(py: Python<'a>, function: &'a PyAny) -> PyRe
.getattr(intern!(py, "wrap_async"))?
.call1((function,))
}
/// Represents the two major kinds of errors that can occur when converting between Rust and Python.
pub enum ConversionError<T> {
/// Occurs across the Python/native boundary.
Python(PyErr),
/// Occurs within the native ecosystem, such as when performing more transformations before
/// finally converting to the native type.
Native(T),
}

View File

@@ -15,6 +15,7 @@
//! HCI packet transport
use crate::wrapper::controller::Controller;
use futures::executor::block_on;
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
/// A source/sink pair for HCI packet I/O.
@@ -58,9 +59,9 @@ impl Transport {
impl Drop for Transport {
fn drop(&mut self) {
// can't await in a Drop impl, but we can at least spawn a task to do it
let obj = self.0.clone();
tokio::spawn(async move { Self(obj).close().await });
// don't spawn a thread to handle closing, as it may get dropped at program termination,
// resulting in `RuntimeWarning: coroutine ... was never awaited` from Python
let _ = block_on(self.close());
}
}

View File

@@ -43,12 +43,10 @@ async def make_hfp_connections(
# Setup RFCOMM channel
wait_dlc = asyncio.get_running_loop().create_future()
rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
lambda dlc: wait_dlc.set_result(dlc)
)
rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(wait_dlc.set_result)
assert devices.connections[0]
assert devices.connections[1]
client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
client_mux = await rfcomm.Client(devices.connections[1]).start()
client_dlc = await client_mux.open_dlc(rfcomm_channel)
server_dlc = await wait_dlc

View File

@@ -215,8 +215,8 @@ async def test_service_search():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
client = Client(devices.connections[1])
await client.connect()
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
@@ -236,8 +236,8 @@ async def test_service_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
client = Client(devices.connections[1])
await client.connect()
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
@@ -257,8 +257,8 @@ async def test_service_search_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
client = Client(devices.connections[1])
await client.connect()
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
)

Some files were not shown because too many files have changed in this diff Show More