Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28a1a5ebc2 | ||
|
|
6310dc777f | ||
|
|
f0e5cdee1a | ||
|
|
7bc7d0f5af | ||
|
|
a65a215fd7 | ||
|
|
80d34a226d | ||
|
|
a9628f73e3 | ||
|
|
9bf2e03354 | ||
|
|
2900b93bb3 | ||
|
|
284cc8a321 | ||
|
|
3dc2e4036c | ||
|
|
268f6b0d51 | ||
|
|
46239b321b | ||
|
|
8a536cd522 | ||
|
|
f9f5d7ccbd | ||
|
|
e08c84dd20 | ||
|
|
8b46136703 | ||
|
|
9c7089c8ff | ||
|
|
aac8d89cd0 | ||
|
|
24e75bfeab | ||
|
|
42868b08d3 | ||
|
|
19b61d9ac0 | ||
|
|
db2a2e2bb9 | ||
|
|
e1fdb12647 | ||
|
|
a8ec1b0949 |
1
.vscode/settings.json
vendored
@@ -29,6 +29,7 @@
|
||||
"deregistration",
|
||||
"dhkey",
|
||||
"diversifier",
|
||||
"endianness",
|
||||
"Fitbit",
|
||||
"GATTLINK",
|
||||
"HANDSFREE",
|
||||
|
||||
204
apps/bench.py
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
11
apps/pair.py
@@ -291,6 +291,7 @@ async def pair(
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
linger,
|
||||
io,
|
||||
oob,
|
||||
prompt,
|
||||
@@ -395,6 +396,7 @@ async def pair(
|
||||
address_or_name,
|
||||
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
pairing_failure = False
|
||||
|
||||
if not request:
|
||||
try:
|
||||
@@ -402,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
|
||||
@@ -455,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(
|
||||
@@ -490,6 +495,7 @@ def main(
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
linger,
|
||||
io,
|
||||
oob,
|
||||
prompt,
|
||||
@@ -514,6 +520,7 @@ def main(
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
linger,
|
||||
io,
|
||||
oob,
|
||||
prompt,
|
||||
|
||||
151
bumble/a2dp.py
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
148
bumble/crypto.py
@@ -21,6 +21,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import operator
|
||||
|
||||
@@ -29,11 +31,13 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
generate_private_key,
|
||||
ECDH,
|
||||
EllipticCurvePrivateKey,
|
||||
EllipticCurvePublicNumbers,
|
||||
EllipticCurvePrivateNumbers,
|
||||
SECP256R1,
|
||||
)
|
||||
from cryptography.hazmat.primitives import cmac
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -46,16 +50,18 @@ logger = logging.getLogger(__name__)
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
class EccKey:
|
||||
def __init__(self, private_key):
|
||||
def __init__(self, private_key: EllipticCurvePrivateKey) -> None:
|
||||
self.private_key = private_key
|
||||
|
||||
@classmethod
|
||||
def generate(cls):
|
||||
def generate(cls) -> EccKey:
|
||||
private_key = generate_private_key(SECP256R1())
|
||||
return cls(private_key)
|
||||
|
||||
@classmethod
|
||||
def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes):
|
||||
def from_private_key_bytes(
|
||||
cls, d_bytes: bytes, x_bytes: bytes, y_bytes: bytes
|
||||
) -> EccKey:
|
||||
d = int.from_bytes(d_bytes, byteorder='big', signed=False)
|
||||
x = int.from_bytes(x_bytes, byteorder='big', signed=False)
|
||||
y = int.from_bytes(y_bytes, byteorder='big', signed=False)
|
||||
@@ -65,7 +71,7 @@ class EccKey:
|
||||
return cls(private_key)
|
||||
|
||||
@property
|
||||
def x(self):
|
||||
def x(self) -> bytes:
|
||||
return (
|
||||
self.private_key.public_key()
|
||||
.public_numbers()
|
||||
@@ -73,14 +79,14 @@ class EccKey:
|
||||
)
|
||||
|
||||
@property
|
||||
def y(self):
|
||||
def y(self) -> bytes:
|
||||
return (
|
||||
self.private_key.public_key()
|
||||
.public_numbers()
|
||||
.y.to_bytes(32, byteorder='big')
|
||||
)
|
||||
|
||||
def dh(self, public_key_x, public_key_y):
|
||||
def dh(self, public_key_x: bytes, public_key_y: bytes) -> bytes:
|
||||
x = int.from_bytes(public_key_x, byteorder='big', signed=False)
|
||||
y = int.from_bytes(public_key_y, byteorder='big', signed=False)
|
||||
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
|
||||
@@ -93,14 +99,23 @@ class EccKey:
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def xor(x, y):
|
||||
def xor(x: bytes, y: bytes) -> bytes:
|
||||
assert len(x) == len(y)
|
||||
return bytes(map(operator.xor, x, y))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def r():
|
||||
def reverse(input: bytes) -> bytes:
|
||||
'''
|
||||
Returns bytes of input in reversed endianness.
|
||||
'''
|
||||
return input[::-1]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def r() -> bytes:
|
||||
'''
|
||||
Generate 16 bytes of random data
|
||||
'''
|
||||
@@ -108,20 +123,20 @@ def r():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def e(key, data):
|
||||
def e(key: bytes, data: bytes) -> bytes:
|
||||
'''
|
||||
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
|
||||
|
||||
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
|
||||
'''
|
||||
|
||||
cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB())
|
||||
cipher = Cipher(algorithms.AES(reverse(key)), modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
return bytes(reversed(encryptor.update(bytes(reversed(data)))))
|
||||
return reverse(encryptor.update(reverse(data)))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def ah(k, r): # pylint: disable=redefined-outer-name
|
||||
def ah(k: bytes, r: bytes) -> bytes: # pylint: disable=redefined-outer-name
|
||||
'''
|
||||
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
|
||||
'''
|
||||
@@ -132,7 +147,16 @@ def ah(k, r): # pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-name
|
||||
def c1(
|
||||
k: bytes,
|
||||
r: bytes,
|
||||
preq: bytes,
|
||||
pres: bytes,
|
||||
iat: int,
|
||||
rat: int,
|
||||
ia: bytes,
|
||||
ra: bytes,
|
||||
) -> bytes: # pylint: disable=redefined-outer-name
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
|
||||
LE Legacy Pairing
|
||||
@@ -144,7 +168,7 @@ def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-n
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def s1(k, r1, r2):
|
||||
def s1(k: bytes, r1: bytes, r2: bytes) -> bytes:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
|
||||
Pairing
|
||||
@@ -154,7 +178,7 @@ def s1(k, r1, r2):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def aes_cmac(m, k):
|
||||
def aes_cmac(m: bytes, k: bytes) -> bytes:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
|
||||
|
||||
@@ -166,20 +190,16 @@ def aes_cmac(m, k):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def f4(u, v, x, z):
|
||||
def f4(u: bytes, v: bytes, x: bytes, z: bytes) -> bytes:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
|
||||
Generation Function f4
|
||||
'''
|
||||
return bytes(
|
||||
reversed(
|
||||
aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))
|
||||
)
|
||||
)
|
||||
return reverse(aes_cmac(reverse(u) + reverse(v) + z, reverse(x)))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def f5(w, n1, n2, a1, a2):
|
||||
def f5(w: bytes, n1: bytes, n2: bytes, a1: bytes, a2: bytes) -> Tuple[bytes, bytes]:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
|
||||
Function f5
|
||||
@@ -187,87 +207,83 @@ def f5(w, n1, n2, a1, a2):
|
||||
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
|
||||
'''
|
||||
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
|
||||
t = aes_cmac(bytes(reversed(w)), salt)
|
||||
t = aes_cmac(reverse(w), salt)
|
||||
key_id = bytes([0x62, 0x74, 0x6C, 0x65])
|
||||
return (
|
||||
bytes(
|
||||
reversed(
|
||||
aes_cmac(
|
||||
bytes([0])
|
||||
+ key_id
|
||||
+ bytes(reversed(n1))
|
||||
+ bytes(reversed(n2))
|
||||
+ bytes(reversed(a1))
|
||||
+ bytes(reversed(a2))
|
||||
+ bytes([1, 0]),
|
||||
t,
|
||||
)
|
||||
reverse(
|
||||
aes_cmac(
|
||||
bytes([0])
|
||||
+ key_id
|
||||
+ reverse(n1)
|
||||
+ reverse(n2)
|
||||
+ reverse(a1)
|
||||
+ reverse(a2)
|
||||
+ bytes([1, 0]),
|
||||
t,
|
||||
)
|
||||
),
|
||||
bytes(
|
||||
reversed(
|
||||
aes_cmac(
|
||||
bytes([1])
|
||||
+ key_id
|
||||
+ bytes(reversed(n1))
|
||||
+ bytes(reversed(n2))
|
||||
+ bytes(reversed(a1))
|
||||
+ bytes(reversed(a2))
|
||||
+ bytes([1, 0]),
|
||||
t,
|
||||
)
|
||||
reverse(
|
||||
aes_cmac(
|
||||
bytes([1])
|
||||
+ key_id
|
||||
+ reverse(n1)
|
||||
+ reverse(n2)
|
||||
+ reverse(a1)
|
||||
+ reverse(a2)
|
||||
+ bytes([1, 0]),
|
||||
t,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def f6(w, n1, n2, r, io_cap, a1, a2): # pylint: disable=redefined-outer-name
|
||||
def f6(
|
||||
w: bytes, n1: bytes, n2: bytes, r: bytes, io_cap: bytes, a1: bytes, a2: bytes
|
||||
) -> bytes: # pylint: disable=redefined-outer-name
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
|
||||
Generation Function f6
|
||||
'''
|
||||
return bytes(
|
||||
reversed(
|
||||
aes_cmac(
|
||||
bytes(reversed(n1))
|
||||
+ bytes(reversed(n2))
|
||||
+ bytes(reversed(r))
|
||||
+ bytes(reversed(io_cap))
|
||||
+ bytes(reversed(a1))
|
||||
+ bytes(reversed(a2)),
|
||||
bytes(reversed(w)),
|
||||
)
|
||||
return reverse(
|
||||
aes_cmac(
|
||||
reverse(n1)
|
||||
+ reverse(n2)
|
||||
+ reverse(r)
|
||||
+ reverse(io_cap)
|
||||
+ reverse(a1)
|
||||
+ reverse(a2),
|
||||
reverse(w),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def g2(u, v, x, y):
|
||||
def g2(u: bytes, v: bytes, x: bytes, y: bytes) -> int:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
|
||||
Value Generation Function g2
|
||||
'''
|
||||
return int.from_bytes(
|
||||
aes_cmac(
|
||||
bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)),
|
||||
bytes(reversed(x)),
|
||||
reverse(u) + reverse(v) + reverse(y),
|
||||
reverse(x),
|
||||
)[-4:],
|
||||
byteorder='big',
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def h6(w, key_id):
|
||||
def h6(w: bytes, key_id: bytes) -> bytes:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
|
||||
'''
|
||||
return aes_cmac(key_id, w)
|
||||
return reverse(aes_cmac(key_id, reverse(w)))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def h7(salt, w):
|
||||
def h7(salt: bytes, w: bytes) -> bytes:
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
|
||||
'''
|
||||
return aes_cmac(w, salt)
|
||||
return reverse(aes_cmac(reverse(w), salt))
|
||||
|
||||
183
bumble/device.py
@@ -32,6 +32,7 @@ from typing import (
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Set,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
@@ -99,13 +100,20 @@ from .hci import (
|
||||
HCI_LE_Extended_Create_Connection_Command,
|
||||
HCI_LE_Rand_Command,
|
||||
HCI_LE_Read_PHY_Command,
|
||||
HCI_LE_Remove_Advertising_Set_Command,
|
||||
HCI_LE_Set_Address_Resolution_Enable_Command,
|
||||
HCI_LE_Set_Advertising_Data_Command,
|
||||
HCI_LE_Set_Advertising_Enable_Command,
|
||||
HCI_LE_Set_Advertising_Parameters_Command,
|
||||
HCI_LE_Set_Advertising_Set_Random_Address_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,
|
||||
HCI_LE_Set_Extended_Scan_Response_Data_Command,
|
||||
HCI_LE_Set_Extended_Advertising_Data_Command,
|
||||
HCI_LE_Set_Extended_Advertising_Enable_Command,
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command,
|
||||
HCI_LE_Set_PHY_Command,
|
||||
HCI_LE_Set_Random_Address_Command,
|
||||
HCI_LE_Set_Scan_Enable_Command,
|
||||
@@ -154,6 +162,7 @@ from .utils import (
|
||||
setup_event_forwarding,
|
||||
composite_listener,
|
||||
deprecated,
|
||||
experimental,
|
||||
)
|
||||
from .keys import (
|
||||
KeyStore,
|
||||
@@ -188,6 +197,8 @@ DEVICE_MIN_SCAN_WINDOW = 25
|
||||
DEVICE_MAX_SCAN_WINDOW = 10240
|
||||
DEVICE_MIN_LE_RSSI = -127
|
||||
DEVICE_MAX_LE_RSSI = 20
|
||||
DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE = 0x00
|
||||
DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE = 0xEF
|
||||
|
||||
DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00'
|
||||
DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms
|
||||
@@ -736,6 +747,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,
|
||||
@@ -956,6 +970,7 @@ class Device(CompositeEventEmitter):
|
||||
]
|
||||
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
|
||||
config: DeviceConfiguration
|
||||
extended_advertising_handles: Set[int]
|
||||
|
||||
@composite_listener
|
||||
class Listener:
|
||||
@@ -1054,6 +1069,7 @@ class Device(CompositeEventEmitter):
|
||||
self.classic_pending_accepts = {
|
||||
Address.ANY: []
|
||||
} # Futures, by BD address OR [Futures] for Address.ANY
|
||||
self.extended_advertising_handles = set()
|
||||
|
||||
# Own address type cache
|
||||
self.advertising_own_address_type = None
|
||||
@@ -1532,6 +1548,149 @@ class Device(CompositeEventEmitter):
|
||||
self.advertising = False
|
||||
self.auto_restart_advertising = False
|
||||
|
||||
@experimental('Extended Advertising is still experimental - Might be changed soon.')
|
||||
async def start_extended_advertising(
|
||||
self,
|
||||
advertising_properties: HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties = HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING,
|
||||
target: Address = Address.ANY,
|
||||
own_address_type: int = OwnAddressType.RANDOM,
|
||||
scan_response: Optional[bytes] = None,
|
||||
advertising_data: Optional[bytes] = None,
|
||||
) -> int:
|
||||
"""Starts an extended advertising set.
|
||||
|
||||
Args:
|
||||
advertising_properties: Properties to pass in HCI_LE_Set_Extended_Advertising_Parameters_Command
|
||||
target: Directed advertising target. Directed property should be set in advertising_properties arg.
|
||||
own_address_type: own address type to use in the advertising.
|
||||
scan_response: raw scan response. When a non-none value is set, HCI_LE_Set_Extended_Scan_Response_Data_Command will be sent.
|
||||
advertising_data: raw advertising data. When a non-none value is set, HCI_LE_Set_Advertising_Set_Random_Address_Command will be sent.
|
||||
|
||||
Returns:
|
||||
Handle of the new advertising set.
|
||||
"""
|
||||
|
||||
adv_handle = -1
|
||||
# Find a free handle
|
||||
for i in range(
|
||||
DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE,
|
||||
DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE + 1,
|
||||
):
|
||||
if i not in self.extended_advertising_handles:
|
||||
adv_handle = i
|
||||
break
|
||||
|
||||
if adv_handle == -1:
|
||||
raise InvalidStateError('No available advertising set.')
|
||||
|
||||
try:
|
||||
# Set the advertising parameters
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command(
|
||||
advertising_handle=adv_handle,
|
||||
advertising_event_properties=advertising_properties,
|
||||
primary_advertising_interval_min=self.advertising_interval_min,
|
||||
primary_advertising_interval_max=self.advertising_interval_max,
|
||||
primary_advertising_channel_map=(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap.CHANNEL_37
|
||||
| HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap.CHANNEL_38
|
||||
| HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap.CHANNEL_39
|
||||
),
|
||||
own_address_type=own_address_type,
|
||||
peer_address_type=target.address_type,
|
||||
peer_address=target,
|
||||
advertising_tx_power=7,
|
||||
advertising_filter_policy=0,
|
||||
primary_advertising_phy=1, # LE 1M
|
||||
secondary_advertising_max_skip=0,
|
||||
secondary_advertising_phy=1, # LE 1M
|
||||
advertising_sid=0,
|
||||
scan_request_notification_enable=0,
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
# Set the advertising data if present
|
||||
if advertising_data is not None:
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Extended_Advertising_Data_Command(
|
||||
advertising_handle=adv_handle,
|
||||
operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
|
||||
fragment_preference=0x01, # Should not fragment
|
||||
advertising_data=advertising_data,
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
# Set the scan response if present
|
||||
if scan_response is not None:
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Extended_Scan_Response_Data_Command(
|
||||
advertising_handle=adv_handle,
|
||||
operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
|
||||
fragment_preference=0x01, # Should not fragment
|
||||
scan_response_data=scan_response,
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
if own_address_type in (
|
||||
OwnAddressType.RANDOM,
|
||||
OwnAddressType.RESOLVABLE_OR_RANDOM,
|
||||
):
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Advertising_Set_Random_Address_Command(
|
||||
advertising_handle=adv_handle,
|
||||
random_address=self.random_address,
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
# Enable advertising
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Extended_Advertising_Enable_Command(
|
||||
enable=1,
|
||||
advertising_handles=[adv_handle],
|
||||
durations=[0], # Forever
|
||||
max_extended_advertising_events=[0], # Infinite
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
except HCI_Error as error:
|
||||
# When any step fails, cleanup the advertising handle.
|
||||
await self.send_command(
|
||||
HCI_LE_Remove_Advertising_Set_Command(advertising_handle=adv_handle), # type: ignore[call-arg]
|
||||
check_result=False,
|
||||
)
|
||||
raise error
|
||||
|
||||
self.extended_advertising_handles.add(adv_handle)
|
||||
return adv_handle
|
||||
|
||||
@experimental('Extended Advertising is still experimental - Might be changed soon.')
|
||||
async def stop_extended_advertising(self, adv_handle: int) -> None:
|
||||
"""Stops an extended advertising set.
|
||||
|
||||
Args:
|
||||
adv_handle: Handle of the advertising set to stop.
|
||||
"""
|
||||
# Disable advertising
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Extended_Advertising_Enable_Command(
|
||||
enable=0,
|
||||
advertising_handles=[adv_handle],
|
||||
durations=[0],
|
||||
max_extended_advertising_events=[0],
|
||||
), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
# Remove advertising set
|
||||
await self.send_command(
|
||||
HCI_LE_Remove_Advertising_Set_Command(advertising_handle=adv_handle), # type: ignore[call-arg]
|
||||
check_result=True,
|
||||
)
|
||||
self.extended_advertising_handles.remove(adv_handle)
|
||||
|
||||
@property
|
||||
def is_advertising(self):
|
||||
return self.advertising
|
||||
@@ -2193,6 +2352,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,
|
||||
@@ -3138,10 +3313,18 @@ class Device(CompositeEventEmitter):
|
||||
connection.encryption = encryption
|
||||
if (
|
||||
not connection.authenticated
|
||||
and connection.transport == BT_BR_EDR_TRANSPORT
|
||||
and encryption == HCI_Encryption_Change_Event.AES_CCM
|
||||
):
|
||||
connection.authenticated = True
|
||||
connection.sc = True
|
||||
if (
|
||||
not connection.authenticated
|
||||
and connection.transport == BT_LE_TRANSPORT
|
||||
and encryption == HCI_Encryption_Change_Event.E0_OR_AES_CCM
|
||||
):
|
||||
connection.authenticated = True
|
||||
connection.sc = True
|
||||
connection.emit('connection_encryption_change')
|
||||
|
||||
@host_event_handler
|
||||
|
||||
423
bumble/hci.py
@@ -21,7 +21,7 @@ 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 (
|
||||
@@ -149,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
|
||||
@@ -3828,8 +3829,10 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
|
||||
'advertising_event_properties',
|
||||
{
|
||||
'size': 2,
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string(
|
||||
x
|
||||
'mapper': lambda x: str(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
|
||||
x
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -3839,8 +3842,8 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
|
||||
'primary_advertising_channel_map',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string(
|
||||
x
|
||||
'mapper': lambda x: str(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap(x)
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -3862,38 +3865,33 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
|
||||
See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
|
||||
'''
|
||||
|
||||
CONNECTABLE_ADVERTISING = 0
|
||||
SCANNABLE_ADVERTISING = 1
|
||||
DIRECTED_ADVERTISING = 2
|
||||
HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3
|
||||
USE_LEGACY_ADVERTISING_PDUS = 4
|
||||
ANONYMOUS_ADVERTISING = 5
|
||||
INCLUDE_TX_POWER = 6
|
||||
class AdvertisingProperties(enum.IntFlag):
|
||||
CONNECTABLE_ADVERTISING = 1 << 0
|
||||
SCANNABLE_ADVERTISING = 1 << 1
|
||||
DIRECTED_ADVERTISING = 1 << 2
|
||||
HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 1 << 3
|
||||
USE_LEGACY_ADVERTISING_PDUS = 1 << 4
|
||||
ANONYMOUS_ADVERTISING = 1 << 5
|
||||
INCLUDE_TX_POWER = 1 << 6
|
||||
|
||||
ADVERTISING_PROPERTIES_NAMES = (
|
||||
'CONNECTABLE_ADVERTISING',
|
||||
'SCANNABLE_ADVERTISING',
|
||||
'DIRECTED_ADVERTISING',
|
||||
'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING',
|
||||
'USE_LEGACY_ADVERTISING_PDUS',
|
||||
'ANONYMOUS_ADVERTISING',
|
||||
'INCLUDE_TX_POWER',
|
||||
)
|
||||
def __str__(self) -> str:
|
||||
return '|'.join(
|
||||
flag.name
|
||||
for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties
|
||||
if self.value & flag.value and flag.name is not None
|
||||
)
|
||||
|
||||
CHANNEL_37 = 0
|
||||
CHANNEL_38 = 1
|
||||
CHANNEL_39 = 2
|
||||
class ChannelMap(enum.IntFlag):
|
||||
CHANNEL_37 = 1 << 0
|
||||
CHANNEL_38 = 1 << 1
|
||||
CHANNEL_39 = 1 << 2
|
||||
|
||||
CHANNEL_NAMES = ('37', '38', '39')
|
||||
|
||||
@classmethod
|
||||
def advertising_properties_string(cls, properties):
|
||||
# pylint: disable=line-too-long
|
||||
return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]'
|
||||
|
||||
@classmethod
|
||||
def channel_map_string(cls, channel_map):
|
||||
return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
|
||||
def __str__(self) -> str:
|
||||
return '|'.join(
|
||||
flag.name
|
||||
for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
|
||||
if self.value & flag.value and flag.name is not None
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -3905,9 +3903,9 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
|
||||
'operation',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
|
||||
x
|
||||
),
|
||||
).name,
|
||||
},
|
||||
),
|
||||
('fragment_preference', 1),
|
||||
@@ -3925,23 +3923,12 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
|
||||
See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
|
||||
'''
|
||||
|
||||
INTERMEDIATE_FRAGMENT = 0x00
|
||||
FIRST_FRAGMENT = 0x01
|
||||
LAST_FRAGMENT = 0x02
|
||||
COMPLETE_DATA = 0x03
|
||||
UNCHANGED_DATA = 0x04
|
||||
|
||||
OPERATION_NAMES = {
|
||||
INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
|
||||
FIRST_FRAGMENT: 'FIRST_FRAGMENT',
|
||||
LAST_FRAGMENT: 'LAST_FRAGMENT',
|
||||
COMPLETE_DATA: 'COMPLETE_DATA',
|
||||
UNCHANGED_DATA: 'UNCHANGED_DATA',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def operation_name(cls, operation):
|
||||
return name_or_number(cls.OPERATION_NAMES, operation)
|
||||
class Operation(enum.IntEnum):
|
||||
INTERMEDIATE_FRAGMENT = 0x00
|
||||
FIRST_FRAGMENT = 0x01
|
||||
LAST_FRAGMENT = 0x02
|
||||
COMPLETE_DATA = 0x03
|
||||
UNCHANGED_DATA = 0x04
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -3953,9 +3940,9 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
|
||||
'operation',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
|
||||
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
|
||||
x
|
||||
),
|
||||
).name,
|
||||
},
|
||||
),
|
||||
('fragment_preference', 1),
|
||||
@@ -3973,22 +3960,6 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
|
||||
See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
|
||||
'''
|
||||
|
||||
INTERMEDIATE_FRAGMENT = 0x00
|
||||
FIRST_FRAGMENT = 0x01
|
||||
LAST_FRAGMENT = 0x02
|
||||
COMPLETE_DATA = 0x03
|
||||
|
||||
OPERATION_NAMES = {
|
||||
INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
|
||||
FIRST_FRAGMENT: 'FIRST_FRAGMENT',
|
||||
LAST_FRAGMENT: 'LAST_FRAGMENT',
|
||||
COMPLETE_DATA: 'COMPLETE_DATA',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def operation_name(cls, operation):
|
||||
return name_or_number(cls.OPERATION_NAMES, operation)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
@@ -4386,6 +4357,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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -5005,6 +5128,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):
|
||||
@@ -5815,18 +5980,17 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
h, data_total_length = struct.unpack_from('<HB', packet, 1)
|
||||
connection_handle = h & 0xFFF
|
||||
packet_status = (h >> 12) & 0b11
|
||||
rfu = (h >> 14) & 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, rfu, data_total_length, data
|
||||
connection_handle, packet_status, data_total_length, data
|
||||
)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
h = (self.packet_status << 12) | (self.rfu << 14) | self.connection_handle
|
||||
h = (self.packet_status << 12) | self.connection_handle
|
||||
return (
|
||||
struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
|
||||
+ self.data
|
||||
@@ -5836,13 +6000,11 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
self,
|
||||
connection_handle: int,
|
||||
packet_status: int,
|
||||
rfu: int,
|
||||
data_total_length: int,
|
||||
data: bytes,
|
||||
) -> None:
|
||||
self.connection_handle = connection_handle
|
||||
self.packet_status = packet_status
|
||||
self.rfu = rfu
|
||||
self.data_total_length = data_total_length
|
||||
self.data = data
|
||||
|
||||
@@ -5853,12 +6015,119 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
return (
|
||||
f'{color("SCO", "blue")}: '
|
||||
f'handle=0x{self.connection_handle:04x}, '
|
||||
f'ps={self.packet_status}, rfu={self.rfu}, '
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
109
bumble/smp.py
@@ -187,8 +187,8 @@ SMP_KEYPRESS_AUTHREQ = 0b00010000
|
||||
SMP_CT2_AUTHREQ = 0b00100000
|
||||
|
||||
# Crypto salt
|
||||
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
|
||||
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
|
||||
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
|
||||
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
@@ -579,7 +579,7 @@ class OobContext:
|
||||
self.r = crypto.r() if r is None else r
|
||||
|
||||
def share(self) -> OobSharedData:
|
||||
pkx = bytes(reversed(self.ecc_key.x))
|
||||
pkx = self.ecc_key.x[::-1]
|
||||
return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
|
||||
|
||||
|
||||
@@ -677,6 +677,13 @@ class Session:
|
||||
},
|
||||
}
|
||||
|
||||
ea: bytes
|
||||
eb: bytes
|
||||
ltk: bytes
|
||||
preq: bytes
|
||||
pres: bytes
|
||||
tk: bytes
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: Manager,
|
||||
@@ -686,15 +693,10 @@ class Session:
|
||||
) -> None:
|
||||
self.manager = manager
|
||||
self.connection = connection
|
||||
self.preq: Optional[bytes] = None
|
||||
self.pres: Optional[bytes] = None
|
||||
self.ea = None
|
||||
self.eb = None
|
||||
self.stk = None
|
||||
self.ltk = None
|
||||
self.ltk_ediv = 0
|
||||
self.ltk_rand = bytes(8)
|
||||
self.link_key = None
|
||||
self.link_key: Optional[bytes] = None
|
||||
self.initiator_key_distribution: int = 0
|
||||
self.responder_key_distribution: int = 0
|
||||
self.peer_random_value: Optional[bytes] = None
|
||||
@@ -787,9 +789,7 @@ class Session:
|
||||
)
|
||||
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:
|
||||
if pairing_config.oob.legacy_context is not None:
|
||||
self.tk = pairing_config.oob.legacy_context.tk
|
||||
else:
|
||||
if pairing_config.oob.legacy_context is None:
|
||||
@@ -807,7 +807,7 @@ class Session:
|
||||
|
||||
@property
|
||||
def pkx(self) -> Tuple[bytes, bytes]:
|
||||
return (bytes(reversed(self.ecc_key.x)), self.peer_public_key_x)
|
||||
return (self.ecc_key.x[::-1], self.peer_public_key_x)
|
||||
|
||||
@property
|
||||
def pka(self) -> bytes:
|
||||
@@ -1061,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.ecc_key.x)),
|
||||
public_key_y=bytes(reversed(self.ecc_key.y)),
|
||||
public_key_x=self.ecc_key.x[::-1],
|
||||
public_key_y=self.ecc_key.y[::-1],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1098,15 +1098,52 @@ class Session:
|
||||
)
|
||||
)
|
||||
|
||||
async def derive_ltk(self) -> None:
|
||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
||||
assert link_key is not None
|
||||
@classmethod
|
||||
def derive_ltk(cls, link_key: bytes, ct2: bool) -> bytes:
|
||||
'''Derives Long Term Key from Link Key.
|
||||
|
||||
Args:
|
||||
link_key: BR/EDR Link Key bytes in little-endian.
|
||||
ct2: whether ct2 is supported on both devices.
|
||||
Returns:
|
||||
LE Long Tern Key bytes in little-endian.
|
||||
'''
|
||||
ilk = (
|
||||
crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
|
||||
if self.ct2
|
||||
if ct2
|
||||
else crypto.h6(link_key, b'tmp2')
|
||||
)
|
||||
self.ltk = crypto.h6(ilk, b'brle')
|
||||
return crypto.h6(ilk, b'brle')
|
||||
|
||||
@classmethod
|
||||
def derive_link_key(cls, ltk: bytes, ct2: bool) -> bytes:
|
||||
'''Derives Link Key from Long Term Key.
|
||||
|
||||
Args:
|
||||
ltk: LE Long Term Key bytes in little-endian.
|
||||
ct2: whether ct2 is supported on both devices.
|
||||
Returns:
|
||||
BR/EDR Link Key bytes in little-endian.
|
||||
'''
|
||||
ilk = (
|
||||
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=ltk)
|
||||
if ct2
|
||||
else crypto.h6(ltk, b'tmp1')
|
||||
)
|
||||
return crypto.h6(ilk, b'lebr')
|
||||
|
||||
async def get_link_key_and_derive_ltk(self) -> None:
|
||||
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
||||
if link_key is None:
|
||||
logging.warning(
|
||||
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
|
||||
)
|
||||
self.send_pairing_failed(
|
||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
||||
)
|
||||
else:
|
||||
self.ltk = self.derive_ltk(link_key, self.ct2)
|
||||
|
||||
def distribute_keys(self) -> None:
|
||||
# Distribute the keys as required
|
||||
@@ -1117,7 +1154,7 @@ class Session:
|
||||
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||
):
|
||||
self.ctkd_task = self.connection.abort_on(
|
||||
'disconnection', self.derive_ltk()
|
||||
'disconnection', self.get_link_key_and_derive_ltk()
|
||||
)
|
||||
elif not self.sc:
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
@@ -1147,12 +1184,7 @@ class Session:
|
||||
|
||||
# CTKD, calculate BR/EDR link key
|
||||
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||
ilk = (
|
||||
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
|
||||
if self.ct2
|
||||
else crypto.h6(self.ltk, b'tmp1')
|
||||
)
|
||||
self.link_key = crypto.h6(ilk, b'lebr')
|
||||
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||
|
||||
else:
|
||||
# CTKD: Derive LTK from LinkKey
|
||||
@@ -1161,7 +1193,7 @@ class Session:
|
||||
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
||||
):
|
||||
self.ctkd_task = self.connection.abort_on(
|
||||
'disconnection', self.derive_ltk()
|
||||
'disconnection', self.get_link_key_and_derive_ltk()
|
||||
)
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
elif not self.sc:
|
||||
@@ -1191,12 +1223,7 @@ class Session:
|
||||
|
||||
# CTKD, calculate BR/EDR link key
|
||||
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||
ilk = (
|
||||
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
|
||||
if self.ct2
|
||||
else crypto.h6(self.ltk, b'tmp1')
|
||||
)
|
||||
self.link_key = crypto.h6(ilk, b'lebr')
|
||||
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||
|
||||
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
|
||||
# Set our expectations for what to wait for in the key distribution phase
|
||||
@@ -1754,14 +1781,10 @@ class Session:
|
||||
self.peer_public_key_y = command.public_key_y
|
||||
|
||||
# Compute the DH key
|
||||
self.dh_key = bytes(
|
||||
reversed(
|
||||
self.ecc_key.dh(
|
||||
bytes(reversed(command.public_key_x)),
|
||||
bytes(reversed(command.public_key_y)),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.dh_key = self.ecc_key.dh(
|
||||
command.public_key_x[::-1],
|
||||
command.public_key_y[::-1],
|
||||
)[::-1]
|
||||
logger.debug(f'DH key: {self.dh_key.hex()}')
|
||||
|
||||
if self.pairing_method == PairingMethod.OOB:
|
||||
@@ -1824,7 +1847,6 @@ class Session:
|
||||
else:
|
||||
self.send_pairing_dhkey_check_command()
|
||||
else:
|
||||
assert self.ltk
|
||||
self.start_encryption(self.ltk)
|
||||
|
||||
def on_smp_pairing_failed_command(
|
||||
@@ -1874,6 +1896,7 @@ class Manager(EventEmitter):
|
||||
sessions: Dict[int, Session]
|
||||
pairing_config_factory: Callable[[Connection], PairingConfig]
|
||||
session_proxy: Type[Session]
|
||||
_ecc_key: Optional[crypto.EccKey]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -24,9 +24,10 @@ import platform
|
||||
|
||||
import usb1
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
from bumble.transport.common import Transport, ParserSource
|
||||
from bumble import hci
|
||||
from bumble.colors import color
|
||||
from bumble.utils import AsyncRunner
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -113,7 +114,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
def __init__(self, device, acl_out):
|
||||
self.device = device
|
||||
self.acl_out = acl_out
|
||||
self.transfer = device.getTransfer()
|
||||
self.acl_out_transfer = device.getTransfer()
|
||||
self.packets = collections.deque() # Queue of packets waiting to be sent
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.cancel_done = self.loop.create_future()
|
||||
@@ -137,21 +138,20 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
# The queue was previously empty, re-prime the pump
|
||||
self.process_queue()
|
||||
|
||||
def on_packet_sent(self, transfer):
|
||||
def transfer_callback(self, transfer):
|
||||
status = transfer.getStatus()
|
||||
# logger.debug(f'<<< USB out transfer callback: status={status}')
|
||||
|
||||
# pylint: disable=no-member
|
||||
if status == usb1.TRANSFER_COMPLETED:
|
||||
self.loop.call_soon_threadsafe(self.on_packet_sent_)
|
||||
self.loop.call_soon_threadsafe(self.on_packet_sent)
|
||||
elif status == usb1.TRANSFER_CANCELLED:
|
||||
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
|
||||
else:
|
||||
logger.warning(
|
||||
color(f'!!! out transfer not completed: status={status}', 'red')
|
||||
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
||||
)
|
||||
|
||||
def on_packet_sent_(self):
|
||||
def on_packet_sent(self):
|
||||
if self.packets:
|
||||
self.packets.popleft()
|
||||
self.process_queue()
|
||||
@@ -163,22 +163,20 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
packet = self.packets[0]
|
||||
packet_type = packet[0]
|
||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||
self.transfer.setBulk(
|
||||
self.acl_out, packet[1:], callback=self.on_packet_sent
|
||||
self.acl_out_transfer.setBulk(
|
||||
self.acl_out, packet[1:], callback=self.transfer_callback
|
||||
)
|
||||
logger.debug('submit ACL')
|
||||
self.transfer.submit()
|
||||
self.acl_out_transfer.submit()
|
||||
elif packet_type == hci.HCI_COMMAND_PACKET:
|
||||
self.transfer.setControl(
|
||||
self.acl_out_transfer.setControl(
|
||||
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
packet[1:],
|
||||
callback=self.on_packet_sent,
|
||||
callback=self.transfer_callback,
|
||||
)
|
||||
logger.debug('submit COMMAND')
|
||||
self.transfer.submit()
|
||||
self.acl_out_transfer.submit()
|
||||
else:
|
||||
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
|
||||
|
||||
@@ -193,11 +191,11 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
self.packets.clear()
|
||||
|
||||
# If we have a transfer in flight, cancel it
|
||||
if self.transfer.isSubmitted():
|
||||
if self.acl_out_transfer.isSubmitted():
|
||||
# Try to cancel the transfer, but that may fail because it may have
|
||||
# already completed
|
||||
try:
|
||||
self.transfer.cancel()
|
||||
self.acl_out_transfer.cancel()
|
||||
|
||||
logger.debug('waiting for OUT transfer cancellation to be done...')
|
||||
await self.cancel_done
|
||||
@@ -206,27 +204,22 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
logger.debug('OUT transfer likely already completed')
|
||||
|
||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||
def __init__(self, context, device, metadata, acl_in, events_in):
|
||||
def __init__(self, device, metadata, acl_in, events_in):
|
||||
super().__init__()
|
||||
self.context = context
|
||||
self.device = device
|
||||
self.metadata = metadata
|
||||
self.acl_in = acl_in
|
||||
self.acl_in_transfer = None
|
||||
self.events_in = events_in
|
||||
self.events_in_transfer = None
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.queue = asyncio.Queue()
|
||||
self.dequeue_task = None
|
||||
self.closed = False
|
||||
self.event_loop_done = self.loop.create_future()
|
||||
self.cancel_done = {
|
||||
hci.HCI_EVENT_PACKET: self.loop.create_future(),
|
||||
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
|
||||
}
|
||||
self.events_in_transfer = None
|
||||
self.acl_in_transfer = None
|
||||
|
||||
# Create a thread to process events
|
||||
self.event_thread = threading.Thread(target=self.run)
|
||||
self.closed = False
|
||||
|
||||
def start(self):
|
||||
# Set up transfer objects for input
|
||||
@@ -234,7 +227,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
self.events_in_transfer.setInterrupt(
|
||||
self.events_in,
|
||||
READ_SIZE,
|
||||
callback=self.on_packet_received,
|
||||
callback=self.transfer_callback,
|
||||
user_data=hci.HCI_EVENT_PACKET,
|
||||
)
|
||||
self.events_in_transfer.submit()
|
||||
@@ -243,22 +236,23 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
self.acl_in_transfer.setBulk(
|
||||
self.acl_in,
|
||||
READ_SIZE,
|
||||
callback=self.on_packet_received,
|
||||
callback=self.transfer_callback,
|
||||
user_data=hci.HCI_ACL_DATA_PACKET,
|
||||
)
|
||||
self.acl_in_transfer.submit()
|
||||
|
||||
self.dequeue_task = self.loop.create_task(self.dequeue())
|
||||
self.event_thread.start()
|
||||
|
||||
def on_packet_received(self, transfer):
|
||||
@property
|
||||
def usb_transfer_submitted(self):
|
||||
return (
|
||||
self.events_in_transfer.isSubmitted()
|
||||
or self.acl_in_transfer.isSubmitted()
|
||||
)
|
||||
|
||||
def transfer_callback(self, transfer):
|
||||
packet_type = transfer.getUserData()
|
||||
status = transfer.getStatus()
|
||||
# logger.debug(
|
||||
# f'<<< USB IN transfer callback: status={status} '
|
||||
# f'packet_type={packet_type} '
|
||||
# f'length={transfer.getActualLength()}'
|
||||
# )
|
||||
|
||||
# pylint: disable=no-member
|
||||
if status == usb1.TRANSFER_COMPLETED:
|
||||
@@ -267,18 +261,18 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
+ transfer.getBuffer()[: transfer.getActualLength()]
|
||||
)
|
||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
|
||||
|
||||
# Re-submit the transfer so we can receive more data
|
||||
transfer.submit()
|
||||
elif status == usb1.TRANSFER_CANCELLED:
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.cancel_done[packet_type].set_result, None
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
color(f'!!! transfer not completed: status={status}', 'red')
|
||||
color(f'!!! IN transfer not completed: status={status}', 'red')
|
||||
)
|
||||
|
||||
# Re-submit the transfer so we can receive more data
|
||||
transfer.submit()
|
||||
self.loop.call_soon_threadsafe(self.on_transport_lost)
|
||||
|
||||
async def dequeue(self):
|
||||
while not self.closed:
|
||||
@@ -288,21 +282,6 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
return
|
||||
self.parser.feed_data(packet)
|
||||
|
||||
def run(self):
|
||||
logger.debug('starting USB event loop')
|
||||
while (
|
||||
self.events_in_transfer.isSubmitted()
|
||||
or self.acl_in_transfer.isSubmitted()
|
||||
):
|
||||
# pylint: disable=no-member
|
||||
try:
|
||||
self.context.handleEvents()
|
||||
except usb1.USBErrorInterrupted:
|
||||
pass
|
||||
|
||||
logger.debug('USB event loop done')
|
||||
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
@@ -331,15 +310,14 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
f'IN[{packet_type}] transfer likely already completed'
|
||||
)
|
||||
|
||||
# Wait for the thread to terminate
|
||||
await self.event_loop_done
|
||||
|
||||
class UsbTransport(Transport):
|
||||
def __init__(self, context, device, interface, setting, source, sink):
|
||||
super().__init__(source, sink)
|
||||
self.context = context
|
||||
self.device = device
|
||||
self.interface = interface
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.event_loop_done = self.loop.create_future()
|
||||
|
||||
# Get exclusive access
|
||||
device.claimInterface(interface)
|
||||
@@ -352,6 +330,22 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
source.start()
|
||||
sink.start()
|
||||
|
||||
# Create a thread to process events
|
||||
self.event_thread = threading.Thread(target=self.run)
|
||||
self.event_thread.start()
|
||||
|
||||
def run(self):
|
||||
logger.debug('starting USB event loop')
|
||||
while self.source.usb_transfer_submitted:
|
||||
# pylint: disable=no-member
|
||||
try:
|
||||
self.context.handleEvents()
|
||||
except usb1.USBErrorInterrupted:
|
||||
pass
|
||||
|
||||
logger.debug('USB event loop done')
|
||||
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
|
||||
|
||||
async def close(self):
|
||||
self.source.close()
|
||||
self.sink.close()
|
||||
@@ -361,6 +355,9 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
self.device.close()
|
||||
self.context.close()
|
||||
|
||||
# Wait for the thread to terminate
|
||||
await self.event_loop_done
|
||||
|
||||
# Find the device according to the spec moniker
|
||||
load_libusb()
|
||||
context = usb1.USBContext()
|
||||
@@ -540,7 +537,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
except usb1.USBError:
|
||||
logger.warning('failed to set configuration')
|
||||
|
||||
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
|
||||
source = UsbPacketSource(device, device_metadata, acl_in, events_in)
|
||||
sink = UsbPacketSink(device, acl_out)
|
||||
return UsbTransport(context, device, interface, setting, source, sink)
|
||||
except usb1.USBError as error:
|
||||
|
||||
@@ -432,7 +432,7 @@ def wrap_async(function):
|
||||
|
||||
def deprecated(msg: str):
|
||||
"""
|
||||
Throw deprecation warning before execution
|
||||
Throw deprecation warning before execution.
|
||||
"""
|
||||
|
||||
def wrapper(function):
|
||||
@@ -444,3 +444,19 @@ def deprecated(msg: str):
|
||||
return inner
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def experimental(msg: str):
|
||||
"""
|
||||
Throws a future warning before execution.
|
||||
"""
|
||||
|
||||
def wrapper(function):
|
||||
@wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, FutureWarning)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -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
|
||||
|
||||
64
docs/mkdocs/src/extras/android_bt_bench.md
Normal 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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
69
examples/run_extended_advertiser.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from bumble.device import AdvertisingType, Device
|
||||
from bumble.hci import Address, HCI_LE_Set_Extended_Advertising_Parameters_Command
|
||||
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
'Usage: run_extended_advertiser.py <config-file> <transport-spec> [type] [address]'
|
||||
)
|
||||
print('example: run_extended_advertiser.py device1.json usb:0')
|
||||
return
|
||||
|
||||
if len(sys.argv) >= 4:
|
||||
advertising_properties = (
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
|
||||
int(sys.argv[3])
|
||||
)
|
||||
)
|
||||
else:
|
||||
advertising_properties = (
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
||||
)
|
||||
|
||||
if len(sys.argv) >= 5:
|
||||
target = Address(sys.argv[4])
|
||||
else:
|
||||
target = Address.ANY
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
await device.power_on()
|
||||
await device.start_extended_advertising(
|
||||
advertising_properties=advertising_properties, target=target
|
||||
)
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
@@ -49,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(
|
||||
@@ -184,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')
|
||||
|
||||
|
||||
@@ -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...')
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/build
|
||||
70
extras/android/BtBench/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
21
extras/android/BtBench/app/proguard-rules.pro
vendored
Normal 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
|
||||
40
extras/android/BtBench/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
extras/android/BtBench/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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({})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
10
extras/android/BtBench/app/src/main/res/values/colors.xml
Normal 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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">BT Bench</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.BTBench" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
@@ -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>
|
||||
7
extras/android/BtBench/build.gradle.kts
Normal 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
|
||||
23
extras/android/BtBench/gradle.properties
Normal 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
|
||||
31
extras/android/BtBench/gradle/libs.versions.toml
Normal 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" }
|
||||
|
||||
BIN
extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
|
||||
24
extras/android/BtBench/settings.gradle.kts
Normal 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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# Copyright 2021-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.
|
||||
@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
|
||||
|
||||
[options]
|
||||
python_requires = >=3.8
|
||||
packages = bumble, bumble.transport, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
|
||||
packages = bumble, bumble.transport, bumble.transport.grpc_protobuf, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
|
||||
package_dir =
|
||||
bumble = bumble
|
||||
bumble.apps = apps
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
|
||||
@@ -38,7 +38,6 @@ from bumble.smp import (
|
||||
OobLegacyContext,
|
||||
)
|
||||
from bumble.core import ProtocolError
|
||||
from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
|
||||
from bumble.keys import PairingKeys
|
||||
|
||||
|
||||
@@ -519,16 +518,8 @@ async def test_self_smp_over_classic():
|
||||
# Mock connection
|
||||
# TODO: Implement Classic SSP and encryption in link relayer
|
||||
LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
|
||||
two_devices.devices[0].on_link_key(
|
||||
two_devices.devices[1].public_address,
|
||||
LINK_KEY,
|
||||
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||
)
|
||||
two_devices.devices[1].on_link_key(
|
||||
two_devices.devices[0].public_address,
|
||||
LINK_KEY,
|
||||
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||
)
|
||||
two_devices.devices[0].get_link_key = AsyncMock(return_value=LINK_KEY)
|
||||
two_devices.devices[1].get_link_key = AsyncMock(return_value=LINK_KEY)
|
||||
two_devices.connections[0].encryption = 1
|
||||
two_devices.connections[1].encryption = 1
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
import pytest
|
||||
|
||||
from bumble import smp
|
||||
from bumble.crypto import EccKey, aes_cmac, ah, c1, f4, f5, f6, g2, h6, h7, s1
|
||||
from bumble.pairing import OobData, OobSharedData, LeRole
|
||||
from bumble.hci import Address
|
||||
@@ -28,8 +31,8 @@ from bumble.core import AdvertisingData
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def reversed_hex(hex_str):
|
||||
return bytes(reversed(bytes.fromhex(hex_str)))
|
||||
def reversed_hex(hex_str: str) -> bytes:
|
||||
return bytes.fromhex(hex_str)[::-1]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -129,112 +132,79 @@ def test_aes_cmac():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_f4():
|
||||
u = bytes(
|
||||
reversed(
|
||||
bytes.fromhex(
|
||||
'20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
|
||||
+ 'eff49111 acf4fddb cc030148 0e359de6'
|
||||
)
|
||||
)
|
||||
u = reversed_hex(
|
||||
'20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
|
||||
)
|
||||
v = bytes(
|
||||
reversed(
|
||||
bytes.fromhex(
|
||||
'55188b3d 32f6bb9a 900afcfb eed4e72a'
|
||||
+ '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
|
||||
)
|
||||
)
|
||||
v = reversed_hex(
|
||||
'55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
|
||||
)
|
||||
x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
|
||||
z = bytes([0])
|
||||
x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
|
||||
z = b'\0'
|
||||
value = f4(u, v, x, z)
|
||||
assert bytes(reversed(value)) == bytes.fromhex(
|
||||
'f2c916f1 07a9bd1c f1eda1be a974872d'
|
||||
)
|
||||
assert value == reversed_hex('f2c916f1 07a9bd1c f1eda1be a974872d')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_f5():
|
||||
w = bytes(
|
||||
reversed(
|
||||
bytes.fromhex(
|
||||
'ec0234a3 57c8ad05 341010a6 0a397d9b'
|
||||
+ '99796b13 b4f866f1 868d34f3 73bfa698'
|
||||
)
|
||||
)
|
||||
w = reversed_hex(
|
||||
'ec0234a3 57c8ad05 341010a6 0a397d9b 99796b13 b4f866f1 868d34f3 73bfa698'
|
||||
)
|
||||
n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
|
||||
n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
|
||||
a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
|
||||
a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
|
||||
n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
|
||||
n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
|
||||
a1 = reversed_hex('00561237 37bfce')
|
||||
a2 = reversed_hex('00a71370 2dcfc1')
|
||||
value = f5(w, n1, n2, a1, a2)
|
||||
assert bytes(reversed(value[0])) == bytes.fromhex(
|
||||
'2965f176 a1084a02 fd3f6a20 ce636e20'
|
||||
)
|
||||
assert bytes(reversed(value[1])) == bytes.fromhex(
|
||||
'69867911 69d7cd23 980522b5 94750a38'
|
||||
)
|
||||
assert value[0] == reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
|
||||
assert value[1] == reversed_hex('69867911 69d7cd23 980522b5 94750a38')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_f6():
|
||||
n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
|
||||
n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
|
||||
mac_key = bytes(reversed(bytes.fromhex('2965f176 a1084a02 fd3f6a20 ce636e20')))
|
||||
r = bytes(reversed(bytes.fromhex('12a3343b b453bb54 08da42d2 0c2d0fc8')))
|
||||
io_cap = bytes(reversed(bytes.fromhex('010102')))
|
||||
a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
|
||||
a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
|
||||
n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
|
||||
n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
|
||||
mac_key = reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
|
||||
r = reversed_hex('12a3343b b453bb54 08da42d2 0c2d0fc8')
|
||||
io_cap = reversed_hex('010102')
|
||||
a1 = reversed_hex('00561237 37bfce')
|
||||
a2 = reversed_hex('00a71370 2dcfc1')
|
||||
value = f6(mac_key, n1, n2, r, io_cap, a1, a2)
|
||||
assert bytes(reversed(value)) == bytes.fromhex(
|
||||
'e3c47398 9cd0e8c5 d26c0b09 da958f61'
|
||||
)
|
||||
assert value == reversed_hex('e3c47398 9cd0e8c5 d26c0b09 da958f61')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_g2():
|
||||
u = bytes(
|
||||
reversed(
|
||||
bytes.fromhex(
|
||||
'20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
|
||||
+ 'eff49111 acf4fddb cc030148 0e359de6'
|
||||
)
|
||||
)
|
||||
u = reversed_hex(
|
||||
'20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
|
||||
)
|
||||
v = bytes(
|
||||
reversed(
|
||||
bytes.fromhex(
|
||||
'55188b3d 32f6bb9a 900afcfb eed4e72a'
|
||||
+ '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
|
||||
)
|
||||
)
|
||||
v = reversed_hex(
|
||||
'55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
|
||||
)
|
||||
x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
|
||||
y = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
|
||||
x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
|
||||
y = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
|
||||
value = g2(u, v, x, y)
|
||||
assert value == 0x2F9ED5BA
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_h6():
|
||||
KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
KEY_ID = bytes.fromhex('6c656272')
|
||||
assert h6(KEY, KEY_ID) == bytes.fromhex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
|
||||
assert h6(KEY, KEY_ID) == reversed_hex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_h7():
|
||||
KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
SALT = bytes.fromhex('00000000 00000000 00000000 746D7031')
|
||||
assert h7(SALT, KEY) == bytes.fromhex('fb173597 c6a3c0ec d2998c2a 75a57011')
|
||||
assert h7(SALT, KEY) == reversed_hex('fb173597 c6a3c0ec d2998c2a 75a57011')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_ah():
|
||||
irk = bytes(reversed(bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')))
|
||||
prand = bytes(reversed(bytes.fromhex('708194')))
|
||||
irk = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
prand = reversed_hex('708194')
|
||||
value = ah(irk, prand)
|
||||
expected = bytes(reversed(bytes.fromhex('0dfbaa')))
|
||||
expected = reversed_hex('0dfbaa')
|
||||
assert value == expected
|
||||
|
||||
|
||||
@@ -243,7 +213,7 @@ def test_oob_data():
|
||||
oob_data = OobData(
|
||||
address=Address("F0:F1:F2:F3:F4:F5"),
|
||||
role=LeRole.BOTH_PERIPHERAL_PREFERRED,
|
||||
shared_data=OobSharedData(c=bytes([1, 2]), r=bytes([3, 4])),
|
||||
shared_data=OobSharedData(c=b'12', r=b'34'),
|
||||
)
|
||||
oob_data_ad = oob_data.to_ad()
|
||||
oob_data_bytes = bytes(oob_data_ad)
|
||||
@@ -255,6 +225,32 @@ def test_oob_data():
|
||||
assert oob_data_parsed.shared_data.r == oob_data.shared_data.r
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
'ct2, expected',
|
||||
[
|
||||
(False, 'bc1ca4ef 633fc1bd 0d8230af ee388fb0'),
|
||||
(True, '287ad379 dca40253 0a39f1f4 3047b835'),
|
||||
],
|
||||
)
|
||||
def test_ltk_to_link_key(ct2: bool, expected: str):
|
||||
LTK = reversed_hex('368df9bc e3264b58 bd066c33 334fbf64')
|
||||
assert smp.Session.derive_link_key(LTK, ct2) == reversed_hex(expected)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
'ct2, expected',
|
||||
[
|
||||
(False, 'a813fb72 f1a3dfa1 8a2c9a43 f10d0a30'),
|
||||
(True, 'e85e09eb 5eccb3e2 69418a13 3211bc79'),
|
||||
],
|
||||
)
|
||||
def test_link_key_to_ltk(ct2: bool, expected: str):
|
||||
LINK_KEY = reversed_hex('05040302 01000908 07060504 03020100')
|
||||
assert smp.Session.derive_ltk(LINK_KEY, ct2) == reversed_hex(expected)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_ecc()
|
||||
|
||||