Compare commits

...

26 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
8715333706 Add a GATT adapter that uses from_bytes and __bytes__ as conversion methods. 2024-11-23 09:13:04 -08:00
zxzxwu
5a72eefb89 Merge pull request #587 from zxzxwu/device
Replace HCI member imports in device.py
2024-11-13 15:25:32 +08:00
Josh Wu
430046944b Replace HCI member import in device.py 2024-11-12 16:53:21 +08:00
zxzxwu
21d23320eb Merge pull request #584 from zxzxwu/commands6.0
Add Core Spec 6.0 new commands support mapping
2024-11-12 04:17:24 +00:00
Josh Wu
2d88e853e8 Add Core Spec 6.0 new commands support mapping 2024-11-07 14:36:54 +08:00
Gilles Boccon-Gibod
a060a70fba Merge pull request #583 from google/gbg/more-gatt-tests
regression test for GATT unsubscription
2024-11-04 13:03:57 -08:00
Gilles Boccon-Gibod
a06394ad4a Merge pull request #582 from google/gbg/580
fix #580
2024-11-04 13:03:15 -08:00
Gilles Boccon-Gibod
a1414c2b5b add unsubscribe test 2024-11-03 19:08:27 -08:00
Gilles Boccon-Gibod
b2864dac2d fix #580 2024-11-02 10:29:40 -07:00
Gilles Boccon-Gibod
b78f895143 Merge pull request #579 from jmdietrich-gcx/unsubscribe_characteristic_in_gatt_client
Remove characteristic in GATT Client unsubscribe() if it's the last subscriber
2024-10-31 04:07:02 -07:00
zxzxwu
c4e9726828 Merge pull request #581 from zxzxwu/context
[BAP] Add missing Unspecified context type
2024-10-31 11:04:25 +00:00
Gilles Boccon-Gibod
d4b8e8348a Merge pull request #574 from google/gbg/update-python-versions
remove test for deprecated Python 3.8 and add 3.13
2024-10-31 03:44:01 -07:00
Josh Wu
19debaa52e [BAP] Add missing Unspecified context type 2024-10-31 18:11:40 +08:00
Jan-Marcel Dietrich
73fe564321 Remove characteristic in GATT Client unsubscribe() if it's the last subscriber
GATT Client's subscribe() adds the characteristic itself as subscriber.
Therefore the characteristic has to be removed in unsubscribe(), if it's
the last subscriber. Otherwise the clean up does not work correctly and
the CCCD never is set back to 0 in the remote device.
2024-10-30 07:34:22 +01:00
Gilles Boccon-Gibod
a00abd65b3 fix some linter warnings 2024-10-28 12:30:37 -07:00
Gilles Boccon-Gibod
f169ceaebb update linter and type checker 2024-10-28 12:30:32 -07:00
Gilles Boccon-Gibod
528af0d338 remove test for deprecated Python 3.8 and add 3.13 2024-10-28 12:29:21 -07:00
Gilles Boccon-Gibod
4b25eed869 Merge pull request #570 from google/gbg/bench-mobly-snippets
bench mobly snippets
2024-10-28 10:25:28 -07:00
Gilles Boccon-Gibod
fcd6bd7136 address PR comments 2024-10-28 10:13:55 -07:00
Gilles Boccon-Gibod
2bed50b353 add mobly to dev deps 2024-10-09 21:22:35 -07:00
Gilles Boccon-Gibod
1fe3778a74 adjust mypy excludes 2024-10-08 22:02:43 -07:00
Gilles Boccon-Gibod
5e31bcf23d add mobly example 2024-10-04 18:17:56 -07:00
Gilles Boccon-Gibod
fe429cb2eb wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
c91695c23a wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
55f99e6887 wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
b190069f48 add snippets lib 2024-10-04 18:13:31 -07:00
63 changed files with 2143 additions and 850 deletions

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
fail-fast: false
steps:

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
fail-fast: false
steps:
@@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
rust-version: [ "1.76.0", "stable" ]
fail-fast: false
steps:

View File

@@ -19,6 +19,7 @@ import asyncio
import enum
import logging
import os
import statistics
import struct
import time
@@ -194,17 +195,19 @@ def make_sdp_records(channel):
}
def log_stats(title, stats):
def log_stats(title, stats, precision=2):
stats_min = min(stats)
stats_max = max(stats)
stats_avg = sum(stats) / len(stats)
stats_avg = statistics.mean(stats)
stats_stdev = statistics.stdev(stats)
logging.info(
color(
(
f'### {title} stats: '
f'min={stats_min:.2f}, '
f'max={stats_max:.2f}, '
f'average={stats_avg:.2f}'
f'min={stats_min:.{precision}f}, '
f'max={stats_max:.{precision}f}, '
f'average={stats_avg:.{precision}f}, '
f'stdev={stats_stdev:.{precision}f}'
),
'cyan',
)
@@ -448,9 +451,9 @@ class Ping:
self.repeat_delay = repeat_delay
self.pace = pace
self.done = asyncio.Event()
self.current_packet_index = 0
self.ping_sent_time = 0.0
self.latencies = []
self.ping_times = []
self.rtts = []
self.next_expected_packet_index = 0
self.min_stats = []
self.max_stats = []
self.avg_stats = []
@@ -477,60 +480,57 @@ class Ping:
logging.info(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
self.current_packet_index = 0
self.latencies = []
await self.send_next_ping()
packet_interval = self.pace / 1000
start_time = time.time()
self.next_expected_packet_index = 0
for i in range(self.tx_packet_count):
target_time = start_time + (i * packet_interval)
now = time.time()
if now < target_time:
await asyncio.sleep(target_time - now)
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
(PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0),
i,
) + bytes(self.tx_packet_size - 6)
logging.info(color(f'Sending packet {i}', 'yellow'))
self.ping_times.append(time.time())
await self.packet_io.send_packet(packet)
await self.done.wait()
min_latency = min(self.latencies)
max_latency = max(self.latencies)
avg_latency = sum(self.latencies) / len(self.latencies)
min_rtt = min(self.rtts)
max_rtt = max(self.rtts)
avg_rtt = statistics.mean(self.rtts)
stdev_rtt = statistics.stdev(self.rtts)
logging.info(
color(
'@@@ Latencies: '
f'min={min_latency:.2f}, '
f'max={max_latency:.2f}, '
f'average={avg_latency:.2f}'
'@@@ RTTs: '
f'min={min_rtt:.2f}, '
f'max={max_rtt:.2f}, '
f'average={avg_rtt:.2f}, '
f'stdev={stdev_rtt:.2f}'
)
)
self.min_stats.append(min_latency)
self.max_stats.append(max_latency)
self.avg_stats.append(avg_latency)
self.min_stats.append(min_rtt)
self.max_stats.append(max_rtt)
self.avg_stats.append(avg_rtt)
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
if self.repeat:
log_stats('Min Latency', self.min_stats)
log_stats('Max Latency', self.max_stats)
log_stats('Average Latency', self.avg_stats)
log_stats('Min RTT', self.min_stats)
log_stats('Max RTT', self.max_stats)
log_stats('Average RTT', self.avg_stats)
if self.repeat:
logging.info(color('--- End of runs', 'blue'))
async def send_next_ping(self):
if self.pace:
await asyncio.sleep(self.pace / 1000)
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
(
PACKET_FLAG_LAST
if self.current_packet_index == self.tx_packet_count - 1
else 0
),
self.current_packet_index,
) + bytes(self.tx_packet_size - 6)
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
self.ping_sent_time = time.time()
await self.packet_io.send_packet(packet)
def on_packet_received(self, packet):
elapsed = time.time() - self.ping_sent_time
try:
packet_type, packet_data = parse_packet(packet)
except ValueError:
@@ -542,21 +542,23 @@ class Ping:
return
if packet_type == PacketType.ACK:
latency = elapsed * 1000
self.latencies.append(latency)
elapsed = time.time() - self.ping_times[packet_index]
rtt = elapsed * 1000
self.rtts.append(rtt)
logging.info(
color(
f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
'green',
)
)
if packet_index == self.current_packet_index:
self.current_packet_index += 1
if packet_index == self.next_expected_packet_index:
self.next_expected_packet_index += 1
else:
logging.info(
color(
f'!!! Unexpected packet, expected {self.current_packet_index} '
f'!!! Unexpected packet, '
f'expected {self.next_expected_packet_index} '
f'but received {packet_index}'
)
)
@@ -565,8 +567,6 @@ class Ping:
self.done.set()
return
AsyncRunner.spawn(self.send_next_ping())
# -----------------------------------------------------------------------------
# Pong
@@ -583,8 +583,11 @@ class Pong:
def reset(self):
self.expected_packet_index = 0
self.receive_times = []
def on_packet_received(self, packet):
self.receive_times.append(time.time())
try:
packet_type, packet_data = parse_packet(packet)
except ValueError:
@@ -599,10 +602,16 @@ class Pong:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
interval = (
self.receive_times[-1] - self.receive_times[-2]
if len(self.receive_times) >= 2
else 0
)
logging.info(
color(
f'<<< Received packet {packet_index}: '
f'flags=0x{packet_flags:02X}, {len(packet)} bytes',
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
f'interval={interval:.4f}',
'green',
)
)
@@ -623,8 +632,35 @@ class Pong:
)
)
if packet_flags & PACKET_FLAG_LAST and not self.linger:
self.done.set()
if packet_flags & PACKET_FLAG_LAST:
if len(self.receive_times) >= 3:
# Show basic stats
intervals = [
self.receive_times[i + 1] - self.receive_times[i]
for i in range(len(self.receive_times) - 1)
]
log_stats('Packet intervals', intervals, 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(intervals)
interval_max = max(intervals)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count)
for i in range(bin_count)
]
for interval in intervals:
for i in reversed(range(bin_count)):
if interval >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
if not self.linger:
self.done.set()
async def run(self):
await self.done.wait()
@@ -942,9 +978,12 @@ class RfcommClient(StreamedPacketIO):
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
connection, self.uuid
)
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
if channel == 0:
logging.info(color('!!! No RFComm service with this UUID found', 'red'))
if channel:
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
else:
logging.warning(
color('!!! No RFComm service with this UUID found', 'red')
)
await connection.disconnect()
return
@@ -1054,6 +1093,8 @@ class RfcommServer(StreamedPacketIO):
if self.credits_threshold is not None:
dlc.rx_credits_threshold = self.credits_threshold
self.ready.set()
async def drain(self):
assert self.dlc
await self.dlc.drain()
@@ -1068,7 +1109,7 @@ class Central(Connection.Listener):
transport,
peripheral_address,
classic,
role_factory,
scenario_factory,
mode_factory,
connection_interval,
phy,
@@ -1081,7 +1122,7 @@ class Central(Connection.Listener):
self.transport = transport
self.peripheral_address = peripheral_address
self.classic = classic
self.role_factory = role_factory
self.scenario_factory = scenario_factory
self.mode_factory = mode_factory
self.authenticate = authenticate
self.encrypt = encrypt or authenticate
@@ -1134,7 +1175,7 @@ class Central(Connection.Listener):
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
)
mode = self.mode_factory(self.device)
role = self.role_factory(mode)
scenario = self.scenario_factory(mode)
self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
@@ -1215,7 +1256,7 @@ class Central(Connection.Listener):
await mode.on_connection(self.connection)
await role.run()
await scenario.run()
await asyncio.sleep(DEFAULT_LINGER_TIME)
await self.connection.disconnect()
@@ -1246,7 +1287,7 @@ class Peripheral(Device.Listener, Connection.Listener):
def __init__(
self,
transport,
role_factory,
scenario_factory,
mode_factory,
classic,
extended_data_length,
@@ -1254,11 +1295,11 @@ class Peripheral(Device.Listener, Connection.Listener):
):
self.transport = transport
self.classic = classic
self.role_factory = role_factory
self.scenario_factory = scenario_factory
self.mode_factory = mode_factory
self.extended_data_length = extended_data_length
self.role_switch = role_switch
self.role = None
self.scenario = None
self.mode = None
self.device = None
self.connection = None
@@ -1278,7 +1319,7 @@ class Peripheral(Device.Listener, Connection.Listener):
)
self.device.listener = self
self.mode = self.mode_factory(self.device)
self.role = self.role_factory(self.mode)
self.scenario = self.scenario_factory(self.mode)
self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
@@ -1315,7 +1356,7 @@ class Peripheral(Device.Listener, Connection.Listener):
print_connection(self.connection)
await self.mode.on_connection(self.connection)
await self.role.run()
await self.scenario.run()
await asyncio.sleep(DEFAULT_LINGER_TIME)
def on_connection(self, connection):
@@ -1344,7 +1385,7 @@ class Peripheral(Device.Listener, Connection.Listener):
def on_disconnection(self, reason):
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None
self.role.reset()
self.scenario.reset()
if self.classic:
AsyncRunner.spawn(self.device.set_discoverable(True))
@@ -1426,13 +1467,13 @@ def create_mode_factory(ctx, default_mode):
# -----------------------------------------------------------------------------
def create_role_factory(ctx, default_role):
role = ctx.obj['role']
if role is None:
role = default_role
def create_scenario_factory(ctx, default_scenario):
scenario = ctx.obj['scenario']
if scenario is None:
scenarion = default_scenario
def create_role(packet_io):
if role == 'sender':
def create_scenario(packet_io):
if scenario == 'send':
return Sender(
packet_io,
start_delay=ctx.obj['start_delay'],
@@ -1443,10 +1484,10 @@ def create_role_factory(ctx, default_role):
packet_count=ctx.obj['packet_count'],
)
if role == 'receiver':
if scenario == 'receive':
return Receiver(packet_io, ctx.obj['linger'])
if role == 'ping':
if scenario == 'ping':
return Ping(
packet_io,
start_delay=ctx.obj['start_delay'],
@@ -1457,12 +1498,12 @@ def create_role_factory(ctx, default_role):
packet_count=ctx.obj['packet_count'],
)
if role == 'pong':
if scenario == 'pong':
return Pong(packet_io, ctx.obj['linger'])
raise ValueError('invalid role')
raise ValueError('invalid scenario')
return create_role
return create_scenario
# -----------------------------------------------------------------------------
@@ -1470,7 +1511,7 @@ def create_role_factory(ctx, default_role):
# -----------------------------------------------------------------------------
@click.group()
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
@click.option('--role', type=click.Choice(['sender', 'receiver', 'ping', 'pong']))
@click.option('--scenario', type=click.Choice(['send', 'receive', 'ping', 'pong']))
@click.option(
'--mode',
type=click.Choice(
@@ -1503,7 +1544,7 @@ def create_role_factory(ctx, default_role):
'--rfcomm-channel',
type=int,
default=DEFAULT_RFCOMM_CHANNEL,
help='RFComm channel to use',
help='RFComm channel to use (specify 0 for channel discovery via SDP)',
)
@click.option(
'--rfcomm-uuid',
@@ -1565,7 +1606,7 @@ def create_role_factory(ctx, default_role):
metavar='SIZE',
type=click.IntRange(8, 8192),
default=500,
help='Packet size (client or ping role)',
help='Packet size (send or ping scenario)',
)
@click.option(
'--packet-count',
@@ -1573,7 +1614,7 @@ def create_role_factory(ctx, default_role):
metavar='COUNT',
type=int,
default=10,
help='Packet count (client or ping role)',
help='Packet count (send or ping scenario)',
)
@click.option(
'--start-delay',
@@ -1581,7 +1622,7 @@ def create_role_factory(ctx, default_role):
metavar='SECONDS',
type=int,
default=1,
help='Start delay (client or ping role)',
help='Start delay (send or ping scenario)',
)
@click.option(
'--repeat',
@@ -1589,7 +1630,7 @@ def create_role_factory(ctx, default_role):
type=int,
default=0,
help=(
'Repeat the run N times (client and ping roles)'
'Repeat the run N times (send and ping scenario)'
'(0, which is the fault, to run just once) '
),
)
@@ -1613,13 +1654,13 @@ def create_role_factory(ctx, default_role):
@click.option(
'--linger',
is_flag=True,
help="Don't exit at the end of a run (server and pong roles)",
help="Don't exit at the end of a run (receive and pong scenarios)",
)
@click.pass_context
def bench(
ctx,
device_config,
role,
scenario,
mode,
att_mtu,
extended_data_length,
@@ -1645,7 +1686,7 @@ def bench(
):
ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config
ctx.obj['role'] = role
ctx.obj['scenario'] = scenario
ctx.obj['mode'] = mode
ctx.obj['att_mtu'] = att_mtu
ctx.obj['rfcomm_channel'] = rfcomm_channel
@@ -1699,7 +1740,7 @@ def central(
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
):
"""Run as a central (initiates the connection)"""
role_factory = create_role_factory(ctx, 'sender')
scenario_factory = create_scenario_factory(ctx, 'send')
mode_factory = create_mode_factory(ctx, 'gatt-client')
classic = ctx.obj['classic']
@@ -1708,7 +1749,7 @@ def central(
transport,
peripheral_address,
classic,
role_factory,
scenario_factory,
mode_factory,
connection_interval,
phy,
@@ -1726,13 +1767,13 @@ def central(
@click.pass_context
def peripheral(ctx, transport):
"""Run as a peripheral (waits for a connection)"""
role_factory = create_role_factory(ctx, 'receiver')
scenario_factory = create_scenario_factory(ctx, 'receive')
mode_factory = create_mode_factory(ctx, 'gatt-server')
async def run_peripheral():
await Peripheral(
transport,
role_factory,
scenario_factory,
mode_factory,
ctx.obj['classic'],
ctx.obj['extended_data_length'],
@@ -1743,7 +1784,11 @@ def peripheral(ctx, transport):
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
logging.basicConfig(
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
datefmt="%H:%M:%S",
)
bench()

View File

@@ -237,6 +237,7 @@ class ClientBridge:
address: str,
tcp_host: str,
tcp_port: int,
authenticate: bool,
encrypt: bool,
):
self.channel = channel
@@ -245,6 +246,7 @@ class ClientBridge:
self.address = address
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.authenticate = authenticate
self.encrypt = encrypt
self.device: Optional[Device] = None
self.connection: Optional[Connection] = None
@@ -274,6 +276,11 @@ class ClientBridge:
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
self.connection.on("disconnection", self.on_disconnection)
if self.authenticate:
print(color("@@@ Authenticating Bluetooth connection", "blue"))
await self.connection.authenticate()
print(color("@@@ Bluetooth connection authenticated", "blue"))
if self.encrypt:
print(color("@@@ Encrypting Bluetooth connection", "blue"))
await self.connection.encrypt()
@@ -491,8 +498,9 @@ def server(context, tcp_host, tcp_port):
@click.argument("bluetooth-address")
@click.option("--tcp-host", help="TCP host", default="_")
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
@click.option("--authenticate", is_flag=True, help="Authenticate the connection")
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt):
bridge = ClientBridge(
context.obj["channel"],
context.obj["uuid"],
@@ -500,6 +508,7 @@ def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
bluetooth_address,
tcp_host,
tcp_port,
authenticate,
encrypt,
)
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))

View File

@@ -759,13 +759,13 @@ class AttributeValue:
def __init__(
self,
read: Union[
Callable[[Optional[Connection]], bytes],
Callable[[Optional[Connection]], Awaitable[bytes]],
Callable[[Optional[Connection]], Any],
Callable[[Optional[Connection]], Awaitable[Any]],
None,
] = None,
write: Union[
Callable[[Optional[Connection], bytes], None],
Callable[[Optional[Connection], bytes], Awaitable[None]],
Callable[[Optional[Connection], Any], None],
Callable[[Optional[Connection], Any], Awaitable[None]],
None,
] = None,
):
@@ -824,13 +824,13 @@ class Attribute(EventEmitter):
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
value: Union[bytes, AttributeValue]
value: Any
def __init__(
self,
attribute_type: Union[str, bytes, UUID],
permissions: Union[str, Attribute.Permissions],
value: Union[str, bytes, AttributeValue] = b'',
value: Any = b'',
) -> None:
EventEmitter.__init__(self)
self.handle = 0
@@ -848,11 +848,7 @@ class Attribute(EventEmitter):
else:
self.type = attribute_type
# Convert the value to a byte array
if isinstance(value, str):
self.value = bytes(value, 'utf-8')
else:
self.value = value
self.value = value
def encode_value(self, value: Any) -> bytes:
return value
@@ -895,6 +891,8 @@ class Attribute(EventEmitter):
else:
value = self.value
self.emit('read', connection, value)
return self.encode_value(value)
async def write_value(self, connection: Connection, value_bytes: bytes) -> None:

View File

@@ -134,6 +134,8 @@ class Frame:
opcode_offset = 3
elif subunit_id == 6:
raise core.InvalidPacketError("reserved subunit ID")
else:
raise core.InvalidPacketError("invalid subunit ID")
opcode = Frame.OperationCode(data[opcode_offset])
operands = data[opcode_offset + 1 :]

File diff suppressed because it is too large Load Diff

View File

@@ -28,12 +28,15 @@ import functools
import logging
import struct
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Sequence,
SupportsBytes,
Type,
Union,
TYPE_CHECKING,
)
@@ -41,6 +44,7 @@ from typing import (
from bumble.colors import color
from bumble.core import BaseBumbleError, UUID
from bumble.att import Attribute, AttributeValue
from bumble.utils import ByteSerializable
if TYPE_CHECKING:
from bumble.gatt_client import AttributeProxy
@@ -343,7 +347,7 @@ class Service(Attribute):
def __init__(
self,
uuid: Union[str, UUID],
characteristics: List[Characteristic],
characteristics: Iterable[Characteristic],
primary=True,
included_services: Iterable[Service] = (),
) -> None:
@@ -362,7 +366,7 @@ class Service(Attribute):
)
self.uuid = uuid
self.included_services = list(included_services)
self.characteristics = characteristics[:]
self.characteristics = list(characteristics)
self.primary = primary
def get_advertising_data(self) -> Optional[bytes]:
@@ -393,7 +397,7 @@ class TemplateService(Service):
def __init__(
self,
characteristics: List[Characteristic],
characteristics: Iterable[Characteristic],
primary: bool = True,
included_services: Iterable[Service] = (),
) -> None:
@@ -490,7 +494,7 @@ class Characteristic(Attribute):
uuid: Union[str, bytes, UUID],
properties: Characteristic.Properties,
permissions: Union[str, Attribute.Permissions],
value: Union[str, bytes, CharacteristicValue] = b'',
value: Any = b'',
descriptors: Sequence[Descriptor] = (),
):
super().__init__(uuid, permissions, value)
@@ -525,7 +529,11 @@ class CharacteristicDeclaration(Attribute):
characteristic: Characteristic
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
def __init__(
self,
characteristic: Characteristic,
value_handle: int,
) -> None:
declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle)
+ characteristic.uuid.to_pdu_bytes()
@@ -705,7 +713,7 @@ class MappedCharacteristicAdapter(PackedCharacteristicAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
The adapted `read_value` and `write_value` methods return/accept aa dictionary which
The adapted `read_value` and `write_value` methods return/accept a dictionary which
is packed/unpacked according to format, with the arguments extracted from the
dictionary by key, in the same order as they occur in the `keys` parameter.
'''
@@ -735,6 +743,24 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
return value.decode('utf-8')
# -----------------------------------------------------------------------------
class SerializableCharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that converts any class to/from bytes using the class'
`to_bytes` and `__bytes__` methods, respectively.
'''
def __init__(self, characteristic, cls: Type[ByteSerializable]):
super().__init__(characteristic)
self.cls = cls
def encode_value(self, value: SupportsBytes) -> bytes:
return bytes(value)
def decode_value(self, value: bytes) -> Any:
return self.cls.from_bytes(value)
# -----------------------------------------------------------------------------
class Descriptor(Attribute):
'''

View File

@@ -898,6 +898,12 @@ class Client:
) and subscriber in subscribers:
subscribers.remove(subscriber)
# The characteristic itself is added as subscriber. If it is the
# last remaining subscriber, we remove it, such that the clean up
# works correctly. Otherwise the CCCD never is set back to 0.
if len(subscribers) == 1 and characteristic in subscribers:
subscribers.remove(characteristic)
# Cleanup if we removed the last one
if not subscribers:
del subscriber_set[characteristic.handle]

View File

@@ -28,7 +28,17 @@ import asyncio
import logging
from collections import defaultdict
import struct
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
from typing import (
Dict,
Iterable,
List,
Optional,
Tuple,
TypeVar,
Type,
Union,
TYPE_CHECKING,
)
from pyee import EventEmitter
from bumble.colors import color
@@ -68,6 +78,7 @@ from bumble.gatt import (
GATT_REQUEST_TIMEOUT,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
Characteristic,
CharacteristicAdapter,
CharacteristicDeclaration,
CharacteristicValue,
IncludedServiceDeclaration,

View File

@@ -915,6 +915,8 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_READ_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+3),
HCI_WRITE_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+4),
HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (12*8+1),
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMMAND : 1 << (12*8+2),
HCI_LE_CS_WRITE_CACHED_REMOTE_FAE_TABLE_COMMAND : 1 << (12*8+3),
HCI_READ_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+4),
HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+5),
HCI_READ_INQUIRY_MODE_COMMAND : 1 << (12*8+6),
@@ -940,6 +942,8 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (16*8+3),
HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+4),
HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+5),
HCI_LE_CS_CREATE_CONFIG_COMMAND : 1 << (16*8+6),
HCI_LE_CS_REMOVE_CONFIG_COMMAND : 1 << (16*8+7),
HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+0),
HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+1),
HCI_REFRESH_ENCRYPTION_KEY_COMMAND : 1 << (17*8+2),
@@ -963,13 +967,20 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND : 1 << (20*8+2),
HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (20*8+3),
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+5),
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+6),
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES : 1 << (20*8+7),
HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
HCI_READ_DATA_BLOCK_SIZE_COMMAND : 1 << (23*8+2),
HCI_LE_CS_TEST_COMMAND : 1 << (23*8+3),
HCI_LE_CS_TEST_END_COMMAND : 1 << (23*8+4),
HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (24*8+0),
HCI_LE_CS_SECURITY_ENABLE_COMMAND : 1 << (24*8+1),
HCI_READ_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+5),
HCI_WRITE_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+6),
HCI_LE_CS_SET_DEFAULT_SETTINGS_COMMAND : 1 << (24*8+7),
HCI_LE_SET_EVENT_MASK_COMMAND : 1 << (25*8+0),
HCI_LE_READ_BUFFER_SIZE_COMMAND : 1 << (25*8+1),
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (25*8+2),
@@ -1000,6 +1011,10 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_LE_RECEIVER_TEST_COMMAND : 1 << (28*8+4),
HCI_LE_TRANSMITTER_TEST_COMMAND : 1 << (28*8+5),
HCI_LE_TEST_END_COMMAND : 1 << (28*8+6),
HCI_LE_ENABLE_MONITORING_ADVERTISERS_COMMAND : 1 << (28*8+7),
HCI_LE_CS_SET_CHANNEL_CLASSIFICATION_COMMAND : 1 << (29*8+0),
HCI_LE_CS_SET_PROCEDURE_PARAMETERS_COMMAND : 1 << (29*8+1),
HCI_LE_CS_PROCEDURE_ENABLE_COMMAND : 1 << (29*8+2),
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (29*8+3),
HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (29*8+4),
HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND : 1 << (29*8+5),
@@ -1136,11 +1151,21 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND : 1 << (46*8+0),
HCI_LE_SUBRATE_REQUEST_COMMAND : 1 << (46*8+1),
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (46*8+2),
HCI_LE_SET_DECISION_DATA_COMMAND : 1 << (46*8+3),
HCI_LE_SET_DECISION_INSTRUCTIONS_COMMAND : 1 << (46*8+4),
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND : 1 << (46*8+5),
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND : 1 << (46*8+6),
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND : 1 << (46*8+7),
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND : 1 << (47*8+0),
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (47*8+1),
HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (47*8+2),
HCI_LE_READ_ALL_REMOTE_FEATURES_COMMAND : 1 << (47*8+3),
HCI_LE_SET_HOST_FEATURE_V2_COMMAND : 1 << (47*8+4),
HCI_LE_ADD_DEVICE_TO_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+5),
HCI_LE_REMOVE_DEVICE_FROM_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+6),
HCI_LE_CLEAR_MONITORED_ADVERTISERS_LIST_COMMAND : 1 << (47*8+7),
HCI_LE_READ_MONITORED_ADVERTISERS_LIST_SIZE_COMMAND : 1 << (48*8+0),
HCI_LE_FRAME_SPACE_UPDATE_COMMAND : 1 << (48*8+1),
}
# LE Supported Features

View File

@@ -1911,6 +1911,7 @@ class ChannelManager:
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
else:
result = L2CAP_Information_Response.NOT_SUPPORTED
data = b''
self.send_control_frame(
connection,

View File

@@ -122,6 +122,8 @@ class LocalLink:
elif transport == BT_BR_EDR_TRANSPORT:
destination_controller = self.find_classic_controller(destination_address)
source_address = sender_controller.public_address
else:
raise ValueError("unsupported transport type")
if destination_controller is not None:
destination_controller.on_link_acl_data(source_address, transport, data)

View File

@@ -39,7 +39,6 @@ from bumble.device import (
AdvertisingEventProperties,
AdvertisingType,
Device,
Phy,
)
from bumble.gatt import Service
from bumble.hci import (
@@ -47,6 +46,7 @@ from bumble.hci import (
HCI_PAGE_TIMEOUT_ERROR,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
Address,
Phy,
)
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error

View File

@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import logging
import struct
@@ -28,10 +29,11 @@ from bumble.device import Connection
from bumble.att import ATT_Error
from bumble.gatt import (
Characteristic,
DelegatedCharacteristicAdapter,
SerializableCharacteristicAdapter,
PackedCharacteristicAdapter,
TemplateService,
CharacteristicValue,
PackedCharacteristicAdapter,
UTF8CharacteristicAdapter,
GATT_AUDIO_INPUT_CONTROL_SERVICE,
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
@@ -154,9 +156,6 @@ class AudioInputState:
attribute=self.attribute_value, value=bytes(self)
)
def on_read(self, _connection: Optional[Connection]) -> bytes:
return bytes(self)
@dataclass
class GainSettingsProperties:
@@ -173,7 +172,7 @@ class GainSettingsProperties:
(gain_settings_unit, gain_settings_minimum, gain_settings_maximum) = (
struct.unpack('BBB', data)
)
GainSettingsProperties(
return GainSettingsProperties(
gain_settings_unit, gain_settings_minimum, gain_settings_maximum
)
@@ -186,9 +185,6 @@ class GainSettingsProperties:
]
)
def on_read(self, _connection: Optional[Connection]) -> bytes:
return bytes(self)
@dataclass
class AudioInputControlPoint:
@@ -321,21 +317,14 @@ class AudioInputDescription:
audio_input_description: str = "Bluetooth"
attribute_value: Optional[CharacteristicValue] = None
@classmethod
def from_bytes(cls, data: bytes):
return cls(audio_input_description=data.decode('utf-8'))
def on_read(self, _connection: Optional[Connection]) -> str:
return self.audio_input_description
def __bytes__(self) -> bytes:
return self.audio_input_description.encode('utf-8')
def on_read(self, _connection: Optional[Connection]) -> bytes:
return self.audio_input_description.encode('utf-8')
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
async def on_write(self, connection: Optional[Connection], value: str) -> None:
assert connection
assert self.attribute_value
self.audio_input_description = value.decode('utf-8')
self.audio_input_description = value
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=value
)
@@ -375,26 +364,29 @@ class AICSService(TemplateService):
self.audio_input_state, self.gain_settings_properties
)
self.audio_input_state_characteristic = DelegatedCharacteristicAdapter(
self.audio_input_state_characteristic = SerializableCharacteristicAdapter(
Characteristic(
uuid=GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
properties=Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY,
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=CharacteristicValue(read=self.audio_input_state.on_read),
value=self.audio_input_state,
),
encode=lambda value: bytes(value),
AudioInputState,
)
self.audio_input_state.attribute_value = (
self.audio_input_state_characteristic.value
)
self.gain_settings_properties_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=CharacteristicValue(read=self.gain_settings_properties.on_read),
self.gain_settings_properties_characteristic = (
SerializableCharacteristicAdapter(
Characteristic(
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=self.gain_settings_properties,
),
GainSettingsProperties,
)
)
@@ -402,7 +394,7 @@ class AICSService(TemplateService):
uuid=GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
properties=Characteristic.Properties.READ,
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=audio_input_type,
value=bytes(audio_input_type, 'utf-8'),
)
self.audio_input_status_characteristic = Characteristic(
@@ -412,18 +404,14 @@ class AICSService(TemplateService):
value=bytes([self.audio_input_status]),
)
self.audio_input_control_point_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
properties=Characteristic.Properties.WRITE,
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=CharacteristicValue(
write=self.audio_input_control_point.on_write
),
)
self.audio_input_control_point_characteristic = Characteristic(
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
properties=Characteristic.Properties.WRITE,
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=CharacteristicValue(write=self.audio_input_control_point.on_write),
)
self.audio_input_description_characteristic = DelegatedCharacteristicAdapter(
self.audio_input_description_characteristic = UTF8CharacteristicAdapter(
Characteristic(
uuid=GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
properties=Characteristic.Properties.READ
@@ -469,8 +457,8 @@ class AICSServiceProxy(ProfileServiceProxy):
)
):
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
self.audio_input_state = DelegatedCharacteristicAdapter(
characteristic=characteristics[0], decode=AudioInputState.from_bytes
self.audio_input_state = SerializableCharacteristicAdapter(
characteristics[0], AudioInputState
)
if not (
@@ -481,9 +469,8 @@ class AICSServiceProxy(ProfileServiceProxy):
raise gatt.InvalidServiceError(
"Gain Settings Attribute Characteristic not found"
)
self.gain_settings_properties = PackedCharacteristicAdapter(
characteristics[0],
'BBB',
self.gain_settings_properties = SerializableCharacteristicAdapter(
characteristics[0], GainSettingsProperties
)
if not (
@@ -494,10 +481,7 @@ class AICSServiceProxy(ProfileServiceProxy):
raise gatt.InvalidServiceError(
"Audio Input Status Characteristic not found"
)
self.audio_input_status = PackedCharacteristicAdapter(
characteristics[0],
'B',
)
self.audio_input_status = PackedCharacteristicAdapter(characteristics[0], 'B')
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
@@ -517,4 +501,4 @@ class AICSServiceProxy(ProfileServiceProxy):
raise gatt.InvalidServiceError(
"Audio Input Description Characteristic not found"
)
self.audio_input_description = characteristics[0]
self.audio_input_description = UTF8CharacteristicAdapter(characteristics[0])

View File

@@ -102,6 +102,7 @@ class ContextType(enum.IntFlag):
# fmt: off
PROHIBITED = 0x0000
UNSPECIFIED = 0x0001
CONVERSATIONAL = 0x0002
MEDIA = 0x0004
GAME = 0x0008
@@ -350,6 +351,7 @@ class CodecSpecificCapabilities:
supported_max_codec_frames_per_sdu = value
# It is expected here that if some fields are missing, an error should be raised.
# pylint: disable=possibly-used-before-assignment,used-before-assignment
return CodecSpecificCapabilities(
supported_sampling_frequencies=supported_sampling_frequencies,
supported_frame_durations=supported_frame_durations,
@@ -426,6 +428,7 @@ class CodecSpecificConfiguration:
codec_frames_per_sdu = value
# It is expected here that if some fields are missing, an error should be raised.
# pylint: disable=possibly-used-before-assignment,used-before-assignment
return CodecSpecificConfiguration(
sampling_frequency=sampling_frequency,
frame_duration=frame_duration,

View File

@@ -276,10 +276,7 @@ class BroadcastReceiveState:
subgroups: List[SubgroupInfo]
@classmethod
def from_bytes(cls, data: bytes) -> Optional[BroadcastReceiveState]:
if not data:
return None
def from_bytes(cls, data: bytes) -> BroadcastReceiveState:
source_id = data[0]
_, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
source_adv_sid = data[8]
@@ -357,7 +354,7 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = BroadcastAudioScanService
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
broadcast_receive_states: List[gatt.SerializableCharacteristicAdapter]
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
@@ -381,8 +378,8 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
"Broadcast Receive State characteristic not found"
)
self.broadcast_receive_states = [
gatt.DelegatedCharacteristicAdapter(
characteristic, decode=BroadcastReceiveState.from_bytes
gatt.SerializableCharacteristicAdapter(
characteristic, BroadcastReceiveState
)
for characteristic in characteristics
]

View File

@@ -64,7 +64,10 @@ class DeviceInformationService(TemplateService):
):
characteristics = [
Characteristic(
uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
uuid,
Characteristic.Properties.READ,
Characteristic.READABLE,
bytes(field, 'utf-8'),
)
for (field, uuid) in (
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),

View File

@@ -30,6 +30,7 @@ from ..gatt import (
TemplateService,
Characteristic,
CharacteristicValue,
SerializableCharacteristicAdapter,
DelegatedCharacteristicAdapter,
PackedCharacteristicAdapter,
)
@@ -150,15 +151,14 @@ class HeartRateService(TemplateService):
body_sensor_location=None,
reset_energy_expended=None,
):
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
Characteristic(
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Characteristic.Properties.NOTIFY,
0,
CharacteristicValue(read=read_heart_rate_measurement),
),
# pylint: disable=unnecessary-lambda
encode=lambda value: bytes(value),
HeartRateService.HeartRateMeasurement,
)
characteristics = [self.heart_rate_measurement_characteristic]
@@ -204,9 +204,8 @@ class HeartRateServiceProxy(ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
):
self.heart_rate_measurement = DelegatedCharacteristicAdapter(
characteristics[0],
decode=HeartRateService.HeartRateMeasurement.from_bytes,
self.heart_rate_measurement = SerializableCharacteristicAdapter(
characteristics[0], HeartRateService.HeartRateMeasurement
)
else:
self.heart_rate_measurement = None

View File

@@ -434,6 +434,8 @@ class DataElement:
if size != 1:
raise InvalidArgumentError('boolean must be 1 byte')
size_index = 0
else:
raise RuntimeError("internal error - self.type not supported")
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
return self.bytes

View File

@@ -1839,7 +1839,7 @@ class Session:
if self.is_initiator:
if self.pairing_method == PairingMethod.OOB:
self.send_pairing_random_command()
else:
elif self.pairing_method == PairingMethod.PASSKEY:
self.send_pairing_confirm_command()
else:
if self.pairing_method == PairingMethod.PASSKEY:

View File

@@ -370,11 +370,13 @@ class PumpedPacketSource(ParserSource):
self.parser.feed_data(packet)
except asyncio.CancelledError:
logger.debug('source pump task done')
self.terminated.set_result(None)
if not self.terminated.done():
self.terminated.set_result(None)
break
except Exception as error:
logger.warning(f'exception while waiting for packet: {error}')
self.terminated.set_exception(error)
if not self.terminated.done():
self.terminated.set_exception(error)
break
self.pump_task = asyncio.create_task(pump_packets())

View File

@@ -24,17 +24,19 @@ import logging
import sys
import warnings
from typing import (
Awaitable,
Set,
TypeVar,
List,
Tuple,
Callable,
Any,
Awaitable,
Callable,
List,
Optional,
Protocol,
Set,
Tuple,
TypeVar,
Union,
overload,
)
from typing_extensions import Self
from pyee import EventEmitter
@@ -487,3 +489,16 @@ class OpenIntEnum(enum.IntEnum):
obj._value_ = value
obj._name_ = f"{cls.__name__}[{value}]"
return obj
# -----------------------------------------------------------------------------
class ByteSerializable(Protocol):
"""
Type protocol for classes that can be instantiated from bytes and serialized
to bytes.
"""
@classmethod
def from_bytes(cls, data: bytes) -> Self: ...
def __bytes__(self) -> bytes: ...

View File

@@ -11,32 +11,44 @@ Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
Options:
--device-config FILENAME Device configuration file
--role [sender|receiver|ping|pong]
--scenario [send|receive|ping|pong]
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
--extended-data-length TEXT Request a data length upon connection,
specified as tx_octets/tx_time
--rfcomm-channel INTEGER RFComm channel to use
--role-switch [central|peripheral]
Request role switch upon connection (central
or peripheral)
--rfcomm-channel INTEGER RFComm channel to use (specify 0 for channel
discovery via SDP)
--rfcomm-uuid TEXT RFComm service UUID to use (ignored if
--rfcomm-channel is not 0)
--rfcomm-l2cap-mtu INTEGER RFComm L2CAP MTU
--rfcomm-max-frame-size INTEGER
RFComm maximum frame size
--rfcomm-initial-credits INTEGER
RFComm initial credits
--rfcomm-max-credits INTEGER RFComm max credits
--rfcomm-credits-threshold INTEGER
RFComm credits threshold
--l2cap-psm INTEGER L2CAP PSM to use
--l2cap-mtu INTEGER L2CAP MTU to use
--l2cap-mps INTEGER L2CAP MPS to use
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
the peer
-s, --packet-size SIZE Packet size (client or ping role)
[8<=x<=4096]
-c, --packet-count COUNT Packet count (client or ping role)
-sd, --start-delay SECONDS Start delay (client or ping role)
--repeat N Repeat the run N times (client and ping
roles)(0, which is the fault, to run just
-s, --packet-size SIZE Packet size (send or ping scenario)
[8<=x<=8192]
-c, --packet-count COUNT Packet count (send or ping scenario)
-sd, --start-delay SECONDS Start delay (send or ping scenario)
--repeat N Repeat the run N times (send and ping
scenario)(0, which is the fault, to run just
once)
--repeat-delay SECONDS Delay, in seconds, between repeats
--pace MILLISECONDS Wait N milliseconds between packets (0,
which is the fault, to send as fast as
possible)
--linger Don't exit at the end of a run (server and
pong roles)
--linger Don't exit at the end of a run (receive and
pong scenarios)
--help Show this message and exit.
Commands:
@@ -71,19 +83,19 @@ using the ``--peripheral`` option. The address will be printed by the Peripheral
it starts.
Independently of whether the device is the Central or Peripheral, each device selects a
``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral
``mode`` and and ``scenario`` to run as. The ``mode`` and ``scenario`` of the Central and Peripheral
must be compatible.
Device 1 mode | Device 2 mode
Device 1 scenario | Device 2 scenario
------------------|------------------
``gatt-client`` | ``gatt-server``
``l2cap-client`` | ``l2cap-server``
``rfcomm-client`` | ``rfcomm-server``
Device 1 role | Device 2 role
--------------|--------------
``sender`` | ``receiver``
``ping`` | ``pong``
Device 1 scenario | Device 2 scenario
------------------|--------------
``send`` | ``receive``
``ping`` | ``pong``
# Examples
@@ -92,7 +104,7 @@ In the following examples, we have two USB Bluetooth controllers, one on `usb:0`
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
!!! example "GATT Throughput"
Using the default mode and role for the Central and Peripheral.
Using the default mode and scenario for the Central and Peripheral.
In the first console/terminal:
```
@@ -137,12 +149,12 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
!!! example "Ping/Pong Latency"
In the first console/terminal:
```
$ bumble-bench --role pong peripheral usb:0
$ bumble-bench --scenario pong peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --role ping central usb:1
$ bumble-bench --scenario ping central usb:1
```
!!! example "Reversed modes with GATT and custom connection interval"
@@ -167,13 +179,13 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
```
!!! example "Reversed roles with L2CAP"
!!! example "Reversed scenarios with L2CAP"
In the first console/terminal:
```
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
$ bumble-bench --mode l2cap-client --scenario send peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode l2cap-server --role receiver central usb:1
$ bumble-bench --mode l2cap-server --scenario receive central usb:1
```

View File

@@ -3,9 +3,7 @@ GETTING STARTED WITH BUMBLE
# Prerequisites
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
necessary (there may be some optional functionality that will not work on some platforms with
python 3.8).
You need Python 3.9 or above.
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
for your platform.
Throughout the documentation, when shell commands are shown, it is assumed that you can

View File

@@ -31,7 +31,7 @@ Some of the configurations that may be useful:
See the [use cases page](use_cases/index.md) for more use cases.
The project is implemented in Python (Python >= 3.8 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
The project is implemented in Python (Python >= 3.9 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
![layers](images/bumble_layers.svg)

View File

@@ -1,7 +1,7 @@
PLATFORMS
=========
Most of the code included in the project should run on any platform that supports Python >= 3.8. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
Most of the code included in the project should run on any platform that supports Python >= 3.9. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
For platform-specific information, see the following pages:

View File

@@ -4,6 +4,6 @@ channels:
- conda-forge
dependencies:
- pip=23
- python=3.8
- python=3.9
- pip:
- --editable .[development,documentation,test]

View File

@@ -282,7 +282,7 @@ async def keyboard_device(device, command):
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.Properties.READ,
Characteristic.READABLE,
'Bumble',
bytes('Bumble', 'utf-8'),
)
],
),

View File

@@ -0,0 +1,47 @@
from mobly import base_test
from mobly import test_runner
from mobly.controllers import android_device
class OneDeviceBenchTest(base_test.BaseTestClass):
def setup_class(self):
self.ads = self.register_controller(android_device)
self.dut = self.ads[0]
self.dut.load_snippet("bench", "com.github.google.bumble.btbench")
def test_rfcomm_client_ping(self):
runner = self.dut.bench.runRfcommClient(
"ping", "DC:E5:5B:E5:51:2C", 100, 970, 100
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_rfcomm_client_send(self):
runner = self.dut.bench.runRfcommClient(
"send", "DC:E5:5B:E5:51:2C", 100, 970, 0
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_l2cap_client_ping(self):
runner = self.dut.bench.runL2capClient(
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_l2cap_client_send(self):
runner = self.dut.bench.runL2capClient(
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
if __name__ == "__main__":
test_runner.main()

View File

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

View File

@@ -0,0 +1,38 @@
import time
from mobly import base_test
from mobly import test_runner
from mobly.controllers import android_device
class TwoDevicesBenchTest(base_test.BaseTestClass):
def setup_class(self):
self.ads = self.register_controller(android_device)
self.dut1 = self.ads[0]
self.dut1.load_snippet("bench", "com.github.google.bumble.btbench")
self.dut2 = self.ads[1]
self.dut2.load_snippet("bench", "com.github.google.bumble.btbench")
def test_rfcomm_client_send_receive(self):
print("### Starting Receiver")
receiver = self.dut2.bench.runRfcommServer("receive")
receiver_id = receiver["id"]
print("--- Receiver status:", receiver)
while not receiver["model"]["running"]:
print("--- Waiting for Receiver to be running...")
time.sleep(1)
receiver = self.dut2.bench.getRunner(receiver_id)
print("### Starting Sender")
sender = self.dut1.bench.runRfcommClient(
"send", "DC:E5:5B:E5:51:2C", 100, 970, 100
)
print("--- Sender status:", sender)
print("--- Waiting for Sender to complete...")
sender_result = self.dut1.bench.waitForRunnerCompletion(sender["id"])
print("--- Sender result:", sender_result)
if __name__ == "__main__":
test_runner.main()

View File

@@ -64,6 +64,7 @@ async def main() -> None:
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
)
# pylint: disable=possibly-used-before-assignment
if device.host.number_of_supported_advertising_sets >= 2:
set2 = await device.create_advertising_set(
random_address=Address("F0:F0:F0:F0:F0:F1"),

View File

@@ -127,7 +127,7 @@ async def main() -> None:
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
'hello',
bytes('hello', 'utf-8'),
),
],
)

View File

@@ -0,0 +1,319 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import dataclasses
import logging
import os
import random
import struct
import sys
from typing import Any, List, Union
from bumble.device import Connection, Device, Peer
from bumble import transport
from bumble import gatt
from bumble import hci
from bumble import core
# -----------------------------------------------------------------------------
SERVICE_UUID = core.UUID("50DB505C-8AC4-4738-8448-3B1D9CC09CC5")
CHARACTERISTIC_UUID_BASE = "D901B45B-4916-412E-ACCA-0000000000"
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class CustomSerializableClass:
x: int
y: int
@classmethod
def from_bytes(cls, data: bytes) -> CustomSerializableClass:
return cls(*struct.unpack(">II", data))
def __bytes__(self) -> bytes:
return struct.pack(">II", self.x, self.y)
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class CustomClass:
a: int
b: int
@classmethod
def decode(cls, data: bytes) -> CustomClass:
return cls(*struct.unpack(">II", data))
def encode(self) -> bytes:
return struct.pack(">II", self.a, self.b)
# -----------------------------------------------------------------------------
async def client(device: Device, address: hci.Address) -> None:
print(f'=== Connecting to {address}...')
connection = await device.connect(address)
print('=== Connected')
# Discover all characteristics.
peer = Peer(connection)
print("*** Discovering services and characteristics...")
await peer.discover_all()
print("*** Discovery complete")
service = peer.get_services_by_uuid(SERVICE_UUID)[0]
characteristics = []
for index in range(1, 9):
characteristics.append(
service.get_characteristics_by_uuid(
CHARACTERISTIC_UUID_BASE + f"{index:02X}"
)[0]
)
# Read all characteristics as raw bytes.
for characteristic in characteristics:
value = await characteristic.read_value()
print(f"### {characteristic} = {value} ({value.hex()})")
# Static characteristic with a bytes value.
c1 = characteristics[0]
c1_value = await c1.read_value()
print(f"@@@ C1 {c1} value = {c1_value} (type={type(c1_value)})")
await c1.write_value("happy π day".encode("utf-8"))
# Static characteristic with a string value.
c2 = gatt.UTF8CharacteristicAdapter(characteristics[1])
c2_value = await c2.read_value()
print(f"@@@ C2 {c2} value = {c2_value} (type={type(c2_value)})")
await c2.write_value("happy π day")
# Static characteristic with a tuple value.
c3 = gatt.PackedCharacteristicAdapter(characteristics[2], ">III")
c3_value = await c3.read_value()
print(f"@@@ C3 {c3} value = {c3_value} (type={type(c3_value)})")
await c3.write_value((2001, 2002, 2003))
# Static characteristic with a named tuple value.
c4 = gatt.MappedCharacteristicAdapter(
characteristics[3], ">III", ["f1", "f2", "f3"]
)
c4_value = await c4.read_value()
print(f"@@@ C4 {c4} value = {c4_value} (type={type(c4_value)})")
await c4.write_value({"f1": 4001, "f2": 4002, "f3": 4003})
# Static characteristic with a serializable value.
c5 = gatt.SerializableCharacteristicAdapter(
characteristics[4], CustomSerializableClass
)
c5_value = await c5.read_value()
print(f"@@@ C5 {c5} value = {c5_value} (type={type(c5_value)})")
await c5.write_value(CustomSerializableClass(56, 57))
# Static characteristic with a delegated value.
c6 = gatt.DelegatedCharacteristicAdapter(
characteristics[5], encode=CustomClass.encode, decode=CustomClass.decode
)
c6_value = await c6.read_value()
print(f"@@@ C6 {c6} value = {c6_value} (type={type(c6_value)})")
await c6.write_value(CustomClass(6, 7))
# Dynamic characteristic with a bytes value.
c7 = characteristics[6]
c7_value = await c7.read_value()
print(f"@@@ C7 {c7} value = {c7_value} (type={type(c7_value)})")
await c7.write_value(bytes.fromhex("01020304"))
# Dynamic characteristic with a string value.
c8 = gatt.UTF8CharacteristicAdapter(characteristics[7])
c8_value = await c8.read_value()
print(f"@@@ C8 {c8} value = {c8_value} (type={type(c8_value)})")
await c8.write_value("howdy")
# -----------------------------------------------------------------------------
def dynamic_read(selector: str) -> Union[bytes, str]:
if selector == "bytes":
print("$$$ Returning random bytes")
return random.randbytes(7)
elif selector == "string":
print("$$$ Returning random string")
return random.randbytes(7).hex()
raise ValueError("invalid selector")
# -----------------------------------------------------------------------------
def dynamic_write(selector: str, value: Any) -> None:
print(f"$$$ Received[{selector}]: {value} (type={type(value)})")
# -----------------------------------------------------------------------------
def on_characteristic_read(characteristic: gatt.Characteristic, value: Any) -> None:
"""Event listener invoked when a characteristic is read."""
print(f"<<< READ: {characteristic} -> {value} ({type(value)})")
# -----------------------------------------------------------------------------
def on_characteristic_write(characteristic: gatt.Characteristic, value: Any) -> None:
"""Event listener invoked when a characteristic is written."""
print(f"<<< WRITE: {characteristic} <- {value} ({type(value)})")
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 2:
print("Usage: run_gatt_with_adapters.py <transport-spec> [<bluetooth-address>]")
print("example: run_gatt_with_adapters.py usb:0 E1:CA:72:48:C4:E8")
return
async with await transport.open_transport(sys.argv[1]) as hci_transport:
# Create a device to manage the host
device = Device.with_hci(
"Bumble",
hci.Address("F0:F1:F2:F3:F4:F5"),
hci_transport.source,
hci_transport.sink,
)
# Static characteristic with a bytes value.
c1 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "01",
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
b'hello',
)
# Static characteristic with a string value.
c2 = gatt.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "02",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
'hello',
)
)
# Static characteristic with a tuple value.
c3 = gatt.PackedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "03",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
(1007, 1008, 1009),
),
">III",
)
# Static characteristic with a named tuple value.
c4 = gatt.MappedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "04",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
{"f1": 3007, "f2": 3008, "f3": 3009},
),
">III",
["f1", "f2", "f3"],
)
# Static characteristic with a serializable value.
c5 = gatt.SerializableCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "05",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomSerializableClass(11, 12),
),
CustomSerializableClass,
)
# Static characteristic with a delegated value.
c6 = gatt.DelegatedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "06",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomClass(1, 2),
),
encode=CustomClass.encode,
decode=CustomClass.decode,
)
# Dynamic characteristic with a bytes value.
c7 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "07",
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("bytes"),
write=lambda connection, value: dynamic_write("bytes", value),
),
)
# Dynamic characteristic with a string value.
c8 = gatt.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "08",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("string"),
write=lambda connection, value: dynamic_write("string", value),
),
)
)
characteristics: List[
Union[gatt.Characteristic, gatt.CharacteristicAdapter]
] = [c1, c2, c3, c4, c5, c6, c7, c8]
# Listen for read and write events.
for characteristic in characteristics:
characteristic.on(
"read",
lambda _, value, c=characteristic: on_characteristic_read(c, value),
)
characteristic.on(
"write",
lambda _, value, c=characteristic: on_characteristic_write(c, value),
)
device.add_service(gatt.Service(SERVICE_UUID, characteristics)) # type: ignore
# Get things going
await device.power_on()
# Connect to a peer
if len(sys.argv) > 2:
await client(device, hci.Address(sys.argv[2]))
else:
await device.start_advertising(auto_restart=True)
await hci_transport.source.wait_for_termination()
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(main())

View File

@@ -57,6 +57,9 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
esco_parameters = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_CVSD_S4
]
else:
raise RuntimeError("unknown active codec")
connection.abort_on(
'disconnection',
connection.device.send_command(

View File

@@ -60,6 +60,8 @@ dependencies {
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.material3)
implementation(libs.mobly.snippet)
implementation(libs.androidx.core)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)

View File

@@ -23,6 +23,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.BTBench"
>
<meta-data
android:name="mobly-snippets"
android:value="com.github.google.bumble.btbench.AutomationSnippet"/>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -35,5 +38,7 @@
</activity>
<!-- <profileable android:shell="true"/>-->
</application>
</manifest>
<instrumentation
android:name="com.google.android.mobly.snippet.SnippetRunner"
android:targetPackage="com.github.google.bumble.btbench" />
</manifest>

View File

@@ -0,0 +1,289 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import com.google.android.mobly.snippet.Snippet;
import com.google.android.mobly.snippet.rpc.Rpc;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.security.InvalidParameterException;
import java.util.ArrayList;
import java.util.UUID;
class Runner {
public UUID mId;
private final Mode mMode;
private final String mModeName;
private final String mScenario;
private final AppViewModel mModel;
Runner(Mode mode, String modeName, String scenario, AppViewModel model) {
this.mId = UUID.randomUUID();
this.mMode = mode;
this.mModeName = modeName;
this.mScenario = scenario;
this.mModel = model;
}
public JSONObject toJson() throws JSONException {
JSONObject result = new JSONObject();
result.put("id", mId.toString());
result.put("mode", mModeName);
result.put("scenario", mScenario);
result.put("model", AutomationSnippet.modelToJson(mModel));
return result;
}
public void stop() {
mModel.abort();
}
public void waitForCompletion() {
mMode.waitForCompletion();
}
}
public class AutomationSnippet implements Snippet {
private static final String TAG = "btbench.snippet";
private final BluetoothAdapter mBluetoothAdapter;
private final Context mContext;
private final ArrayList<Runner> mRunners = new ArrayList<>();
public AutomationSnippet() {
mContext = ApplicationProvider.getApplicationContext();
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
mBluetoothAdapter = bluetoothManager.getAdapter();
if (mBluetoothAdapter == null) {
throw new RuntimeException("bluetooth not supported");
}
}
private Runner runScenario(AppViewModel model, String mode, String scenario) {
Mode runnable;
switch (mode) {
case "rfcomm-client":
runnable = new RfcommClient(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break;
case "rfcomm-server":
runnable = new RfcommServer(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break;
case "l2cap-client":
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break;
case "l2cap-server":
runnable = new L2capServer(model, mBluetoothAdapter,
(PacketIO packetIO) -> createIoClient(model, scenario,
packetIO));
break;
default:
return null;
}
runnable.run();
Runner runner = new Runner(runnable, mode, scenario, model);
mRunners.add(runner);
return runner;
}
private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) {
switch (scenario) {
case "send":
return new Sender(model, packetIO);
case "receive":
return new Receiver(model, packetIO);
case "ping":
return new Pinger(model, packetIO);
case "pong":
return new Ponger(model, packetIO);
default:
return null;
}
}
public static JSONObject modelToJson(AppViewModel model) throws JSONException {
JSONObject result = new JSONObject();
result.put("status", model.getStatus());
result.put("running", model.getRunning());
result.put("l2cap_psm", model.getL2capPsm());
if (model.getStatus().equals("OK")) {
JSONObject stats = new JSONObject();
result.put("stats", stats);
stats.put("throughput", model.getThroughput());
JSONObject rttStats = new JSONObject();
stats.put("rtt", rttStats);
rttStats.put("compound", model.getStats());
} else {
result.put("last_error", model.getLastError());
}
return result;
}
private Runner findRunner(String runnerId) {
for (Runner runner : mRunners) {
if (runner.mId.toString().equals(runnerId)) {
return runner;
}
}
return null;
}
@Rpc(description = "Run a scenario in RFComm Client mode")
public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount,
int packetSize, int packetInterval) throws JSONException {
assert (mBluetoothAdapter != null);
// We only support "send" and "ping" for this mode for now
if (!(scenario.equals("send") || scenario.equals("ping"))) {
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
}
AppViewModel model = new AppViewModel();
model.setPeerBluetoothAddress(peerBluetoothAddress);
model.setSenderPacketCount(packetCount);
model.setSenderPacketSize(packetSize);
model.setSenderPacketInterval(packetInterval);
Runner runner = runScenario(model, "rfcomm-client", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Run a scenario in RFComm Server mode")
public JSONObject runRfcommServer(String scenario) throws JSONException {
assert (mBluetoothAdapter != null);
// We only support "receive" and "pong" for this mode for now
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
}
AppViewModel model = new AppViewModel();
Runner runner = runScenario(model, "rfcomm-server", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Run a scenario in L2CAP Client mode")
public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm,
boolean use_2m_phy, int packetCount, int packetSize,
int packetInterval) throws JSONException {
assert (mBluetoothAdapter != null);
// We only support "send" and "ping" for this mode for now
if (!(scenario.equals("send") || scenario.equals("ping"))) {
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
}
AppViewModel model = new AppViewModel();
model.setPeerBluetoothAddress(peerBluetoothAddress);
model.setL2capPsm(psm);
model.setUse2mPhy(use_2m_phy);
model.setSenderPacketCount(packetCount);
model.setSenderPacketSize(packetSize);
model.setSenderPacketInterval(packetInterval);
Runner runner = runScenario(model, "l2cap-client", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Run a scenario in L2CAP Server mode")
public JSONObject runL2capServer(String scenario) throws JSONException {
assert (mBluetoothAdapter != null);
// We only support "receive" and "pong" for this mode for now
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
}
AppViewModel model = new AppViewModel();
Runner runner = runScenario(model, "l2cap-server", scenario);
assert runner != null;
return runner.toJson();
}
@Rpc(description = "Stop a Runner")
public JSONObject stopRunner(String runnerId) throws JSONException {
Runner runner = findRunner(runnerId);
if (runner == null) {
return new JSONObject();
}
runner.stop();
return runner.toJson();
}
@Rpc(description = "Wait for a Runner to complete")
public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException {
Runner runner = findRunner(runnerId);
if (runner == null) {
return new JSONObject();
}
runner.waitForCompletion();
return runner.toJson();
}
@Rpc(description = "Get a Runner by ID")
public JSONObject getRunner(String runnerId) throws JSONException {
Runner runner = findRunner(runnerId);
if (runner == null) {
return new JSONObject();
}
return runner.toJson();
}
@Rpc(description = "Get all Runners")
public JSONObject getRunners() throws JSONException {
JSONObject result = new JSONObject();
JSONArray runners = new JSONArray();
result.put("runners", runners);
for (Runner runner: mRunners) {
runners.put(runner.toJson());
}
return result;
}
@Override
public void shutdown() {
}
}

View File

@@ -0,0 +1,20 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
interface IoClient {
fun run()
fun abort()
}

View File

@@ -29,10 +29,13 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
class L2capClient(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
private val context: Context
) {
private val context: Context,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode {
private var socketClient: SocketClient? = null
@SuppressLint("MissingPermission")
fun run() {
override fun run() {
viewModel.running = true
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
val address = viewModel.peerBluetoothAddress.take(17)
@@ -75,6 +78,7 @@ class L2capClient(
) {
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
if (viewModel.use2mPhy) {
Log.info("requesting 2M PHY")
gatt.setPreferredPhy(
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_LE_2M_MASK,
@@ -95,7 +99,11 @@ class L2capClient(
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
val client = SocketClient(viewModel, socket)
client.run()
socketClient = SocketClient(viewModel, socket, createIoClient)
socketClient!!.run()
}
}
override fun waitForCompletion() {
socketClient?.waitForCompletion()
}
}

View File

@@ -27,11 +27,17 @@ import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.l2cap-server")
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
class L2capServer(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode {
private var socketServer: SocketServer? = null
@SuppressLint("MissingPermission")
fun run() {
override fun run() {
// Advertise so that the peer can find us and connect.
val callback = object: AdvertiseCallback() {
val callback = object : AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.warning("failed to start advertising: $errorCode")
}
@@ -55,7 +61,14 @@ class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdap
viewModel.l2capPsm = serverSocket.psm
Log.info("psm = $serverSocket.psm")
val server = SocketServer(viewModel, serverSocket)
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
socketServer!!.run(
{ advertiser.stopAdvertising(callback) },
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }
)
}
}
override fun waitForCompletion() {
socketServer?.waitForCompletion()
}
}

View File

@@ -34,12 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -54,6 +57,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -69,6 +73,9 @@ private val Log = Logger.getLogger("bumble.main-activity")
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
const val SENDER_PACKET_INTERVAL_PREF_KEY = "sender_packet_interval"
const val SCENARIO_PREF_KEY = "scenario"
const val MODE_PREF_KEY = "mode"
class MainActivity : ComponentActivity() {
private val appViewModel = AppViewModel()
@@ -139,10 +146,7 @@ class MainActivity : ComponentActivity() {
MainView(
appViewModel,
::becomeDiscoverable,
::runRfcommClient,
::runRfcommServer,
::runL2capClient,
::runL2capServer,
::runScenario
)
}
@@ -159,37 +163,54 @@ class MainActivity : ComponentActivity() {
if (packetSize > 0) {
appViewModel.senderPacketSize = packetSize
}
val packetInterval = intent.getIntExtra("packet-interval", 0)
if (packetInterval > 0) {
appViewModel.senderPacketInterval = packetInterval
}
appViewModel.updateSenderPacketSizeSlider()
intent.getStringExtra("scenario")?.let {
when (it) {
"send" -> appViewModel.scenario = SEND_SCENARIO
"receive" -> appViewModel.scenario = RECEIVE_SCENARIO
"ping" -> appViewModel.scenario = PING_SCENARIO
"pong" -> appViewModel.scenario = PONG_SCENARIO
}
}
intent.getStringExtra("mode")?.let {
when (it) {
"rfcomm-client" -> appViewModel.mode = RFCOMM_CLIENT_MODE
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
}
}
intent.getStringExtra("autostart")?.let {
when (it) {
"rfcomm-client" -> runRfcommClient()
"rfcomm-server" -> runRfcommServer()
"l2cap-client" -> runL2capClient()
"l2cap-server" -> runL2capServer()
"run-scenario" -> runScenario()
"scan-start" -> runScan(true)
"stop-start" -> runScan(false)
}
}
}
private fun runRfcommClient() {
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
rfcommClient?.run()
}
private fun runScenario() {
if (bluetoothAdapter == null) {
return
}
private fun runRfcommServer() {
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
rfcommServer?.run()
}
private fun runL2capClient() {
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
l2capClient?.run()
}
private fun runL2capServer() {
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
l2capServer?.run()
val runner = when (appViewModel.mode) {
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
L2CAP_CLIENT_MODE -> L2capClient(
appViewModel,
bluetoothAdapter!!,
baseContext,
::createIoClient
)
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
else -> throw IllegalStateException()
}
runner.run()
}
private fun runScan(startScan: Boolean) {
@@ -197,6 +218,17 @@ class MainActivity : ComponentActivity() {
scan?.run(startScan)
}
private fun createIoClient(packetIo: PacketIO): IoClient {
return when (appViewModel.scenario) {
SEND_SCENARIO -> Sender(appViewModel, packetIo)
RECEIVE_SCENARIO -> Receiver(appViewModel, packetIo)
PING_SCENARIO -> Pinger(appViewModel, packetIo)
PONG_SCENARIO -> Ponger(appViewModel, packetIo)
else -> throw IllegalStateException()
}
}
@SuppressLint("MissingPermission")
fun becomeDiscoverable() {
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
@@ -210,10 +242,7 @@ class MainActivity : ComponentActivity() {
fun MainView(
appViewModel: AppViewModel,
becomeDiscoverable: () -> Unit,
runRfcommClient: () -> Unit,
runRfcommServer: () -> Unit,
runL2capClient: () -> Unit,
runL2capServer: () -> Unit,
runScenario: () -> Unit,
) {
BTBenchTheme {
val scrollState = rememberScrollState()
@@ -239,7 +268,9 @@ fun MainView(
Text(text = "Peer Bluetooth Address")
},
value = appViewModel.peerBluetoothAddress,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
),
@@ -249,14 +280,18 @@ fun MainView(
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
focusManager.clearFocus()
})
}),
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
)
Divider()
TextField(label = {
Text(text = "L2CAP PSM")
},
TextField(
label = {
Text(text = "L2CAP PSM")
},
value = appViewModel.l2capPsm.toString(),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
@@ -271,7 +306,8 @@ fun MainView(
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
focusManager.clearFocus()
})
}),
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE)
)
Divider()
Slider(
@@ -290,6 +326,32 @@ fun MainView(
)
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
Divider()
TextField(
label = {
Text(text = "Packet Interval (ms)")
},
value = appViewModel.senderPacketInterval.toString(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = {
if (it.isNotEmpty()) {
val interval = it.toIntOrNull()
if (interval != null) {
appViewModel.updateSenderPacketInterval(interval)
}
}
},
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
focusManager.clearFocus()
}),
enabled = (appViewModel.scenario == PING_SCENARIO)
)
Divider()
ActionButton(
text = "Become Discoverable", onClick = becomeDiscoverable, true
)
@@ -300,25 +362,78 @@ fun MainView(
Text(text = "2M PHY")
Spacer(modifier = Modifier.padding(start = 8.dp))
Switch(
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
checked = appViewModel.use2mPhy,
onCheckedChange = { appViewModel.use2mPhy = it }
)
}
Row {
ActionButton(
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
)
ActionButton(
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
)
Column(Modifier.selectableGroup()) {
listOf(
RFCOMM_CLIENT_MODE,
RFCOMM_SERVER_MODE,
L2CAP_CLIENT_MODE,
L2CAP_SERVER_MODE
).forEach { text ->
Row(
Modifier
.selectable(
selected = (text == appViewModel.mode),
onClick = { appViewModel.updateMode(text) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == appViewModel.mode),
onClick = null
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
Column(Modifier.selectableGroup()) {
listOf(
SEND_SCENARIO,
RECEIVE_SCENARIO,
PING_SCENARIO,
PONG_SCENARIO
).forEach { text ->
Row(
Modifier
.selectable(
selected = (text == appViewModel.scenario),
onClick = { appViewModel.updateScenario(text) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == appViewModel.scenario),
onClick = null
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
Row {
ActionButton(
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
text = "Start", onClick = runScenario, enabled = !appViewModel.running
)
ActionButton(
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
)
}
Divider()
@@ -328,6 +443,12 @@ fun MainView(
Text(
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
)
Text(
text = "Status: ${appViewModel.status}"
)
Text(
text = "Last Error: ${appViewModel.lastError}"
)
Text(
text = "Packets Sent: ${appViewModel.packetsSent}"
)
@@ -337,9 +458,8 @@ fun MainView(
Text(
text = "Throughput: ${appViewModel.throughput}"
)
Divider()
ActionButton(
text = "Abort", onClick = appViewModel::abort, appViewModel.running
Text(
text = "Stats: ${appViewModel.stats}"
)
}
}
@@ -351,4 +471,4 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
Button(onClick = onClick, enabled = enabled) {
Text(text = text)
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
interface Mode {
fun run()
fun waitForCompletion()
}

View File

@@ -27,10 +27,25 @@ val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
const val DEFAULT_SENDER_PACKET_COUNT = 100
const val DEFAULT_SENDER_PACKET_SIZE = 1024
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
const val DEFAULT_PSM = 128
const val L2CAP_CLIENT_MODE = "L2CAP Client"
const val L2CAP_SERVER_MODE = "L2CAP Server"
const val RFCOMM_CLIENT_MODE = "RFCOMM Client"
const val RFCOMM_SERVER_MODE = "RFCOMM Server"
const val SEND_SCENARIO = "Send"
const val RECEIVE_SCENARIO = "Receive"
const val PING_SCENARIO = "Ping"
const val PONG_SCENARIO = "Pong"
class AppViewModel : ViewModel() {
private var preferences: SharedPreferences? = null
var status by mutableStateOf("")
var lastError by mutableStateOf("")
var mode by mutableStateOf(RFCOMM_SERVER_MODE)
var scenario by mutableStateOf(RECEIVE_SCENARIO)
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
var use2mPhy by mutableStateOf(true)
@@ -41,9 +56,11 @@ class AppViewModel : ViewModel() {
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
var senderPacketInterval by mutableIntStateOf(DEFAULT_SENDER_PACKET_INTERVAL)
var packetsSent by mutableIntStateOf(0)
var packetsReceived by mutableIntStateOf(0)
var throughput by mutableIntStateOf(0)
var stats by mutableStateOf("")
var running by mutableStateOf(false)
var aborter: (() -> Unit)? = null
@@ -66,6 +83,21 @@ class AppViewModel : ViewModel() {
senderPacketSize = savedSenderPacketSize
}
updateSenderPacketSizeSlider()
val savedSenderPacketInterval = preferences.getInt(SENDER_PACKET_INTERVAL_PREF_KEY, -1)
if (savedSenderPacketInterval != -1) {
senderPacketInterval = savedSenderPacketInterval
}
val savedMode = preferences.getString(MODE_PREF_KEY, null)
if (savedMode != null) {
mode = savedMode
}
val savedScenario = preferences.getString(SCENARIO_PREF_KEY, null)
if (savedScenario != null) {
scenario = savedScenario
}
}
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
@@ -164,6 +196,42 @@ class AppViewModel : ViewModel() {
}
}
fun updateSenderPacketInterval(senderPacketInterval: Int) {
this.senderPacketInterval = senderPacketInterval
with(preferences!!.edit()) {
putInt(SENDER_PACKET_INTERVAL_PREF_KEY, senderPacketInterval)
apply()
}
}
fun updateScenario(scenario: String) {
this.scenario = scenario
with(preferences!!.edit()) {
putString(SCENARIO_PREF_KEY, scenario)
apply()
}
}
fun updateMode(mode: String) {
this.mode = mode
with(preferences!!.edit()) {
putString(MODE_PREF_KEY, mode)
apply()
}
}
fun clear() {
status = ""
lastError = ""
mtu = 0
rxPhy = 0
txPhy = 0
packetsSent = 0
packetsReceived = 0
throughput = 0
stats = ""
}
fun abort() {
aborter?.let { it() }
}

View File

@@ -74,13 +74,13 @@ abstract class PacketSink {
fun onPacket(packet: Packet) {
when (packet) {
is ResetPacket -> onResetPacket()
is AckPacket -> onAckPacket()
is AckPacket -> onAckPacket(packet)
is SequencePacket -> onSequencePacket(packet)
}
}
abstract fun onResetPacket()
abstract fun onAckPacket()
abstract fun onAckPacket(packet: AckPacket)
abstract fun onSequencePacket(packet: SequencePacket)
}
@@ -175,4 +175,4 @@ class SocketDataSource(
} while (true)
Log.info("end of stream")
}
}
}

View File

@@ -0,0 +1,104 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import java.util.concurrent.Semaphore
import java.util.logging.Logger
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
private const val DEFAULT_STARTUP_DELAY = 3000
private val Log = Logger.getLogger("btbench.pinger")
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
PacketSink() {
private val pingTimes: ArrayList<TimeSource.Monotonic.ValueTimeMark> = ArrayList()
private val rtts: ArrayList<Long> = ArrayList()
private val done = Semaphore(0)
init {
packetIO.packetSink = this
}
override fun run() {
viewModel.clear()
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
Log.info("running")
Log.info("sending reset")
packetIO.sendPacket(ResetPacket())
val packetCount = viewModel.senderPacketCount
val packetSize = viewModel.senderPacketSize
val startTime = TimeSource.Monotonic.markNow()
for (i in 0..<packetCount) {
val now = TimeSource.Monotonic.markNow()
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
val delay = targetTime - now
if (delay.isPositive()) {
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
Thread.sleep(delay.inWholeMilliseconds)
}
pingTimes.add(TimeSource.Monotonic.markNow())
packetIO.sendPacket(
SequencePacket(
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
i,
ByteArray(packetSize - 6)
)
)
viewModel.packetsSent = i + 1
}
// Wait for the last ACK
Log.info("waiting for last ACK")
done.acquire()
Log.info("got last ACK")
}
override fun abort() {
done.release()
}
override fun onResetPacket() {
}
override fun onAckPacket(packet: AckPacket) {
val now = TimeSource.Monotonic.markNow()
viewModel.packetsReceived += 1
if (packet.sequenceNumber < pingTimes.size) {
val rtt = (now - pingTimes[packet.sequenceNumber]).inWholeMilliseconds
rtts.add(rtt)
Log.info("received ACK ${packet.sequenceNumber}, RTT=$rtt")
} else {
Log.warning("received ACK with unexpected sequence ${packet.sequenceNumber}")
}
if (packet.flags and Packet.LAST_FLAG != 0) {
Log.info("last packet received")
val stats = "RTTs: min=${rtts.min()}, max=${rtts.max()}, avg=${rtts.sum() / rtts.size}"
Log.info(stats)
viewModel.stats = stats
done.release()
}
}
override fun onSequencePacket(packet: SequencePacket) {
}
}

View File

@@ -0,0 +1,62 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.github.google.bumble.btbench
import java.util.logging.Logger
import kotlin.time.TimeSource
private val Log = Logger.getLogger("btbench.receiver")
class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var expectedSequenceNumber: Int = 0
init {
packetIO.packetSink = this
}
override fun run() {
viewModel.clear()
}
override fun abort() {}
override fun onResetPacket() {
startTime = TimeSource.Monotonic.markNow()
lastPacketTime = startTime
expectedSequenceNumber = 0
viewModel.packetsSent = 0
viewModel.packetsReceived = 0
viewModel.stats = ""
}
override fun onAckPacket(packet: AckPacket) {
}
override fun onSequencePacket(packet: SequencePacket) {
val now = TimeSource.Monotonic.markNow()
lastPacketTime = now
viewModel.packetsReceived += 1
if (packet.sequenceNumber != expectedSequenceNumber) {
Log.warning("unexpected packet sequence number (expected ${expectedSequenceNumber}, got ${packet.sequenceNumber})")
}
expectedSequenceNumber += 1
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
viewModel.packetsSent += 1
}
}

View File

@@ -20,7 +20,7 @@ import kotlin.time.TimeSource
private val Log = Logger.getLogger("btbench.receiver")
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var bytesReceived = 0
@@ -29,6 +29,12 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
packetIO.packetSink = this
}
override fun run() {
viewModel.clear()
}
override fun abort() {}
override fun onResetPacket() {
startTime = TimeSource.Monotonic.markNow()
lastPacketTime = startTime
@@ -36,9 +42,10 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
viewModel.throughput = 0
viewModel.packetsSent = 0
viewModel.packetsReceived = 0
viewModel.stats = ""
}
override fun onAckPacket() {
override fun onAckPacket(packet: AckPacket) {
}

View File

@@ -16,22 +16,30 @@ package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.rfcomm-client")
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
class RfcommClient(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode {
private var socketClient: SocketClient? = null
@SuppressLint("MissingPermission")
fun run() {
override fun run() {
val address = viewModel.peerBluetoothAddress.take(17)
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
DEFAULT_RFCOMM_UUID
)
val client = SocketClient(viewModel, socket)
client.run()
socketClient = SocketClient(viewModel, socket, createIoClient)
socketClient!!.run()
}
override fun waitForCompletion() {
socketClient?.waitForCompletion()
}
}

View File

@@ -16,20 +16,27 @@ package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.rfcomm-server")
class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
class RfcommServer(
private val viewModel: AppViewModel,
private val bluetoothAdapter: BluetoothAdapter,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) : Mode {
private var socketServer: SocketServer? = null
@SuppressLint("MissingPermission")
fun run() {
override fun run() {
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
"BumbleBench", DEFAULT_RFCOMM_UUID
)
val server = SocketServer(viewModel, serverSocket)
server.run({}, {})
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
socketServer!!.run({}, {})
}
}
override fun waitForCompletion() {
socketServer?.waitForCompletion()
}
}

View File

@@ -35,4 +35,4 @@ class Scan(val bluetoothAdapter: BluetoothAdapter) {
bluetoothLeScanner?.stopScan(scanCallback)
}
}
}
}

View File

@@ -19,9 +19,12 @@ import java.util.logging.Logger
import kotlin.time.DurationUnit
import kotlin.time.TimeSource
private const val DEFAULT_STARTUP_DELAY = 3000
private val Log = Logger.getLogger("btbench.sender")
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
PacketSink() {
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
private var bytesSent = 0
private val done = Semaphore(0)
@@ -30,10 +33,12 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
packetIO.packetSink = this
}
fun run() {
viewModel.packetsSent = 0
viewModel.packetsReceived = 0
viewModel.throughput = 0
override fun run() {
viewModel.clear()
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
Log.info("running")
Log.info("sending reset")
packetIO.sendPacket(ResetPacket())
@@ -63,14 +68,14 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
Log.info("got ACK")
}
fun abort() {
override fun abort() {
done.release()
}
override fun onResetPacket() {
}
override fun onAckPacket() {
override fun onAckPacket(packet: AckPacket) {
Log.info("received ACK")
val elapsed = TimeSource.Monotonic.markNow() - startTime
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
@@ -81,4 +86,4 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
override fun onSequencePacket(packet: SequencePacket) {
}
}
}

View File

@@ -22,16 +22,20 @@ import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.socket-client")
private const val DEFAULT_STARTUP_DELAY = 3000
class SocketClient(
private val viewModel: AppViewModel,
private val socket: BluetoothSocket,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) {
private var clientThread: Thread? = null
class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
@SuppressLint("MissingPermission")
fun run() {
viewModel.running = true
val socketDataSink = SocketDataSink(socket)
val streamIO = StreamedPacketIO(socketDataSink)
val socketDataSource = SocketDataSource(socket, streamIO::onData)
val sender = Sender(viewModel, streamIO)
val ioClient = createIoClient(streamIO)
fun cleanup() {
socket.close()
@@ -39,9 +43,9 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
viewModel.running = false
}
thread(name = "SocketClient") {
clientThread = thread(name = "SocketClient") {
viewModel.aborter = {
sender.abort()
ioClient.abort()
socket.close()
}
Log.info("connecting to remote")
@@ -49,27 +53,37 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
socket.connect()
} catch (error: IOException) {
Log.warning("connection failed")
viewModel.status = "ABORTED"
viewModel.lastError = "CONNECTION_FAILED"
cleanup()
return@thread
}
Log.info("connected")
thread {
val sourceThread = thread {
socketDataSource.receive()
socket.close()
sender.abort()
ioClient.abort()
}
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
Log.info("Starting to send")
try {
sender.run()
ioClient.run()
socket.close()
viewModel.status = "OK"
} catch (error: IOException) {
Log.info("run ended abruptly")
viewModel.status = "ABORTED"
viewModel.lastError = "IO_ERROR"
}
Log.info("waiting for source thread to finish")
sourceThread.join()
cleanup()
}
}
}
fun waitForCompletion() {
clientThread?.join()
}
}

View File

@@ -21,7 +21,13 @@ import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.socket-server")
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
class SocketServer(
private val viewModel: AppViewModel,
private val serverSocket: BluetoothServerSocket,
private val createIoClient: (packetIo: PacketIO) -> IoClient
) {
private var serverThread: Thread? = null
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
var aborted = false
viewModel.running = true
@@ -31,7 +37,7 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
viewModel.running = false
}
thread(name = "SocketServer") {
serverThread = thread(name = "SocketServer") {
while (!aborted) {
viewModel.aborter = {
serverSocket.close()
@@ -46,6 +52,8 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
return@thread
}
Log.info("got connection from ${socket.remoteDevice.address}")
Log.info("maxReceivePacketSize=${socket.maxReceivePacketSize}")
Log.info("maxTransmitPacketSize=${socket.maxTransmitPacketSize}")
onConnected()
viewModel.aborter = {
@@ -57,11 +65,22 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
val socketDataSink = SocketDataSink(socket)
val streamIO = StreamedPacketIO(socketDataSink)
val socketDataSource = SocketDataSource(socket, streamIO::onData)
val receiver = Receiver(viewModel, streamIO)
val ioThread = thread(name = "IoClient") {
val ioClient = createIoClient(streamIO)
ioClient.run()
}
socketDataSource.receive()
socket.close()
ioThread.join()
}
cleanup()
}
}
}
fun waitForCompletion() {
serverThread?.join()
}
}

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.2.0"
agp = "8.4.0"
kotlin = "1.9.0"
core-ktx = "1.12.0"
junit = "4.13.2"
@@ -8,6 +8,8 @@ espresso-core = "3.5.1"
lifecycle-runtime-ktx = "2.6.2"
activity-compose = "1.7.2"
compose-bom = "2023.08.00"
mobly-snippet = "1.4.0"
core = "1.6.1"
[libraries]
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
@@ -24,6 +26,8 @@ ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
material3 = { group = "androidx.compose.material3", name = "material3" }
mobly-snippet = { group = "com.google.android.mobly", name = "mobly-snippet-lib", version.ref = "mobly.snippet" }
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -1,6 +1,6 @@
#Wed Oct 25 07:40:52 PDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -23,6 +23,7 @@ public class HciProxy {
HciHal hciHal = HciHal.create(new HciHalCallback() {
@Override
public void onPacket(HciPacket.Type type, byte[] packet) {
Log.d(TAG, String.format("CONTROLLER->HOST: type=%s, size=%d", type, packet.length));
mServer.sendPacket(type, packet);
switch (type) {
@@ -83,7 +84,7 @@ public class HciProxy {
@Override
public void onPacket(HciPacket.Type type, byte[] packet) {
Log.d(TAG, String.format("onPacket: type=%s, size=%d", type, packet.length));
Log.d(TAG, String.format("HOST->CONTROLLER: type=%s, size=%d", type, packet.length));
hciHal.sendPacket(type, packet);
switch (type) {

View File

@@ -45,6 +45,7 @@ ignore="pandora" # FIXME: pylint does not support stubs yet:
[tool.pylint.typecheck]
signature-mutators="AsyncRunner.run_in_task"
disable="not-callable"
[tool.black]
skip-string-normalization = true
@@ -55,7 +56,7 @@ extend-exclude = '''
'''
[tool.mypy]
exclude = ['bumble/transport/grpc_protobuf']
exclude = ['bumble/transport/grpc_protobuf', 'examples/mobly/bench']
[[tool.mypy.overrides]]
module = "bumble.transport.grpc_protobuf.*"

View File

@@ -80,7 +80,7 @@ impl Address {
/// Creates a new [Address] object.
pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
PyModule::import(py, intern!(py, "bumble.hci"))?
.getattr(intern!(py, "Address"))?
.call1((address, address_type))
.map(|any| Self(any.into()))

View File

@@ -51,7 +51,7 @@ install_requires =
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
pyusb >= 1.2; platform_system!='Emscripten'
websockets >= 12.0; platform_system!='Emscripten'
websockets == 13.1; platform_system!='Emscripten'
[options.entry_points]
console_scripts =
@@ -91,9 +91,10 @@ development =
black == 24.3
grpcio-tools >= 1.62.1
invoke >= 1.7.3
mypy == 1.10.0
mobly >= 1.12.2
mypy == 1.12.0
nox >= 2022
pylint == 3.1.0
pylint == 3.3.1
pyyaml >= 6.0
types-appdirs >= 1.4.3
types-invoke >= 1.7.3

View File

@@ -28,6 +28,7 @@ from bumble.profiles.aics import (
AudioInputState,
AICSServiceProxy,
GainMode,
GainSettingsProperties,
AudioInputStatus,
AudioInputControlPointOpCode,
ErrorCode,
@@ -82,7 +83,12 @@ async def test_init_service(aics_client: AICSServiceProxy):
gain_mode=GainMode.MANUAL,
change_counter=0,
)
assert await aics_client.gain_settings_properties.read_value() == (1, 0, 255)
assert (
await aics_client.gain_settings_properties.read_value()
== GainSettingsProperties(
gain_settings_unit=1, gain_settings_minimum=0, gain_settings_maximum=255
)
)
assert await aics_client.audio_input_status.read_value() == (
AudioInputStatus.ACTIVE
)
@@ -481,12 +487,12 @@ async def test_set_automatic_gain_mode_when_automatic_only(
@pytest.mark.asyncio
async def test_audio_input_description_initial_value(aics_client: AICSServiceProxy):
description = await aics_client.audio_input_description.read_value()
assert description.decode('utf-8') == "Bluetooth"
assert description == "Bluetooth"
@pytest.mark.asyncio
async def test_audio_input_description_write_and_read(aics_client: AICSServiceProxy):
new_description = "Line Input".encode('utf-8')
new_description = "Line Input"
await aics_client.audio_input_description.write_value(new_description)

View File

@@ -15,11 +15,13 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
import os
import struct
import pytest
from typing_extensions import Self
from unittest.mock import AsyncMock, Mock, ANY
from bumble.controller import Controller
@@ -31,6 +33,7 @@ from bumble.gatt import (
GATT_BATTERY_LEVEL_CHARACTERISTIC,
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
CharacteristicAdapter,
SerializableCharacteristicAdapter,
DelegatedCharacteristicAdapter,
PackedCharacteristicAdapter,
MappedCharacteristicAdapter,
@@ -310,7 +313,7 @@ async def test_attribute_getters():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_CharacteristicAdapter():
async def test_CharacteristicAdapter() -> None:
# Check that the CharacteristicAdapter base class is transparent
v = bytes([1, 2, 3])
c = Characteristic(
@@ -329,67 +332,94 @@ async def test_CharacteristicAdapter():
assert c.value == v
# Simple delegated adapter
a = DelegatedCharacteristicAdapter(
delegated = DelegatedCharacteristicAdapter(
c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))
)
value = await a.read_value(None)
assert value == bytes(reversed(v))
delegated_value = await delegated.read_value(None)
assert delegated_value == bytes(reversed(v))
v = bytes([3, 4, 5])
await a.write_value(None, v)
assert a.value == bytes(reversed(v))
delegated_value2 = bytes([3, 4, 5])
await delegated.write_value(None, delegated_value2)
assert delegated.value == bytes(reversed(delegated_value2))
# Packed adapter with single element format
v = 1234
pv = struct.pack('>H', v)
c.value = v
a = PackedCharacteristicAdapter(c, '>H')
packed_value_ref = 1234
packed_value_bytes = struct.pack('>H', packed_value_ref)
c.value = packed_value_ref
packed = PackedCharacteristicAdapter(c, '>H')
value = await a.read_value(None)
assert value == pv
c.value = None
await a.write_value(None, pv)
assert a.value == v
packed_value_read = await packed.read_value(None)
assert packed_value_read == packed_value_bytes
c.value = b''
await packed.write_value(None, packed_value_bytes)
assert packed.value == packed_value_ref
# Packed adapter with multi-element format
v1 = 1234
v2 = 5678
pv = struct.pack('>HH', v1, v2)
packed_multi_value_bytes = struct.pack('>HH', v1, v2)
c.value = (v1, v2)
a = PackedCharacteristicAdapter(c, '>HH')
packed_multi = PackedCharacteristicAdapter(c, '>HH')
value = await a.read_value(None)
assert value == pv
c.value = None
await a.write_value(None, pv)
assert a.value == (v1, v2)
packed_multi_read_value = await packed_multi.read_value(None)
assert packed_multi_read_value == packed_multi_value_bytes
packed_multi.value = b''
await packed_multi.write_value(None, packed_multi_value_bytes)
assert packed_multi.value == (v1, v2)
# Mapped adapter
v1 = 1234
v2 = 5678
pv = struct.pack('>HH', v1, v2)
packed_mapped_value_bytes = struct.pack('>HH', v1, v2)
mapped = {'v1': v1, 'v2': v2}
c.value = mapped
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
packed_mapped = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
value = await a.read_value(None)
assert value == pv
c.value = None
await a.write_value(None, pv)
assert a.value == mapped
packed_mapped_read_value = await packed_mapped.read_value(None)
assert packed_mapped_read_value == packed_mapped_value_bytes
c.value = b''
await packed_mapped.write_value(None, packed_mapped_value_bytes)
assert packed_mapped.value == mapped
# UTF-8 adapter
v = 'Hello π'
ev = v.encode('utf-8')
c.value = v
a = UTF8CharacteristicAdapter(c)
string_value = 'Hello π'
string_value_bytes = string_value.encode('utf-8')
c.value = string_value
string_c = UTF8CharacteristicAdapter(c)
value = await a.read_value(None)
assert value == ev
c.value = None
await a.write_value(None, ev)
assert a.value == v
string_read_value = await string_c.read_value(None)
assert string_read_value == string_value_bytes
c.value = b''
await string_c.write_value(None, string_value_bytes)
assert string_c.value == string_value
# Class adapter
class BlaBla:
def __init__(self, a: int, b: int) -> None:
self.a = a
self.b = b
@classmethod
def from_bytes(cls, data: bytes) -> Self:
a, b = struct.unpack(">II", data)
return cls(a, b)
def __bytes__(self) -> bytes:
return struct.pack(">II", self.a, self.b)
class_value = BlaBla(3, 4)
class_value_bytes = struct.pack(">II", 3, 4)
c.value = class_value
class_c = SerializableCharacteristicAdapter(c, BlaBla)
class_read_value = await class_c.read_value(None)
assert class_read_value == class_value_bytes
c.value = b''
await class_c.write_value(None, class_value_bytes)
assert isinstance(c.value, BlaBla)
assert c.value.a == 3
assert c.value.b == 4
# -----------------------------------------------------------------------------
@@ -851,7 +881,12 @@ async def test_unsubscribe():
await async_barrier()
mock1.assert_called_once_with(ANY, True, False)
await c2.subscribe()
assert len(server.gatt_server.subscribers) == 1
def callback(_):
pass
await c2.subscribe(callback)
await async_barrier()
mock2.assert_called_once_with(ANY, True, False)
@@ -861,10 +896,16 @@ async def test_unsubscribe():
mock1.assert_called_once_with(ANY, False, False)
mock2.reset_mock()
await c2.unsubscribe()
await c2.unsubscribe(callback)
await async_barrier()
mock2.assert_called_once_with(ANY, False, False)
# All CCCDs should be zeros now
assert list(server.gatt_server.subscribers.values())[0] == {
c1.handle: bytes([0, 0]),
c2.handle: bytes([0, 0]),
}
mock1.reset_mock()
await c1.unsubscribe()
await async_barrier()