forked from auracaster/bumble_mirror
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcd6bd7136 | |||
| 2bed50b353 | |||
| 1fe3778a74 | |||
| 5e31bcf23d | |||
| fe429cb2eb | |||
| c91695c23a | |||
| 55f99e6887 | |||
| b190069f48 |
+138
-93
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
# 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 asyncio.subprocess
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
|
||||
import click
|
||||
|
||||
from bumble.a2dp import (
|
||||
make_audio_source_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
A2DP_NON_A2DP_CODEC_TYPE,
|
||||
AacFrame,
|
||||
AacParser,
|
||||
AacPacketSource,
|
||||
AacMediaCodecInformation,
|
||||
SbcFrame,
|
||||
SbcParser,
|
||||
SbcPacketSource,
|
||||
SbcMediaCodecInformation,
|
||||
OpusPacket,
|
||||
OpusParser,
|
||||
OpusPacketSource,
|
||||
OpusMediaCodecInformation,
|
||||
)
|
||||
from bumble.avrcp import Protocol as AvrcpProtocol
|
||||
from bumble.avdtp import (
|
||||
find_avdtp_service_with_connection,
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacketPump,
|
||||
Protocol as AvdtpProtocol,
|
||||
)
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
AdvertisingData,
|
||||
ConnectionError as BumbleConnectionError,
|
||||
DeviceClass,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble.device import Connection, Device, DeviceConfiguration
|
||||
from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant
|
||||
from bumble.pairing import PairingConfig
|
||||
from bumble.transport import open_transport
|
||||
from bumble.utils import AsyncRunner
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def a2dp_source_sdp_records():
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
service_record_handle: make_audio_source_service_sdp_records(
|
||||
service_record_handle
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def sbc_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
sbc_parser = SbcParser(read_function)
|
||||
sbc_frame: SbcFrame
|
||||
async for sbc_frame in sbc_parser.frames:
|
||||
# We only need the first frame
|
||||
print(color(f"SBC format: {sbc_frame}", "cyan"))
|
||||
break
|
||||
|
||||
channel_mode = [
|
||||
SbcMediaCodecInformation.ChannelMode.MONO,
|
||||
SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL,
|
||||
SbcMediaCodecInformation.ChannelMode.STEREO,
|
||||
SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
][sbc_frame.channel_mode]
|
||||
block_length = {
|
||||
4: SbcMediaCodecInformation.BlockLength.BL_4,
|
||||
8: SbcMediaCodecInformation.BlockLength.BL_8,
|
||||
12: SbcMediaCodecInformation.BlockLength.BL_12,
|
||||
16: SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
}[sbc_frame.block_count]
|
||||
subbands = {
|
||||
4: SbcMediaCodecInformation.Subbands.S_4,
|
||||
8: SbcMediaCodecInformation.Subbands.S_8,
|
||||
}[sbc_frame.subband_count]
|
||||
allocation_method = [
|
||||
SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||
SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
][sbc_frame.allocation_method]
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.from_int(
|
||||
sbc_frame.sampling_frequency
|
||||
),
|
||||
channel_mode=channel_mode,
|
||||
block_length=block_length,
|
||||
subbands=subbands,
|
||||
allocation_method=allocation_method,
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def aac_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
aac_parser = AacParser(read_function)
|
||||
aac_frame: AacFrame
|
||||
async for aac_frame in aac_parser.frames:
|
||||
# We only need the first frame
|
||||
print(color(f"AAC format: {aac_frame}", "cyan"))
|
||||
break
|
||||
|
||||
sampling_frequency = AacMediaCodecInformation.SamplingFrequency.from_int(
|
||||
aac_frame.sampling_frequency
|
||||
)
|
||||
channels = (
|
||||
AacMediaCodecInformation.Channels.MONO
|
||||
if aac_frame.channel_configuration == 1
|
||||
else AacMediaCodecInformation.Channels.STEREO
|
||||
)
|
||||
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=sampling_frequency,
|
||||
channels=channels,
|
||||
vbr=1,
|
||||
bitrate=128000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def opus_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
opus_parser = OpusParser(read_function)
|
||||
opus_packet: OpusPacket
|
||||
async for opus_packet in opus_parser.packets:
|
||||
# We only need the first packet
|
||||
print(color(f"Opus format: {opus_packet}", "cyan"))
|
||||
break
|
||||
|
||||
if opus_packet.channel_mode == OpusPacket.ChannelMode.MONO:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.MONO
|
||||
elif opus_packet.channel_mode == OpusPacket.ChannelMode.STEREO:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.STEREO
|
||||
else:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.DUAL_MONO
|
||||
|
||||
if opus_packet.duration == 10:
|
||||
frame_size = OpusMediaCodecInformation.FrameSize.FS_10MS
|
||||
else:
|
||||
frame_size = OpusMediaCodecInformation.FrameSize.FS_20MS
|
||||
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_NON_A2DP_CODEC_TYPE,
|
||||
media_codec_information=OpusMediaCodecInformation(
|
||||
channel_mode=channel_mode,
|
||||
sampling_frequency=OpusMediaCodecInformation.SamplingFrequency.SF_48000,
|
||||
frame_size=frame_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Player:
|
||||
def __init__(
|
||||
self,
|
||||
transport: str,
|
||||
device_config: Optional[str],
|
||||
authenticate: bool,
|
||||
encrypt: bool,
|
||||
) -> None:
|
||||
self.transport = transport
|
||||
self.device_config = device_config
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt
|
||||
self.avrcp_protocol: Optional[AvrcpProtocol] = None
|
||||
self.done: Optional[asyncio.Event]
|
||||
|
||||
async def run(self, workload) -> None:
|
||||
self.done = asyncio.Event()
|
||||
try:
|
||||
await self._run(workload)
|
||||
except Exception as error:
|
||||
print(color(f"!!! ERROR: {error}", "red"))
|
||||
|
||||
async def _run(self, workload) -> None:
|
||||
async with await open_transport(self.transport) as (hci_source, hci_sink):
|
||||
# Create a device
|
||||
device_config = DeviceConfiguration()
|
||||
if self.device_config:
|
||||
device_config.load_from_file(self.device_config)
|
||||
else:
|
||||
device_config.name = "Bumble Player"
|
||||
device_config.class_of_device = DeviceClass.pack_class_of_device(
|
||||
DeviceClass.AUDIO_SERVICE_CLASS,
|
||||
DeviceClass.AUDIO_VIDEO_MAJOR_DEVICE_CLASS,
|
||||
DeviceClass.AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS,
|
||||
)
|
||||
device_config.keystore = "JsonKeyStore"
|
||||
|
||||
device_config.classic_enabled = True
|
||||
device_config.le_enabled = False
|
||||
device_config.le_simultaneous_enabled = False
|
||||
device_config.classic_sc_enabled = False
|
||||
device_config.classic_smp_enabled = False
|
||||
device = Device.from_config_with_hci(device_config, hci_source, hci_sink)
|
||||
|
||||
# Setup the SDP records to expose the SRC service
|
||||
device.sdp_service_records = a2dp_source_sdp_records()
|
||||
|
||||
# Setup AVRCP
|
||||
self.avrcp_protocol = AvrcpProtocol()
|
||||
self.avrcp_protocol.listen(device)
|
||||
|
||||
# Don't require MITM when pairing.
|
||||
device.pairing_config_factory = lambda connection: PairingConfig(mitm=False)
|
||||
|
||||
# Start the controller
|
||||
await device.power_on()
|
||||
|
||||
# Print some of the config/properties
|
||||
print(
|
||||
"Player Bluetooth Address:",
|
||||
color(
|
||||
device.public_address.to_string(with_type_qualifier=False),
|
||||
"yellow",
|
||||
),
|
||||
)
|
||||
|
||||
# Listen for connections
|
||||
device.on("connection", self.on_bluetooth_connection)
|
||||
|
||||
# Run the workload
|
||||
try:
|
||||
await workload(device)
|
||||
except BumbleConnectionError as error:
|
||||
if error.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
|
||||
print(color("Connection already established", "blue"))
|
||||
else:
|
||||
print(color(f"Failed to connect: {error}", "red"))
|
||||
|
||||
# Wait until it is time to exit
|
||||
assert self.done is not None
|
||||
await asyncio.wait(
|
||||
[hci_source.terminated, asyncio.ensure_future(self.done.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
def on_bluetooth_connection(self, connection: Connection) -> None:
|
||||
print(color(f"--- Connected: {connection}", "cyan"))
|
||||
connection.on("disconnection", self.on_bluetooth_disconnection)
|
||||
|
||||
def on_bluetooth_disconnection(self, reason) -> None:
|
||||
print(color(f"--- Disconnected: {HCI_Constant.error_name(reason)}", "cyan"))
|
||||
self.set_done()
|
||||
|
||||
async def connect(self, device: Device, address: str) -> Connection:
|
||||
print(color(f"Connecting to {address}...", "green"))
|
||||
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||
|
||||
# Request authentication
|
||||
if self.authenticate:
|
||||
print(color("*** Authenticating...", "blue"))
|
||||
await connection.authenticate()
|
||||
print(color("*** Authenticated", "blue"))
|
||||
|
||||
# Enable encryption
|
||||
if self.encrypt:
|
||||
print(color("*** Enabling encryption...", "blue"))
|
||||
await connection.encrypt()
|
||||
print(color("*** Encryption on", "blue"))
|
||||
|
||||
return connection
|
||||
|
||||
async def create_avdtp_protocol(self, connection: Connection) -> AvdtpProtocol:
|
||||
# Look for an A2DP service
|
||||
avdtp_version = await find_avdtp_service_with_connection(connection)
|
||||
if not avdtp_version:
|
||||
raise RuntimeError("no A2DP service found")
|
||||
|
||||
print(color(f"AVDTP Version: {avdtp_version}"))
|
||||
|
||||
# Create a client to interact with the remote device
|
||||
return await AvdtpProtocol.connect(connection, avdtp_version)
|
||||
|
||||
async def stream_packets(
|
||||
self,
|
||||
protocol: AvdtpProtocol,
|
||||
codec_type: int,
|
||||
vendor_id: int,
|
||||
codec_id: int,
|
||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource],
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
):
|
||||
# Discover all endpoints on the remote device
|
||||
endpoints = await protocol.discover_remote_endpoints()
|
||||
for endpoint in endpoints:
|
||||
print('@@@', endpoint)
|
||||
|
||||
# Select a sink
|
||||
sink = protocol.find_remote_sink_by_codec(
|
||||
AVDTP_AUDIO_MEDIA_TYPE, codec_type, vendor_id, codec_id
|
||||
)
|
||||
if sink is None:
|
||||
print(color('!!! no compatible sink found', 'red'))
|
||||
return
|
||||
print(f'### Selected sink: {sink.seid}')
|
||||
|
||||
# Check if the sink supports delay reporting
|
||||
delay_reporting = False
|
||||
for capability in sink.capabilities:
|
||||
if capability.service_category == AVDTP_DELAY_REPORTING_SERVICE_CATEGORY:
|
||||
delay_reporting = True
|
||||
break
|
||||
|
||||
def on_delay_report(delay: int):
|
||||
print(color(f"*** DELAY REPORT: {delay}", "blue"))
|
||||
|
||||
# Adjust the codec capabilities for certain codecs
|
||||
for capability in sink.capabilities:
|
||||
if isinstance(capability, MediaCodecCapabilities):
|
||||
if isinstance(
|
||||
codec_capabilities.media_codec_information, SbcMediaCodecInformation
|
||||
) and isinstance(
|
||||
capability.media_codec_information, SbcMediaCodecInformation
|
||||
):
|
||||
codec_capabilities.media_codec_information.minimum_bitpool_value = (
|
||||
capability.media_codec_information.minimum_bitpool_value
|
||||
)
|
||||
codec_capabilities.media_codec_information.maximum_bitpool_value = (
|
||||
capability.media_codec_information.maximum_bitpool_value
|
||||
)
|
||||
print(color("Source media codec:", "green"), codec_capabilities)
|
||||
|
||||
# Stream the packets
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
source = protocol.add_source(codec_capabilities, packet_pump, delay_reporting)
|
||||
source.on("delay_report", on_delay_report)
|
||||
stream = await protocol.create_stream(source, sink)
|
||||
await stream.start()
|
||||
|
||||
await packet_pump.wait_for_completion()
|
||||
|
||||
async def discover(self, device: Device) -> None:
|
||||
@device.listens_to("inquiry_result")
|
||||
def on_inquiry_result(
|
||||
address: Address, class_of_device: int, data: AdvertisingData, rssi: int
|
||||
) -> None:
|
||||
(
|
||||
service_classes,
|
||||
major_device_class,
|
||||
minor_device_class,
|
||||
) = DeviceClass.split_class_of_device(class_of_device)
|
||||
separator = "\n "
|
||||
print(f">>> {color(address.to_string(False), 'yellow')}:")
|
||||
print(f" Device Class (raw): {class_of_device:06X}")
|
||||
major_class_name = DeviceClass.major_device_class_name(major_device_class)
|
||||
print(" Device Major Class: " f"{major_class_name}")
|
||||
minor_class_name = DeviceClass.minor_device_class_name(
|
||||
major_device_class, minor_device_class
|
||||
)
|
||||
print(" Device Minor Class: " f"{minor_class_name}")
|
||||
print(
|
||||
" Device Services: "
|
||||
f"{', '.join(DeviceClass.service_class_labels(service_classes))}"
|
||||
)
|
||||
print(f" RSSI: {rssi}")
|
||||
if data.ad_structures:
|
||||
print(f" {data.to_string(separator)}")
|
||||
|
||||
await device.start_discovery()
|
||||
|
||||
async def pair(self, device: Device, address: str) -> None:
|
||||
print(color(f"Connecting to {address}...", "green"))
|
||||
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||
|
||||
print(color("Pairing...", "magenta"))
|
||||
await connection.authenticate()
|
||||
print(color("Pairing completed", "magenta"))
|
||||
self.set_done()
|
||||
|
||||
async def inquire(self, device: Device, address: str) -> None:
|
||||
connection = await self.connect(device, address)
|
||||
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
||||
|
||||
# Discover the remote endpoints
|
||||
endpoints = await avdtp_protocol.discover_remote_endpoints()
|
||||
print(f'@@@ Found {len(list(endpoints))} endpoints')
|
||||
for endpoint in endpoints:
|
||||
print('@@@', endpoint)
|
||||
|
||||
self.set_done()
|
||||
|
||||
async def play(
|
||||
self,
|
||||
device: Device,
|
||||
address: Optional[str],
|
||||
audio_format: str,
|
||||
audio_file: str,
|
||||
) -> None:
|
||||
if audio_format == "auto":
|
||||
if audio_file.endswith(".sbc"):
|
||||
audio_format = "sbc"
|
||||
elif audio_file.endswith(".aac") or audio_file.endswith(".adts"):
|
||||
audio_format = "aac"
|
||||
elif audio_file.endswith(".ogg"):
|
||||
audio_format = "opus"
|
||||
else:
|
||||
raise ValueError("Unable to determine audio format from file extension")
|
||||
|
||||
device.on(
|
||||
"connection",
|
||||
lambda connection: AsyncRunner.spawn(on_connection(connection)),
|
||||
)
|
||||
|
||||
async def on_connection(connection: Connection):
|
||||
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
||||
|
||||
with open(audio_file, 'rb') as input_file:
|
||||
# NOTE: this should be using asyncio file reading, but blocking reads
|
||||
# are good enough for this command line app.
|
||||
async def read_audio_data(byte_count):
|
||||
return input_file.read(byte_count)
|
||||
|
||||
# Obtain the codec capabilities from the stream
|
||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource]
|
||||
vendor_id = 0
|
||||
codec_id = 0
|
||||
if audio_format == "sbc":
|
||||
codec_type = A2DP_SBC_CODEC_TYPE
|
||||
codec_capabilities = await sbc_codec_capabilities(read_audio_data)
|
||||
packet_source = SbcPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
elif audio_format == "aac":
|
||||
codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE
|
||||
codec_capabilities = await aac_codec_capabilities(read_audio_data)
|
||||
packet_source = AacPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
else:
|
||||
codec_type = A2DP_NON_A2DP_CODEC_TYPE
|
||||
vendor_id = OpusMediaCodecInformation.VENDOR_ID
|
||||
codec_id = OpusMediaCodecInformation.CODEC_ID
|
||||
codec_capabilities = await opus_codec_capabilities(read_audio_data)
|
||||
packet_source = OpusPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
|
||||
# Rewind to the start
|
||||
input_file.seek(0)
|
||||
|
||||
try:
|
||||
await self.stream_packets(
|
||||
avdtp_protocol,
|
||||
codec_type,
|
||||
vendor_id,
|
||||
codec_id,
|
||||
packet_source,
|
||||
codec_capabilities,
|
||||
)
|
||||
except Exception as error:
|
||||
print(color(f"!!! Error while streaming: {error}", "red"))
|
||||
|
||||
self.set_done()
|
||||
|
||||
if address:
|
||||
await self.connect(device, address)
|
||||
else:
|
||||
print(color("Waiting for an incoming connection...", "magenta"))
|
||||
|
||||
def set_done(self) -> None:
|
||||
if self.done:
|
||||
self.done.set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def create_player(context) -> Player:
|
||||
return Player(
|
||||
transport=context.obj["hci_transport"],
|
||||
device_config=context.obj["device_config"],
|
||||
authenticate=context.obj["authenticate"],
|
||||
encrypt=context.obj["encrypt"],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
@click.option("--hci-transport", metavar="TRANSPORT", required=True)
|
||||
@click.option("--device-config", metavar="FILENAME", help="Device configuration file")
|
||||
@click.option(
|
||||
"--authenticate",
|
||||
is_flag=True,
|
||||
help="Request authentication when connecting",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--encrypt", is_flag=True, help="Request encryption when connecting", default=True
|
||||
)
|
||||
def player_cli(ctx, hci_transport, device_config, authenticate, encrypt):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["hci_transport"] = hci_transport
|
||||
ctx.obj["device_config"] = device_config
|
||||
ctx.obj["authenticate"] = authenticate
|
||||
ctx.obj["encrypt"] = encrypt
|
||||
|
||||
|
||||
@player_cli.command("discover")
|
||||
@click.pass_context
|
||||
def discover(context):
|
||||
"""Discover speakers or headphones"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(player.discover))
|
||||
|
||||
|
||||
@player_cli.command("inquire")
|
||||
@click.pass_context
|
||||
@click.argument(
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
)
|
||||
def inquire(context, address):
|
||||
"""Connect to a speaker or headphone and inquire about their capabilities"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(lambda device: player.inquire(device, address)))
|
||||
|
||||
|
||||
@player_cli.command("pair")
|
||||
@click.pass_context
|
||||
@click.argument(
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
)
|
||||
def pair(context, address):
|
||||
"""Pair with a speaker or headphone"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(lambda device: player.pair(device, address)))
|
||||
|
||||
|
||||
@player_cli.command("play")
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--connect",
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
help="Address or name to connect to",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--audio-format",
|
||||
type=click.Choice(["auto", "sbc", "aac", "opus"]),
|
||||
help="Audio file format (use 'auto' to infer the format from the file extension)",
|
||||
default="auto",
|
||||
)
|
||||
@click.argument("audio_file")
|
||||
def play(context, address, audio_format, audio_file):
|
||||
"""Play and audio file"""
|
||||
player = create_player(context)
|
||||
asyncio.run(
|
||||
player.run(
|
||||
lambda device: player.play(device, address, audio_format, audio_file)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||
player_cli()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
main() # pylint: disable=no-value-for-parameter
|
||||
+10
-1
@@ -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))
|
||||
|
||||
+27
-25
@@ -44,18 +44,25 @@ from bumble.avdtp import (
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
Listener,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacket,
|
||||
Protocol,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE,
|
||||
make_audio_sink_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SbcMediaCodecInformation,
|
||||
AacMediaCodecInformation,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.codecs import AacAudioRtpPacket
|
||||
from bumble.rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -86,7 +93,7 @@ class AudioExtractor:
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
return AacAudioRtpPacket.from_bytes(packet.payload).to_adts()
|
||||
return AacAudioRtpPacket(packet.payload).to_adts()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -444,12 +451,10 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
channels=AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO,
|
||||
media_codec_information=AacMediaCodecInformation.from_lists(
|
||||
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
||||
sampling_frequencies=[48000, 44100],
|
||||
channels=[1, 2],
|
||||
vbr=1,
|
||||
bitrate=256000,
|
||||
),
|
||||
@@ -459,23 +464,20 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||
| SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
|
||||
+203
-503
@@ -17,16 +17,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import struct
|
||||
from typing import Awaitable, Callable
|
||||
from typing_extensions import ClassVar, Self
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import List, Callable, Awaitable
|
||||
|
||||
|
||||
from .codecs import AacAudioRtpPacket
|
||||
from .company_ids import COMPANY_IDENTIFIERS
|
||||
from .sdp import (
|
||||
DataElement,
|
||||
@@ -46,7 +42,6 @@ from .core import (
|
||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||
name_or_number,
|
||||
)
|
||||
from .rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -108,8 +103,6 @@ SBC_ALLOCATION_METHOD_NAMES = {
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
||||
}
|
||||
|
||||
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||
|
||||
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
||||
8000,
|
||||
11025,
|
||||
@@ -137,9 +130,6 @@ MPEG_2_4_OBJECT_TYPE_NAMES = {
|
||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
||||
}
|
||||
|
||||
|
||||
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
@@ -267,61 +257,38 @@ class SbcMediaCodecInformation:
|
||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
sampling_frequency: SamplingFrequency
|
||||
channel_mode: ChannelMode
|
||||
block_length: BlockLength
|
||||
subbands: Subbands
|
||||
allocation_method: AllocationMethod
|
||||
sampling_frequency: int
|
||||
channel_mode: int
|
||||
block_length: int
|
||||
subbands: int
|
||||
allocation_method: int
|
||||
minimum_bitpool_value: int
|
||||
maximum_bitpool_value: int
|
||||
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_16000 = 1 << 3
|
||||
SF_32000 = 1 << 2
|
||||
SF_44100 = 1 << 1
|
||||
SF_48000 = 1 << 0
|
||||
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
||||
CHANNEL_MODE_BITS = {
|
||||
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
||||
SBC_DUAL_CHANNEL_MODE: 1 << 2,
|
||||
SBC_STEREO_CHANNEL_MODE: 1 << 1,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE: 1,
|
||||
}
|
||||
BLOCK_LENGTH_BITS = {4: 1 << 3, 8: 1 << 2, 12: 1 << 1, 16: 1}
|
||||
SUBBANDS_BITS = {4: 1 << 1, 8: 1}
|
||||
ALLOCATION_METHOD_BITS = {
|
||||
SBC_SNR_ALLOCATION_METHOD: 1 << 1,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD: 1,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, sampling_frequency: int) -> Self:
|
||||
sampling_frequencies = [
|
||||
16000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
]
|
||||
index = sampling_frequencies.index(sampling_frequency)
|
||||
return cls(1 << (len(sampling_frequencies) - index - 1))
|
||||
|
||||
class ChannelMode(enum.IntFlag):
|
||||
MONO = 1 << 3
|
||||
DUAL_CHANNEL = 1 << 2
|
||||
STEREO = 1 << 1
|
||||
JOINT_STEREO = 1 << 0
|
||||
|
||||
class BlockLength(enum.IntFlag):
|
||||
BL_4 = 1 << 3
|
||||
BL_8 = 1 << 2
|
||||
BL_12 = 1 << 1
|
||||
BL_16 = 1 << 0
|
||||
|
||||
class Subbands(enum.IntFlag):
|
||||
S_4 = 1 << 1
|
||||
S_8 = 1 << 0
|
||||
|
||||
class AllocationMethod(enum.IntFlag):
|
||||
SNR = 1 << 1
|
||||
LOUDNESS = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
sampling_frequency = cls.SamplingFrequency((data[0] >> 4) & 0x0F)
|
||||
channel_mode = cls.ChannelMode((data[0] >> 0) & 0x0F)
|
||||
block_length = cls.BlockLength((data[1] >> 4) & 0x0F)
|
||||
subbands = cls.Subbands((data[1] >> 2) & 0x03)
|
||||
allocation_method = cls.AllocationMethod((data[1] >> 0) & 0x03)
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> SbcMediaCodecInformation:
|
||||
sampling_frequency = (data[0] >> 4) & 0x0F
|
||||
channel_mode = (data[0] >> 0) & 0x0F
|
||||
block_length = (data[1] >> 4) & 0x0F
|
||||
subbands = (data[1] >> 2) & 0x03
|
||||
allocation_method = (data[1] >> 0) & 0x03
|
||||
minimum_bitpool_value = (data[2] >> 0) & 0xFF
|
||||
maximum_bitpool_value = (data[3] >> 0) & 0xFF
|
||||
return cls(
|
||||
return SbcMediaCodecInformation(
|
||||
sampling_frequency,
|
||||
channel_mode,
|
||||
block_length,
|
||||
@@ -331,6 +298,52 @@ class SbcMediaCodecInformation:
|
||||
maximum_bitpool_value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_discrete_values(
|
||||
cls,
|
||||
sampling_frequency: int,
|
||||
channel_mode: int,
|
||||
block_length: int,
|
||||
subbands: int,
|
||||
allocation_method: int,
|
||||
minimum_bitpool_value: int,
|
||||
maximum_bitpool_value: int,
|
||||
) -> SbcMediaCodecInformation:
|
||||
return SbcMediaCodecInformation(
|
||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
|
||||
block_length=cls.BLOCK_LENGTH_BITS[block_length],
|
||||
subbands=cls.SUBBANDS_BITS[subbands],
|
||||
allocation_method=cls.ALLOCATION_METHOD_BITS[allocation_method],
|
||||
minimum_bitpool_value=minimum_bitpool_value,
|
||||
maximum_bitpool_value=maximum_bitpool_value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_lists(
|
||||
cls,
|
||||
sampling_frequencies: List[int],
|
||||
channel_modes: List[int],
|
||||
block_lengths: List[int],
|
||||
subbands: List[int],
|
||||
allocation_methods: List[int],
|
||||
minimum_bitpool_value: int,
|
||||
maximum_bitpool_value: int,
|
||||
) -> SbcMediaCodecInformation:
|
||||
return SbcMediaCodecInformation(
|
||||
sampling_frequency=sum(
|
||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||
),
|
||||
channel_mode=sum(cls.CHANNEL_MODE_BITS[x] for x in channel_modes),
|
||||
block_length=sum(cls.BLOCK_LENGTH_BITS[x] for x in block_lengths),
|
||||
subbands=sum(cls.SUBBANDS_BITS[x] for x in subbands),
|
||||
allocation_method=sum(
|
||||
cls.ALLOCATION_METHOD_BITS[x] for x in allocation_methods
|
||||
),
|
||||
minimum_bitpool_value=minimum_bitpool_value,
|
||||
maximum_bitpool_value=maximum_bitpool_value,
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
@@ -343,6 +356,23 @@ class SbcMediaCodecInformation:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
|
||||
allocation_methods = ['SNR', 'Loudness']
|
||||
return '\n'.join(
|
||||
# pylint: disable=line-too-long
|
||||
[
|
||||
'SbcMediaCodecInformation(',
|
||||
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, SBC_SAMPLING_FREQUENCIES)])}',
|
||||
f' channel_mode: {",".join([str(x) for x in flags_to_list(self.channel_mode, channel_modes)])}',
|
||||
f' block_length: {",".join([str(x) for x in flags_to_list(self.block_length, SBC_BLOCK_LENGTHS)])}',
|
||||
f' subbands: {",".join([str(x) for x in flags_to_list(self.subbands, SBC_SUBBANDS)])}',
|
||||
f' allocation_method: {",".join([str(x) for x in flags_to_list(self.allocation_method, allocation_methods)])}',
|
||||
f' minimum_bitpool_value: {self.minimum_bitpool_value}',
|
||||
f' maximum_bitpool_value: {self.maximum_bitpool_value}' ')',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
@@ -351,66 +381,83 @@ class AacMediaCodecInformation:
|
||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
object_type: ObjectType
|
||||
sampling_frequency: SamplingFrequency
|
||||
channels: Channels
|
||||
object_type: int
|
||||
sampling_frequency: int
|
||||
channels: int
|
||||
rfa: int
|
||||
vbr: int
|
||||
bitrate: int
|
||||
|
||||
class ObjectType(enum.IntFlag):
|
||||
MPEG_2_AAC_LC = 1 << 7
|
||||
MPEG_4_AAC_LC = 1 << 6
|
||||
MPEG_4_AAC_LTP = 1 << 5
|
||||
MPEG_4_AAC_SCALABLE = 1 << 4
|
||||
OBJECT_TYPE_BITS = {
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
||||
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
||||
MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5,
|
||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4,
|
||||
}
|
||||
SAMPLING_FREQUENCY_BITS = {
|
||||
8000: 1 << 11,
|
||||
11025: 1 << 10,
|
||||
12000: 1 << 9,
|
||||
16000: 1 << 8,
|
||||
22050: 1 << 7,
|
||||
24000: 1 << 6,
|
||||
32000: 1 << 5,
|
||||
44100: 1 << 4,
|
||||
48000: 1 << 3,
|
||||
64000: 1 << 2,
|
||||
88200: 1 << 1,
|
||||
96000: 1,
|
||||
}
|
||||
CHANNELS_BITS = {1: 1 << 1, 2: 1}
|
||||
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_8000 = 1 << 11
|
||||
SF_11025 = 1 << 10
|
||||
SF_12000 = 1 << 9
|
||||
SF_16000 = 1 << 8
|
||||
SF_22050 = 1 << 7
|
||||
SF_24000 = 1 << 6
|
||||
SF_32000 = 1 << 5
|
||||
SF_44100 = 1 << 4
|
||||
SF_48000 = 1 << 3
|
||||
SF_64000 = 1 << 2
|
||||
SF_88200 = 1 << 1
|
||||
SF_96000 = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, sampling_frequency: int) -> Self:
|
||||
sampling_frequencies = [
|
||||
8000,
|
||||
11025,
|
||||
12000,
|
||||
16000,
|
||||
22050,
|
||||
24000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
64000,
|
||||
88200,
|
||||
96000,
|
||||
]
|
||||
index = sampling_frequencies.index(sampling_frequency)
|
||||
return cls(1 << (len(sampling_frequencies) - index - 1))
|
||||
|
||||
class Channels(enum.IntFlag):
|
||||
MONO = 1 << 1
|
||||
STEREO = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> AacMediaCodecInformation:
|
||||
object_type = cls.ObjectType(data[0])
|
||||
sampling_frequency = cls.SamplingFrequency(
|
||||
(data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||
)
|
||||
channels = cls.Channels((data[2] >> 2) & 0x03)
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> AacMediaCodecInformation:
|
||||
object_type = data[0]
|
||||
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||
channels = (data[2] >> 2) & 0x03
|
||||
rfa = 0
|
||||
vbr = (data[3] >> 7) & 0x01
|
||||
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
|
||||
return AacMediaCodecInformation(
|
||||
object_type, sampling_frequency, channels, vbr, bitrate
|
||||
object_type, sampling_frequency, channels, rfa, vbr, bitrate
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_discrete_values(
|
||||
cls,
|
||||
object_type: int,
|
||||
sampling_frequency: int,
|
||||
channels: int,
|
||||
vbr: int,
|
||||
bitrate: int,
|
||||
) -> AacMediaCodecInformation:
|
||||
return AacMediaCodecInformation(
|
||||
object_type=cls.OBJECT_TYPE_BITS[object_type],
|
||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||
channels=cls.CHANNELS_BITS[channels],
|
||||
rfa=0,
|
||||
vbr=vbr,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_lists(
|
||||
cls,
|
||||
object_types: List[int],
|
||||
sampling_frequencies: List[int],
|
||||
channels: List[int],
|
||||
vbr: int,
|
||||
bitrate: int,
|
||||
) -> AacMediaCodecInformation:
|
||||
return AacMediaCodecInformation(
|
||||
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
|
||||
sampling_frequency=sum(
|
||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||
),
|
||||
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
|
||||
rfa=0,
|
||||
vbr=vbr,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
@@ -425,6 +472,30 @@ class AacMediaCodecInformation:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
object_types = [
|
||||
'MPEG_2_AAC_LC',
|
||||
'MPEG_4_AAC_LC',
|
||||
'MPEG_4_AAC_LTP',
|
||||
'MPEG_4_AAC_SCALABLE',
|
||||
'[4]',
|
||||
'[5]',
|
||||
'[6]',
|
||||
'[7]',
|
||||
]
|
||||
channels = [1, 2]
|
||||
# pylint: disable=line-too-long
|
||||
return '\n'.join(
|
||||
[
|
||||
'AacMediaCodecInformation(',
|
||||
f' object_type: {",".join([str(x) for x in flags_to_list(self.object_type, object_types)])}',
|
||||
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, MPEG_2_4_AAC_SAMPLING_FREQUENCIES)])}',
|
||||
f' channels: {",".join([str(x) for x in flags_to_list(self.channels, channels)])}',
|
||||
f' vbr: {self.vbr}',
|
||||
f' bitrate: {self.bitrate}' ')',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -443,7 +514,7 @@ class VendorSpecificMediaCodecInformation:
|
||||
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return struct.pack('<IH', self.vendor_id, self.codec_id) + self.value
|
||||
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
# pylint: disable=line-too-long
|
||||
@@ -457,69 +528,13 @@ class VendorSpecificMediaCodecInformation:
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class OpusMediaCodecInformation(VendorSpecificMediaCodecInformation):
|
||||
vendor_id: int = dataclasses.field(init=False, repr=False)
|
||||
codec_id: int = dataclasses.field(init=False, repr=False)
|
||||
value: bytes = dataclasses.field(init=False, repr=False)
|
||||
channel_mode: ChannelMode
|
||||
frame_size: FrameSize
|
||||
sampling_frequency: SamplingFrequency
|
||||
|
||||
class ChannelMode(enum.IntFlag):
|
||||
MONO = 1 << 0
|
||||
STEREO = 1 << 1
|
||||
DUAL_MONO = 1 << 2
|
||||
|
||||
class FrameSize(enum.IntFlag):
|
||||
FS_10MS = 1 << 0
|
||||
FS_20MS = 1 << 1
|
||||
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_48000 = 1 << 0
|
||||
|
||||
VENDOR_ID: ClassVar[int] = 0x000000E0
|
||||
CODEC_ID: ClassVar[int] = 0x0001
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vendor_id = self.VENDOR_ID
|
||||
self.codec_id = self.CODEC_ID
|
||||
self.value = bytes(
|
||||
[
|
||||
self.channel_mode
|
||||
| (self.frame_size << 3)
|
||||
| (self.sampling_frequency << 7)
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
"""Create a new instance from the `value` part of the data, not including
|
||||
the vendor id and codec id"""
|
||||
channel_mode = cls.ChannelMode(data[0] & 0x07)
|
||||
frame_size = cls.FrameSize((data[0] >> 3) & 0x03)
|
||||
sampling_frequency = cls.SamplingFrequency((data[0] >> 7) & 0x01)
|
||||
|
||||
return cls(
|
||||
channel_mode,
|
||||
frame_size,
|
||||
sampling_frequency,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return repr(self)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class SbcFrame:
|
||||
sampling_frequency: int
|
||||
block_count: int
|
||||
channel_mode: int
|
||||
allocation_method: int
|
||||
subband_count: int
|
||||
bitpool: int
|
||||
payload: bytes
|
||||
|
||||
@property
|
||||
@@ -538,10 +553,8 @@ class SbcFrame:
|
||||
return (
|
||||
f'SBC(sf={self.sampling_frequency},'
|
||||
f'cm={self.channel_mode},'
|
||||
f'am={self.allocation_method},'
|
||||
f'br={self.bitrate},'
|
||||
f'sc={self.sample_count},'
|
||||
f'bp={self.bitpool},'
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
@@ -570,7 +583,6 @@ class SbcParser:
|
||||
blocks = 4 * (1 + ((header[1] >> 4) & 3))
|
||||
channel_mode = (header[1] >> 2) & 3
|
||||
channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2
|
||||
allocation_method = (header[1] >> 1) & 1
|
||||
subbands = 8 if ((header[1]) & 1) else 4
|
||||
bitpool = header[2]
|
||||
|
||||
@@ -590,13 +602,7 @@ class SbcParser:
|
||||
|
||||
# Emit the next frame
|
||||
yield SbcFrame(
|
||||
sampling_frequency,
|
||||
blocks,
|
||||
channel_mode,
|
||||
allocation_method,
|
||||
subbands,
|
||||
bitpool,
|
||||
payload,
|
||||
sampling_frequency, blocks, channel_mode, subbands, payload
|
||||
)
|
||||
|
||||
return generate_frames()
|
||||
@@ -604,15 +610,21 @@ class SbcParser:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SbcPacketSource:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
def __init__(
|
||||
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
|
||||
) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
self.codec_capabilities = codec_capabilities
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .avdtp import MediaPacket # Import here to avoid a circular reference
|
||||
|
||||
sequence_number = 0
|
||||
sample_count = 0
|
||||
timestamp = 0
|
||||
frames = []
|
||||
frames_size = 0
|
||||
max_rtp_payload = self.mtu - 12 - 1
|
||||
@@ -620,29 +632,29 @@ class SbcPacketSource:
|
||||
# NOTE: this doesn't support frame fragments
|
||||
sbc_parser = SbcParser(self.read)
|
||||
async for frame in sbc_parser.frames:
|
||||
print(frame)
|
||||
|
||||
if (
|
||||
frames_size + len(frame.payload) > max_rtp_payload
|
||||
or len(frames) == SBC_MAX_FRAMES_IN_RTP_PAYLOAD
|
||||
or len(frames) == 16
|
||||
):
|
||||
# Need to flush what has been accumulated so far
|
||||
logger.debug(f"yielding {len(frames)} frames")
|
||||
|
||||
# Emit a packet
|
||||
sbc_payload = bytes([len(frames) & 0x0F]) + b''.join(
|
||||
sbc_payload = bytes([len(frames)]) + b''.join(
|
||||
[frame.payload for frame in frames]
|
||||
)
|
||||
timestamp_seconds = sample_count / frame.sampling_frequency
|
||||
timestamp = int(1000 * timestamp_seconds)
|
||||
packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload
|
||||
)
|
||||
packet.timestamp_seconds = timestamp_seconds
|
||||
packet.timestamp_seconds = timestamp / frame.sampling_frequency
|
||||
yield packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
sample_count += sum((frame.sample_count for frame in frames))
|
||||
timestamp += sum((frame.sample_count for frame in frames))
|
||||
timestamp &= 0xFFFFFFFF
|
||||
frames = [frame]
|
||||
frames_size = len(frame.payload)
|
||||
else:
|
||||
@@ -651,315 +663,3 @@ class SbcPacketSource:
|
||||
frames_size += len(frame.payload)
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class AacFrame:
|
||||
class Profile(enum.IntEnum):
|
||||
MAIN = 0
|
||||
LC = 1
|
||||
SSR = 2
|
||||
LTP = 3
|
||||
|
||||
profile: Profile
|
||||
sampling_frequency: int
|
||||
channel_configuration: int
|
||||
payload: bytes
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return 1024
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.sample_count / self.sampling_frequency
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'AAC(sf={self.sampling_frequency},'
|
||||
f'ch={self.channel_configuration},'
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
ADTS_AAC_SAMPLING_FREQUENCIES = [
|
||||
96000,
|
||||
88200,
|
||||
64000,
|
||||
48000,
|
||||
44100,
|
||||
32000,
|
||||
24000,
|
||||
22050,
|
||||
16000,
|
||||
12000,
|
||||
11025,
|
||||
8000,
|
||||
7350,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacParser:
|
||||
"""Parser for AAC frames in an ADTS stream"""
|
||||
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
|
||||
self.read = read
|
||||
|
||||
@property
|
||||
def frames(self) -> AsyncGenerator[AacFrame, None]:
|
||||
async def generate_frames() -> AsyncGenerator[AacFrame, None]:
|
||||
while True:
|
||||
header = await self.read(7)
|
||||
if not header:
|
||||
return
|
||||
|
||||
sync_word = (header[0] << 4) | (header[1] >> 4)
|
||||
if sync_word != 0b111111111111:
|
||||
raise ValueError(f"invalid sync word ({sync_word:06x})")
|
||||
layer = (header[1] >> 1) & 0b11
|
||||
profile = AacFrame.Profile((header[2] >> 6) & 0b11)
|
||||
sampling_frequency = ADTS_AAC_SAMPLING_FREQUENCIES[
|
||||
(header[2] >> 2) & 0b1111
|
||||
]
|
||||
channel_configuration = ((header[2] & 0b1) << 2) | (header[3] >> 6)
|
||||
frame_length = (
|
||||
((header[3] & 0b11) << 11) | (header[4] << 3) | (header[5] >> 5)
|
||||
)
|
||||
|
||||
if layer != 0:
|
||||
raise ValueError("layer must be 0")
|
||||
|
||||
payload = await self.read(frame_length - 7)
|
||||
if payload:
|
||||
yield AacFrame(
|
||||
profile, sampling_frequency, channel_configuration, payload
|
||||
)
|
||||
|
||||
return generate_frames()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacPacketSource:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
sequence_number = 0
|
||||
sample_count = 0
|
||||
|
||||
aac_parser = AacParser(self.read)
|
||||
async for frame in aac_parser.frames:
|
||||
logger.debug("yielding one AAC frame")
|
||||
|
||||
# Emit a packet
|
||||
aac_payload = bytes(
|
||||
AacAudioRtpPacket.for_simple_aac(
|
||||
frame.sampling_frequency,
|
||||
frame.channel_configuration,
|
||||
frame.payload,
|
||||
)
|
||||
)
|
||||
timestamp_seconds = sample_count / frame.sampling_frequency
|
||||
timestamp = int(1000 * timestamp_seconds)
|
||||
packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, aac_payload
|
||||
)
|
||||
packet.timestamp_seconds = timestamp_seconds
|
||||
yield packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
sample_count += frame.sample_count
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class OpusPacket:
|
||||
class ChannelMode(enum.IntEnum):
|
||||
MONO = 0
|
||||
STEREO = 1
|
||||
DUAL_MONO = 2
|
||||
|
||||
channel_mode: ChannelMode
|
||||
duration: int # Duration in ms.
|
||||
sampling_frequency: int
|
||||
payload: bytes
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Opus(ch={self.channel_mode.name}, '
|
||||
f'd={self.duration}ms, '
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OpusParser:
|
||||
"""
|
||||
Parser for Opus packets in an Ogg stream
|
||||
|
||||
See RFC 3533
|
||||
|
||||
NOTE: this parser only supports bitstreams with a single logical stream.
|
||||
"""
|
||||
|
||||
CAPTURE_PATTERN = b'OggS'
|
||||
|
||||
class HeaderType(enum.IntFlag):
|
||||
CONTINUED = 0x01
|
||||
FIRST = 0x02
|
||||
LAST = 0x04
|
||||
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
|
||||
self.read = read
|
||||
|
||||
@property
|
||||
def packets(self) -> AsyncGenerator[OpusPacket, None]:
|
||||
async def generate_frames() -> AsyncGenerator[OpusPacket, None]:
|
||||
packet = b''
|
||||
packet_count = 0
|
||||
expected_bitstream_serial_number = None
|
||||
expected_page_sequence_number = 0
|
||||
channel_mode = OpusPacket.ChannelMode.STEREO
|
||||
|
||||
while True:
|
||||
# Parse the page header
|
||||
header = await self.read(27)
|
||||
if len(header) != 27:
|
||||
logger.debug("end of stream")
|
||||
break
|
||||
|
||||
capture_pattern = header[:4]
|
||||
if capture_pattern != self.CAPTURE_PATTERN:
|
||||
print(capture_pattern.hex())
|
||||
raise ValueError("invalid capture pattern at start of page")
|
||||
|
||||
version = header[4]
|
||||
if version != 0:
|
||||
raise ValueError(f"version {version} not supported")
|
||||
|
||||
header_type = self.HeaderType(header[5])
|
||||
(
|
||||
granule_position,
|
||||
bitstream_serial_number,
|
||||
page_sequence_number,
|
||||
crc_checksum,
|
||||
page_segments,
|
||||
) = struct.unpack_from("<QIIIB", header, 6)
|
||||
segment_table = await self.read(page_segments)
|
||||
|
||||
if header_type & self.HeaderType.FIRST:
|
||||
if expected_bitstream_serial_number is None:
|
||||
# We will only accept pages for the first encountered stream
|
||||
logger.debug("BOS")
|
||||
expected_bitstream_serial_number = bitstream_serial_number
|
||||
expected_page_sequence_number = page_sequence_number
|
||||
|
||||
if (
|
||||
expected_bitstream_serial_number is None
|
||||
or expected_bitstream_serial_number != bitstream_serial_number
|
||||
):
|
||||
logger.debug("skipping page (not the first logical bitstream)")
|
||||
for lacing_value in segment_table:
|
||||
if lacing_value:
|
||||
await self.read(lacing_value)
|
||||
continue
|
||||
|
||||
if expected_page_sequence_number != page_sequence_number:
|
||||
raise ValueError(
|
||||
f"expected page sequence number {expected_page_sequence_number}"
|
||||
f" but got {page_sequence_number}"
|
||||
)
|
||||
expected_page_sequence_number = page_sequence_number + 1
|
||||
|
||||
# Assemble the page
|
||||
if not header_type & self.HeaderType.CONTINUED:
|
||||
packet = b''
|
||||
for lacing_value in segment_table:
|
||||
if lacing_value:
|
||||
packet += await self.read(lacing_value)
|
||||
if lacing_value < 255:
|
||||
# End of packet
|
||||
packet_count += 1
|
||||
|
||||
if packet_count == 1:
|
||||
# The first packet contains the identification header
|
||||
logger.debug("first packet (header)")
|
||||
if packet[:8] != b"OpusHead":
|
||||
raise ValueError("first packet is not OpusHead")
|
||||
packet_count = (
|
||||
OpusPacket.ChannelMode.MONO
|
||||
if packet[9] == 1
|
||||
else OpusPacket.ChannelMode.STEREO
|
||||
)
|
||||
|
||||
elif packet_count == 2:
|
||||
# The second packet contains the comment header
|
||||
logger.debug("second packet (tags)")
|
||||
if packet[:8] != b"OpusTags":
|
||||
logger.warning("second packet is not OpusTags")
|
||||
else:
|
||||
yield OpusPacket(channel_mode, 20, 48000, packet)
|
||||
|
||||
packet = b''
|
||||
|
||||
if header_type & self.HeaderType.LAST:
|
||||
logger.debug("EOS")
|
||||
|
||||
return generate_frames()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OpusPacketSource:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
sequence_number = 0
|
||||
elapsed_ms = 0
|
||||
|
||||
opus_parser = OpusParser(self.read)
|
||||
async for opus_packet in opus_parser.packets:
|
||||
# We only support sending one Opus frame per RTP packet
|
||||
# TODO: check the spec for the first byte value here
|
||||
opus_payload = bytes([1]) + opus_packet.payload
|
||||
elapsed_s = elapsed_ms / 1000
|
||||
timestamp = int(elapsed_s * opus_packet.sampling_frequency)
|
||||
rtp_packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, opus_payload
|
||||
)
|
||||
rtp_packet.timestamp_seconds = elapsed_s
|
||||
yield rtp_packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
elapsed_ms += opus_packet.duration
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# This map should be left at the end of the file so it can refer to the classes
|
||||
# above
|
||||
# -----------------------------------------------------------------------------
|
||||
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES = {
|
||||
OpusMediaCodecInformation.VENDOR_ID: {
|
||||
OpusMediaCodecInformation.CODEC_ID: OpusMediaCodecInformation
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -710,7 +710,7 @@ class ATT_Prepare_Write_Response(ATT_PDU):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@ATT_PDU.subclass([("flags", 1)])
|
||||
@ATT_PDU.subclass([])
|
||||
class ATT_Execute_Write_Request(ATT_PDU):
|
||||
'''
|
||||
See Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Execute Write Request
|
||||
|
||||
+2
-1
@@ -119,7 +119,7 @@ class Frame:
|
||||
# Not supported
|
||||
raise NotImplementedError("extended subunit types not supported")
|
||||
|
||||
if subunit_id < 5 or subunit_id == 7:
|
||||
if subunit_id < 5:
|
||||
opcode_offset = 2
|
||||
elif subunit_id == 5:
|
||||
# Extended to the next byte
|
||||
@@ -132,6 +132,7 @@ class Frame:
|
||||
else:
|
||||
subunit_id = 5 + extension
|
||||
opcode_offset = 3
|
||||
|
||||
elif subunit_id == 6:
|
||||
raise core.InvalidPacketError("reserved subunit ID")
|
||||
|
||||
|
||||
+97
-54
@@ -17,10 +17,12 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
import enum
|
||||
import warnings
|
||||
from pyee import EventEmitter
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
@@ -37,8 +39,6 @@ from typing import (
|
||||
cast,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .core import (
|
||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||
InvalidStateError,
|
||||
@@ -51,16 +51,13 @@ from .a2dp import (
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
A2DP_NON_A2DP_CODEC_TYPE,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES,
|
||||
AacMediaCodecInformation,
|
||||
SbcMediaCodecInformation,
|
||||
VendorSpecificMediaCodecInformation,
|
||||
)
|
||||
from .rtp import MediaPacket
|
||||
from . import sdp, device, l2cap
|
||||
from .colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -281,6 +278,95 @@ class RealtimeClock:
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacket:
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> MediaPacket:
|
||||
version = (data[0] >> 6) & 0x03
|
||||
padding = (data[0] >> 5) & 0x01
|
||||
extension = (data[0] >> 4) & 0x01
|
||||
csrc_count = data[0] & 0x0F
|
||||
marker = (data[1] >> 7) & 0x01
|
||||
payload_type = data[1] & 0x7F
|
||||
sequence_number = struct.unpack_from('>H', data, 2)[0]
|
||||
timestamp = struct.unpack_from('>I', data, 4)[0]
|
||||
ssrc = struct.unpack_from('>I', data, 8)[0]
|
||||
csrc_list = [
|
||||
struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count)
|
||||
]
|
||||
payload = data[12 + csrc_count * 4 :]
|
||||
|
||||
return MediaPacket(
|
||||
version,
|
||||
padding,
|
||||
extension,
|
||||
marker,
|
||||
sequence_number,
|
||||
timestamp,
|
||||
ssrc,
|
||||
csrc_list,
|
||||
payload_type,
|
||||
payload,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: int,
|
||||
padding: int,
|
||||
extension: int,
|
||||
marker: int,
|
||||
sequence_number: int,
|
||||
timestamp: int,
|
||||
ssrc: int,
|
||||
csrc_list: List[int],
|
||||
payload_type: int,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.padding = padding
|
||||
self.extension = extension
|
||||
self.marker = marker
|
||||
self.sequence_number = sequence_number & 0xFFFF
|
||||
self.timestamp = timestamp & 0xFFFFFFFF
|
||||
self.ssrc = ssrc
|
||||
self.csrc_list = csrc_list
|
||||
self.payload_type = payload_type
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
header = bytes(
|
||||
[
|
||||
self.version << 6
|
||||
| self.padding << 5
|
||||
| self.extension << 4
|
||||
| len(self.csrc_list),
|
||||
self.marker << 7 | self.payload_type,
|
||||
]
|
||||
) + struct.pack(
|
||||
'>HII',
|
||||
self.sequence_number,
|
||||
self.timestamp,
|
||||
self.ssrc,
|
||||
)
|
||||
for csrc in self.csrc_list:
|
||||
header += struct.pack('>I', csrc)
|
||||
return header + self.payload
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'RTP(v={self.version},'
|
||||
f'p={self.padding},'
|
||||
f'x={self.extension},'
|
||||
f'm={self.marker},'
|
||||
f'pt={self.payload_type},'
|
||||
f'sn={self.sequence_number},'
|
||||
f'ts={self.timestamp},'
|
||||
f'ssrc={self.ssrc},'
|
||||
f'csrcs={self.csrc_list},'
|
||||
f'payload_size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacketPump:
|
||||
pump_task: Optional[asyncio.Task]
|
||||
@@ -291,7 +377,6 @@ class MediaPacketPump:
|
||||
self.packets = packets
|
||||
self.clock = clock
|
||||
self.pump_task = None
|
||||
self.completed = asyncio.Event()
|
||||
|
||||
async def start(self, rtp_channel: l2cap.ClassicChannel) -> None:
|
||||
async def pump_packets():
|
||||
@@ -321,8 +406,6 @@ class MediaPacketPump:
|
||||
)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
logger.debug('pump canceled')
|
||||
finally:
|
||||
self.completed.set()
|
||||
|
||||
# Pump packets
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
@@ -334,9 +417,6 @@ class MediaPacketPump:
|
||||
await self.pump_task
|
||||
self.pump_task = None
|
||||
|
||||
async def wait_for_completion(self) -> None:
|
||||
await self.completed.wait()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MessageAssembler:
|
||||
@@ -535,25 +615,11 @@ class MediaCodecCapabilities(ServiceCapabilities):
|
||||
self.media_codec_information
|
||||
)
|
||||
elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE:
|
||||
vendor_media_codec_information = (
|
||||
self.media_codec_information = (
|
||||
VendorSpecificMediaCodecInformation.from_bytes(
|
||||
self.media_codec_information
|
||||
)
|
||||
)
|
||||
if (
|
||||
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
|
||||
vendor_media_codec_information.vendor_id
|
||||
)
|
||||
) and (
|
||||
media_codec_information_class := vendor_class_map.get(
|
||||
vendor_media_codec_information.codec_id
|
||||
)
|
||||
):
|
||||
self.media_codec_information = media_codec_information_class.from_bytes(
|
||||
vendor_media_codec_information.value
|
||||
)
|
||||
else:
|
||||
self.media_codec_information = vendor_media_codec_information
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1250,20 +1316,10 @@ class Protocol(EventEmitter):
|
||||
return None
|
||||
|
||||
def add_source(
|
||||
self,
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
packet_pump: MediaPacketPump,
|
||||
delay_reporting: bool = False,
|
||||
self, codec_capabilities: MediaCodecCapabilities, packet_pump: MediaPacketPump
|
||||
) -> LocalSource:
|
||||
seid = len(self.local_endpoints) + 1
|
||||
service_capabilities = (
|
||||
[ServiceCapabilities(AVDTP_DELAY_REPORTING_SERVICE_CATEGORY)]
|
||||
if delay_reporting
|
||||
else []
|
||||
)
|
||||
source = LocalSource(
|
||||
self, seid, codec_capabilities, service_capabilities, packet_pump
|
||||
)
|
||||
source = LocalSource(self, seid, codec_capabilities, packet_pump)
|
||||
self.local_endpoints.append(source)
|
||||
|
||||
return source
|
||||
@@ -1316,7 +1372,7 @@ class Protocol(EventEmitter):
|
||||
return self.remote_endpoints.values()
|
||||
|
||||
def find_remote_sink_by_codec(
|
||||
self, media_type: int, codec_type: int, vendor_id: int = 0, codec_id: int = 0
|
||||
self, media_type: int, codec_type: int
|
||||
) -> Optional[DiscoveredStreamEndPoint]:
|
||||
for endpoint in self.remote_endpoints.values():
|
||||
if (
|
||||
@@ -1341,19 +1397,7 @@ class Protocol(EventEmitter):
|
||||
codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE
|
||||
and codec_capabilities.media_codec_type == codec_type
|
||||
):
|
||||
if isinstance(
|
||||
codec_capabilities.media_codec_information,
|
||||
VendorSpecificMediaCodecInformation,
|
||||
):
|
||||
if (
|
||||
codec_capabilities.media_codec_information.vendor_id
|
||||
== vendor_id
|
||||
and codec_capabilities.media_codec_information.codec_id
|
||||
== codec_id
|
||||
):
|
||||
has_codec = True
|
||||
else:
|
||||
has_codec = True
|
||||
has_codec = True
|
||||
if has_media_transport and has_codec:
|
||||
return endpoint
|
||||
|
||||
@@ -2136,13 +2180,12 @@ class LocalSource(LocalStreamEndPoint):
|
||||
protocol: Protocol,
|
||||
seid: int,
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
other_capabilitiles: Iterable[ServiceCapabilities],
|
||||
packet_pump: MediaPacketPump,
|
||||
) -> None:
|
||||
capabilities = [
|
||||
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
||||
codec_capabilities,
|
||||
] + list(other_capabilitiles)
|
||||
]
|
||||
super().__init__(
|
||||
protocol,
|
||||
seid,
|
||||
|
||||
+29
-48
@@ -1491,14 +1491,10 @@ class Protocol(pyee.EventEmitter):
|
||||
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
|
||||
)
|
||||
|
||||
# Only addressing the unit, or the PANEL subunit with subunit ID 0 is supported
|
||||
# in this profile.
|
||||
if not (
|
||||
command.subunit_type == avc.Frame.SubunitType.UNIT
|
||||
and command.subunit_id == 7
|
||||
) and not (
|
||||
command.subunit_type == avc.Frame.SubunitType.PANEL
|
||||
and command.subunit_id == 0
|
||||
# Only the PANEL subunit type with subunit ID 0 is supported in this profile.
|
||||
if (
|
||||
command.subunit_type != avc.Frame.SubunitType.PANEL
|
||||
or command.subunit_id != 0
|
||||
):
|
||||
logger.debug("subunit not supported")
|
||||
self.send_not_implemented_response(transaction_label, command)
|
||||
@@ -1532,8 +1528,8 @@ class Protocol(pyee.EventEmitter):
|
||||
# TODO: delegate
|
||||
response = avc.PassThroughResponseFrame(
|
||||
avc.ResponseFrame.ResponseCode.ACCEPTED,
|
||||
command.subunit_type,
|
||||
command.subunit_id,
|
||||
avc.Frame.SubunitType.PANEL,
|
||||
0,
|
||||
command.state_flag,
|
||||
command.operation_id,
|
||||
command.operation_data,
|
||||
@@ -1850,15 +1846,6 @@ class Protocol(pyee.EventEmitter):
|
||||
RejectedResponse(pdu_id, status_code),
|
||||
)
|
||||
|
||||
def send_not_implemented_avrcp_response(
|
||||
self, transaction_label: int, pdu_id: Protocol.PduId
|
||||
) -> None:
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED,
|
||||
NotImplementedResponse(pdu_id, b''),
|
||||
)
|
||||
|
||||
def _on_get_capabilities_command(
|
||||
self, transaction_label: int, command: GetCapabilitiesCommand
|
||||
) -> None:
|
||||
@@ -1904,35 +1891,29 @@ class Protocol(pyee.EventEmitter):
|
||||
async def register_notification():
|
||||
# Check if the event is supported.
|
||||
supported_events = await self.delegate.get_supported_events()
|
||||
if command.event_id not in supported_events:
|
||||
logger.debug("event not supported")
|
||||
self.send_not_implemented_avrcp_response(
|
||||
transaction_label, self.PduId.REGISTER_NOTIFICATION
|
||||
)
|
||||
return
|
||||
if command.event_id in supported_events:
|
||||
if command.event_id == EventId.VOLUME_CHANGED:
|
||||
volume = await self.delegate.get_absolute_volume()
|
||||
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
|
||||
if command.event_id == EventId.VOLUME_CHANGED:
|
||||
volume = await self.delegate.get_absolute_volume()
|
||||
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
|
||||
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
|
||||
# TODO: testing only, use delegate
|
||||
response = RegisterNotificationResponse(
|
||||
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
|
||||
)
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
|
||||
# TODO: testing only, use delegate
|
||||
response = RegisterNotificationResponse(
|
||||
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
|
||||
)
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
|
||||
self._delegate_command(transaction_label, command, register_notification())
|
||||
|
||||
+68
-214
@@ -17,7 +17,6 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core
|
||||
|
||||
@@ -102,40 +101,12 @@ class BitReader:
|
||||
break
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class BitWriter:
|
||||
"""Simple but not optimized bit stream writer."""
|
||||
|
||||
data: int
|
||||
bit_count: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data = 0
|
||||
self.bit_count = 0
|
||||
|
||||
def write(self, value: int, bit_count: int) -> None:
|
||||
self.data = (self.data << bit_count) | value
|
||||
self.bit_count += bit_count
|
||||
|
||||
def write_bytes(self, data: bytes) -> None:
|
||||
bit_count = 8 * len(data)
|
||||
self.data = (self.data << bit_count) | int.from_bytes(data, 'big')
|
||||
self.bit_count += bit_count
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return (self.data << ((8 - (self.bit_count % 8)) % 8)).to_bytes(
|
||||
(self.bit_count + 7) // 8, 'big'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioRtpPacket:
|
||||
"""AAC payload encapsulated in an RTP packet payload"""
|
||||
|
||||
audio_mux_element: AudioMuxElement
|
||||
|
||||
@staticmethod
|
||||
def read_latm_value(reader: BitReader) -> int:
|
||||
def latm_value(reader: BitReader) -> int:
|
||||
bytes_for_value = reader.read(2)
|
||||
value = 0
|
||||
for _ in range(bytes_for_value + 1):
|
||||
@@ -143,33 +114,24 @@ class AacAudioRtpPacket:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def read_audio_object_type(reader: BitReader):
|
||||
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
|
||||
audio_object_type = reader.read(5)
|
||||
if audio_object_type == 31:
|
||||
audio_object_type = 32 + reader.read(6)
|
||||
|
||||
return audio_object_type
|
||||
def program_config_element(reader: BitReader):
|
||||
raise core.InvalidPacketError('program_config_element not supported')
|
||||
|
||||
@dataclass
|
||||
class GASpecificConfig:
|
||||
audio_object_type: int
|
||||
# NOTE: other fields not supported
|
||||
|
||||
@classmethod
|
||||
def from_bits(
|
||||
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||
) -> Self:
|
||||
def __init__(
|
||||
self, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||
) -> None:
|
||||
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
||||
frame_length_flag = reader.read(1)
|
||||
depends_on_core_coder = reader.read(1)
|
||||
if depends_on_core_coder:
|
||||
core_coder_delay = reader.read(14)
|
||||
self.core_coder_delay = reader.read(14)
|
||||
extension_flag = reader.read(1)
|
||||
if not channel_configuration:
|
||||
raise core.InvalidPacketError('program_config_element not supported')
|
||||
AacAudioRtpPacket.program_config_element(reader)
|
||||
if audio_object_type in (6, 20):
|
||||
layer_nr = reader.read(3)
|
||||
self.layer_nr = reader.read(3)
|
||||
if extension_flag:
|
||||
if audio_object_type == 22:
|
||||
num_of_sub_frame = reader.read(5)
|
||||
@@ -182,13 +144,14 @@ class AacAudioRtpPacket:
|
||||
if extension_flag_3 == 1:
|
||||
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||
|
||||
return cls(audio_object_type)
|
||||
@staticmethod
|
||||
def audio_object_type(reader: BitReader):
|
||||
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
|
||||
audio_object_type = reader.read(5)
|
||||
if audio_object_type == 31:
|
||||
audio_object_type = 32 + reader.read(6)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
assert self.audio_object_type in (1, 2)
|
||||
writer.write(0, 1) # frame_length_flag = 0
|
||||
writer.write(0, 1) # depends_on_core_coder = 0
|
||||
writer.write(0, 1) # extension_flag = 0
|
||||
return audio_object_type
|
||||
|
||||
@dataclass
|
||||
class AudioSpecificConfig:
|
||||
@@ -196,7 +159,6 @@ class AacAudioRtpPacket:
|
||||
sampling_frequency_index: int
|
||||
sampling_frequency: int
|
||||
channel_configuration: int
|
||||
ga_specific_config: AacAudioRtpPacket.GASpecificConfig
|
||||
sbr_present_flag: int
|
||||
ps_present_flag: int
|
||||
extension_audio_object_type: int
|
||||
@@ -220,73 +182,44 @@ class AacAudioRtpPacket:
|
||||
7350,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_simple_aac(
|
||||
cls,
|
||||
audio_object_type: int,
|
||||
sampling_frequency: int,
|
||||
channel_configuration: int,
|
||||
) -> Self:
|
||||
if sampling_frequency not in cls.SAMPLING_FREQUENCIES:
|
||||
raise ValueError(f'invalid sampling frequency {sampling_frequency}')
|
||||
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(audio_object_type)
|
||||
|
||||
return cls(
|
||||
audio_object_type=audio_object_type,
|
||||
sampling_frequency_index=cls.SAMPLING_FREQUENCIES.index(
|
||||
sampling_frequency
|
||||
),
|
||||
sampling_frequency=sampling_frequency,
|
||||
channel_configuration=channel_configuration,
|
||||
ga_specific_config=ga_specific_config,
|
||||
sbr_present_flag=0,
|
||||
ps_present_flag=0,
|
||||
extension_audio_object_type=0,
|
||||
extension_sampling_frequency_index=0,
|
||||
extension_sampling_frequency=0,
|
||||
extension_channel_configuration=0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
def __init__(self, reader: BitReader) -> None:
|
||||
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
|
||||
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
|
||||
sampling_frequency_index = reader.read(4)
|
||||
if sampling_frequency_index == 0xF:
|
||||
sampling_frequency = reader.read(24)
|
||||
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||
self.sampling_frequency_index = reader.read(4)
|
||||
if self.sampling_frequency_index == 0xF:
|
||||
self.sampling_frequency = reader.read(24)
|
||||
else:
|
||||
sampling_frequency = cls.SAMPLING_FREQUENCIES[sampling_frequency_index]
|
||||
channel_configuration = reader.read(4)
|
||||
sbr_present_flag = 0
|
||||
ps_present_flag = 0
|
||||
extension_sampling_frequency_index = 0
|
||||
extension_sampling_frequency = 0
|
||||
extension_channel_configuration = 0
|
||||
extension_audio_object_type = 0
|
||||
if audio_object_type in (5, 29):
|
||||
extension_audio_object_type = 5
|
||||
sbr_present_flag = 1
|
||||
if audio_object_type == 29:
|
||||
ps_present_flag = 1
|
||||
extension_sampling_frequency_index = reader.read(4)
|
||||
if extension_sampling_frequency_index == 0xF:
|
||||
extension_sampling_frequency = reader.read(24)
|
||||
self.sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||
self.sampling_frequency_index
|
||||
]
|
||||
self.channel_configuration = reader.read(4)
|
||||
self.sbr_present_flag = -1
|
||||
self.ps_present_flag = -1
|
||||
if self.audio_object_type in (5, 29):
|
||||
self.extension_audio_object_type = 5
|
||||
self.sbc_present_flag = 1
|
||||
if self.audio_object_type == 29:
|
||||
self.ps_present_flag = 1
|
||||
self.extension_sampling_frequency_index = reader.read(4)
|
||||
if self.extension_sampling_frequency_index == 0xF:
|
||||
self.extension_sampling_frequency = reader.read(24)
|
||||
else:
|
||||
extension_sampling_frequency = cls.SAMPLING_FREQUENCIES[
|
||||
extension_sampling_frequency_index
|
||||
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||
self.extension_sampling_frequency_index
|
||||
]
|
||||
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
|
||||
if audio_object_type == 22:
|
||||
extension_channel_configuration = reader.read(4)
|
||||
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||
if self.audio_object_type == 22:
|
||||
self.extension_channel_configuration = reader.read(4)
|
||||
else:
|
||||
self.extension_audio_object_type = 0
|
||||
|
||||
if audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig.from_bits(
|
||||
reader, channel_configuration, audio_object_type
|
||||
if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
|
||||
reader, self.channel_configuration, self.audio_object_type
|
||||
)
|
||||
else:
|
||||
raise core.InvalidPacketError(
|
||||
f'audioObjectType {audio_object_type} not supported'
|
||||
f'audioObjectType {self.audio_object_type} not supported'
|
||||
)
|
||||
|
||||
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
|
||||
@@ -315,44 +248,13 @@ class AacAudioRtpPacket:
|
||||
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
||||
# self.extension_channel_configuration = reader.read(4)
|
||||
|
||||
return cls(
|
||||
audio_object_type,
|
||||
sampling_frequency_index,
|
||||
sampling_frequency,
|
||||
channel_configuration,
|
||||
ga_specific_config,
|
||||
sbr_present_flag,
|
||||
ps_present_flag,
|
||||
extension_audio_object_type,
|
||||
extension_sampling_frequency_index,
|
||||
extension_sampling_frequency,
|
||||
extension_channel_configuration,
|
||||
)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
if self.sampling_frequency_index >= 15:
|
||||
raise ValueError(
|
||||
f"unsupported sampling frequency index {self.sampling_frequency_index}"
|
||||
)
|
||||
|
||||
if self.audio_object_type not in (1, 2):
|
||||
raise ValueError(
|
||||
f"unsupported audio object type {self.audio_object_type} "
|
||||
)
|
||||
|
||||
writer.write(self.audio_object_type, 5)
|
||||
writer.write(self.sampling_frequency_index, 4)
|
||||
writer.write(self.channel_configuration, 4)
|
||||
self.ga_specific_config.to_bits(writer)
|
||||
|
||||
@dataclass
|
||||
class StreamMuxConfig:
|
||||
other_data_present: int
|
||||
other_data_len_bits: int
|
||||
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
|
||||
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
def __init__(self, reader: BitReader) -> None:
|
||||
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
|
||||
audio_mux_version = reader.read(1)
|
||||
if audio_mux_version == 1:
|
||||
@@ -362,7 +264,7 @@ class AacAudioRtpPacket:
|
||||
if audio_mux_version_a != 0:
|
||||
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||
if audio_mux_version == 1:
|
||||
tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader)
|
||||
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
||||
stream_cnt = 0
|
||||
all_streams_same_time_framing = reader.read(1)
|
||||
num_sub_frames = reader.read(6)
|
||||
@@ -373,13 +275,13 @@ class AacAudioRtpPacket:
|
||||
if num_layer != 0:
|
||||
raise core.InvalidPacketError('num_layer != 0 not supported')
|
||||
if audio_mux_version == 0:
|
||||
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||
reader
|
||||
)
|
||||
else:
|
||||
asc_len = AacAudioRtpPacket.read_latm_value(reader)
|
||||
asc_len = AacAudioRtpPacket.latm_value(reader)
|
||||
marker = reader.bit_position
|
||||
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||
reader
|
||||
)
|
||||
audio_specific_config_len = reader.bit_position - marker
|
||||
@@ -397,49 +299,36 @@ class AacAudioRtpPacket:
|
||||
f'frame_length_type {frame_length_type} not supported'
|
||||
)
|
||||
|
||||
other_data_present = reader.read(1)
|
||||
other_data_len_bits = 0
|
||||
if other_data_present:
|
||||
self.other_data_present = reader.read(1)
|
||||
if self.other_data_present:
|
||||
if audio_mux_version == 1:
|
||||
other_data_len_bits = AacAudioRtpPacket.read_latm_value(reader)
|
||||
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
|
||||
else:
|
||||
self.other_data_len_bits = 0
|
||||
while True:
|
||||
other_data_len_bits *= 256
|
||||
self.other_data_len_bits *= 256
|
||||
other_data_len_esc = reader.read(1)
|
||||
other_data_len_bits += reader.read(8)
|
||||
self.other_data_len_bits += reader.read(8)
|
||||
if other_data_len_esc == 0:
|
||||
break
|
||||
crc_check_present = reader.read(1)
|
||||
if crc_check_present:
|
||||
crc_checksum = reader.read(8)
|
||||
|
||||
return cls(other_data_present, other_data_len_bits, audio_specific_config)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
writer.write(0, 1) # audioMuxVersion = 0
|
||||
writer.write(1, 1) # allStreamsSameTimeFraming = 1
|
||||
writer.write(0, 6) # numSubFrames = 0
|
||||
writer.write(0, 4) # numProgram = 0
|
||||
writer.write(0, 3) # numLayer = 0
|
||||
self.audio_specific_config.to_bits(writer)
|
||||
writer.write(0, 3) # frameLengthType = 0
|
||||
writer.write(0, 8) # latmBufferFullness = 0
|
||||
writer.write(0, 1) # otherDataPresent = 0
|
||||
writer.write(0, 1) # crcCheckPresent = 0
|
||||
|
||||
@dataclass
|
||||
class AudioMuxElement:
|
||||
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
||||
payload: bytes
|
||||
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
||||
|
||||
def __init__(self, reader: BitReader, mux_config_present: int):
|
||||
if mux_config_present == 0:
|
||||
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
|
||||
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||
# (only supports mux_config_present=1)
|
||||
use_same_stream_mux = reader.read(1)
|
||||
if use_same_stream_mux:
|
||||
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
||||
stream_mux_config = AacAudioRtpPacket.StreamMuxConfig.from_bits(reader)
|
||||
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
||||
|
||||
# We only support:
|
||||
# allStreamsSameTimeFraming == 1
|
||||
@@ -455,46 +344,19 @@ class AacAudioRtpPacket:
|
||||
if tmp != 255:
|
||||
break
|
||||
|
||||
payload = reader.read_bytes(mux_slot_length_bytes)
|
||||
self.payload = reader.read_bytes(mux_slot_length_bytes)
|
||||
|
||||
if stream_mux_config.other_data_present:
|
||||
reader.skip(stream_mux_config.other_data_len_bits)
|
||||
if self.stream_mux_config.other_data_present:
|
||||
reader.skip(self.stream_mux_config.other_data_len_bits)
|
||||
|
||||
# ByteAlign
|
||||
while reader.bit_position % 8:
|
||||
reader.read(1)
|
||||
|
||||
return cls(stream_mux_config, payload)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
writer.write(0, 1) # useSameStreamMux = 0
|
||||
self.stream_mux_config.to_bits(writer)
|
||||
mux_slot_length_bytes = len(self.payload)
|
||||
while mux_slot_length_bytes > 255:
|
||||
writer.write(255, 8)
|
||||
mux_slot_length_bytes -= 255
|
||||
writer.write(mux_slot_length_bytes, 8)
|
||||
if mux_slot_length_bytes == 255:
|
||||
writer.write(0, 8)
|
||||
writer.write_bytes(self.payload)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
def __init__(self, data: bytes) -> None:
|
||||
# Parse the bit stream
|
||||
reader = BitReader(data)
|
||||
return cls(cls.AudioMuxElement.from_bits(reader))
|
||||
|
||||
@classmethod
|
||||
def for_simple_aac(
|
||||
cls, sampling_frequency: int, channel_configuration: int, payload: bytes
|
||||
) -> Self:
|
||||
audio_specific_config = cls.AudioSpecificConfig.for_simple_aac(
|
||||
2, sampling_frequency, channel_configuration
|
||||
)
|
||||
stream_mux_config = cls.StreamMuxConfig(0, 0, audio_specific_config)
|
||||
audio_mux_element = cls.AudioMuxElement(stream_mux_config, payload)
|
||||
|
||||
return cls(audio_mux_element)
|
||||
self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
|
||||
|
||||
def to_adts(self):
|
||||
# pylint: disable=line-too-long
|
||||
@@ -521,11 +383,3 @@ class AacAudioRtpPacket:
|
||||
)
|
||||
+ self.audio_mux_element.payload
|
||||
)
|
||||
|
||||
def __init__(self, audio_mux_element: AudioMuxElement) -> None:
|
||||
self.audio_mux_element = audio_mux_element
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
writer = BitWriter()
|
||||
self.audio_mux_element.to_bits(writer)
|
||||
return bytes(writer)
|
||||
|
||||
+11
-19
@@ -1571,22 +1571,14 @@ class Connection(CompositeEventEmitter):
|
||||
raise
|
||||
|
||||
def __str__(self):
|
||||
if self.transport == BT_LE_TRANSPORT:
|
||||
return (
|
||||
f'Connection(transport=LE, handle=0x{self.handle:04X}, '
|
||||
f'role={self.role_name}, '
|
||||
f'self_address={self.self_address}, '
|
||||
f'self_resolvable_address={self.self_resolvable_address}, '
|
||||
f'peer_address={self.peer_address}, '
|
||||
f'peer_resolvable_address={self.peer_resolvable_address})'
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f'Connection(transport=BR/EDR, handle=0x{self.handle:04X}, '
|
||||
f'role={self.role_name}, '
|
||||
f'self_address={self.self_address}, '
|
||||
f'peer_address={self.peer_address})'
|
||||
)
|
||||
return (
|
||||
f'Connection(handle=0x{self.handle:04X}, '
|
||||
f'role={self.role_name}, '
|
||||
f'self_address={self.self_address}, '
|
||||
f'self_resolvable_address={self.self_resolvable_address}, '
|
||||
f'peer_address={self.peer_address}, '
|
||||
f'peer_resolvable_address={self.peer_resolvable_address})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1774,9 +1766,9 @@ device_host_event_handlers: List[str] = []
|
||||
# -----------------------------------------------------------------------------
|
||||
class Device(CompositeEventEmitter):
|
||||
# Incomplete list of fields.
|
||||
random_address: Address # Random private address that may change periodically
|
||||
public_address: Address # Public address that is globally unique (from controller)
|
||||
static_address: Address # Random static address that does not change once set
|
||||
random_address: Address # Random address that may change with RPA
|
||||
public_address: Address # Public address (obtained from the controller)
|
||||
static_address: Address # Random address that can be set but does not change
|
||||
classic_enabled: bool
|
||||
name: str
|
||||
class_of_device: int
|
||||
|
||||
@@ -6065,32 +6065,6 @@ class HCI_Read_Remote_Version_Information_Complete_Event(HCI_Event):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Event.event(
|
||||
[
|
||||
('status', STATUS_SPEC),
|
||||
('connection_handle', 2),
|
||||
('unused', 1),
|
||||
(
|
||||
'service_type',
|
||||
{
|
||||
'size': 1,
|
||||
'mapper': lambda x: HCI_QOS_Setup_Complete_Event.ServiceType(x).name,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
class HCI_QOS_Setup_Complete_Event(HCI_Event):
|
||||
'''
|
||||
See Bluetooth spec @ 7.7.13 QoS Setup Complete Event
|
||||
'''
|
||||
|
||||
class ServiceType(OpenIntEnum):
|
||||
NO_TRAFFIC_AVAILABLE = 0x00
|
||||
BEST_EFFORT_AVAILABLE = 0x01
|
||||
GUARANTEED_AVAILABLE = 0x02
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Event.event(
|
||||
[
|
||||
|
||||
+41
-45
@@ -795,32 +795,29 @@ class HfProtocol(pyee.EventEmitter):
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
|
||||
while self.read_buffer:
|
||||
# Locate header and trailer.
|
||||
header = self.read_buffer.find(b'\r\n')
|
||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||
if header == -1 or trailer == -1:
|
||||
return
|
||||
# Locate header and trailer.
|
||||
header = self.read_buffer.find(b'\r\n')
|
||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||
if header == -1 or trailer == -1:
|
||||
return
|
||||
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_response = self.read_buffer[header + 2 : trailer]
|
||||
response = AtResponse.parse_from(raw_response)
|
||||
logger.debug(f"<<< {raw_response.decode()}")
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_response = self.read_buffer[header + 2 : trailer]
|
||||
response = AtResponse.parse_from(raw_response)
|
||||
logger.debug(f"<<< {raw_response.decode()}")
|
||||
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||
|
||||
# Forward the received code to the correct queue.
|
||||
if self.command_lock.locked() and (
|
||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||
):
|
||||
self.response_queue.put_nowait(response)
|
||||
elif response.code in UNSOLICITED_CODES:
|
||||
self.unsolicited_queue.put_nowait(response)
|
||||
else:
|
||||
logger.warning(
|
||||
f"dropping unexpected response with code '{response.code}'"
|
||||
)
|
||||
# Forward the received code to the correct queue.
|
||||
if self.command_lock.locked() and (
|
||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||
):
|
||||
self.response_queue.put_nowait(response)
|
||||
elif response.code in UNSOLICITED_CODES:
|
||||
self.unsolicited_queue.put_nowait(response)
|
||||
else:
|
||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
@@ -1247,32 +1244,31 @@ class AgProtocol(pyee.EventEmitter):
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
|
||||
while self.read_buffer:
|
||||
# Locate the trailer.
|
||||
trailer = self.read_buffer.find(b'\r')
|
||||
if trailer == -1:
|
||||
return
|
||||
# Locate the trailer.
|
||||
trailer = self.read_buffer.find(b'\r')
|
||||
if trailer == -1:
|
||||
return
|
||||
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_command = self.read_buffer[:trailer]
|
||||
command = AtCommand.parse_from(raw_command)
|
||||
logger.debug(f"<<< {raw_command.decode()}")
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_command = self.read_buffer[:trailer]
|
||||
command = AtCommand.parse_from(raw_command)
|
||||
logger.debug(f"<<< {raw_command.decode()}")
|
||||
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 1 :]
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 1 :]
|
||||
|
||||
if command.sub_code == AtCommand.SubCode.TEST:
|
||||
handler_name = f'_on_{command.code.lower()}_test'
|
||||
elif command.sub_code == AtCommand.SubCode.READ:
|
||||
handler_name = f'_on_{command.code.lower()}_read'
|
||||
else:
|
||||
handler_name = f'_on_{command.code.lower()}'
|
||||
if command.sub_code == AtCommand.SubCode.TEST:
|
||||
handler_name = f'_on_{command.code.lower()}_test'
|
||||
elif command.sub_code == AtCommand.SubCode.READ:
|
||||
handler_name = f'_on_{command.code.lower()}_read'
|
||||
else:
|
||||
handler_name = f'_on_{command.code.lower()}'
|
||||
|
||||
if handler := getattr(self, handler_name, None):
|
||||
handler(*command.parameters)
|
||||
else:
|
||||
logger.warning('Handler %s not found', handler_name)
|
||||
self.send_response('ERROR')
|
||||
if handler := getattr(self, handler_name, None):
|
||||
handler(*command.parameters)
|
||||
else:
|
||||
logger.warning('Handler %s not found', handler_name)
|
||||
self.send_response('ERROR')
|
||||
|
||||
def send_response(self, response: str) -> None:
|
||||
"""Sends an AT response."""
|
||||
|
||||
@@ -1106,18 +1106,6 @@ class Host(AbortableEventEmitter):
|
||||
event.status,
|
||||
)
|
||||
|
||||
def on_hci_qos_setup_complete_event(self, event):
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.emit(
|
||||
'connection_qos_setup', event.connection_handle, event.service_type
|
||||
)
|
||||
else:
|
||||
self.emit(
|
||||
'connection_qos_setup_failure',
|
||||
event.connection_handle,
|
||||
event.status,
|
||||
)
|
||||
|
||||
def on_hci_link_supervision_timeout_changed_event(self, event):
|
||||
pass
|
||||
|
||||
|
||||
+18
-27
@@ -25,7 +25,7 @@ from bumble.utils import AsyncRunner, OpenIntEnum
|
||||
from bumble.hci import Address
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -271,12 +271,24 @@ class HearingAccessService(gatt.TemplateService):
|
||||
def on_disconnection(_reason) -> None:
|
||||
self.currently_connected_clients.remove(connection)
|
||||
|
||||
@connection.on('pairing') # type: ignore
|
||||
def on_pairing(*_: Any) -> None:
|
||||
self.on_incoming_paired_connection(connection)
|
||||
# TODO Should we filter on device bonded && device is HAP ?
|
||||
self.currently_connected_clients.add(connection)
|
||||
if (
|
||||
connection.peer_address
|
||||
not in self.preset_changed_operations_history_per_device
|
||||
):
|
||||
self.preset_changed_operations_history_per_device[
|
||||
connection.peer_address
|
||||
] = []
|
||||
return
|
||||
|
||||
if connection.peer_resolvable_address:
|
||||
self.on_incoming_paired_connection(connection)
|
||||
async def on_connection_async() -> None:
|
||||
# Send all the PresetChangedOperation that occur when not connected
|
||||
await self._preset_changed_operation(connection)
|
||||
# Update the active preset index if needed
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
connection.abort_on('disconnection', on_connection_async())
|
||||
|
||||
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
||||
@@ -313,27 +325,6 @@ class HearingAccessService(gatt.TemplateService):
|
||||
]
|
||||
)
|
||||
|
||||
def on_incoming_paired_connection(self, connection: Connection):
|
||||
'''Setup initial operations to handle a remote bonded HAP device'''
|
||||
# TODO Should we filter on HAP device only ?
|
||||
self.currently_connected_clients.add(connection)
|
||||
if (
|
||||
connection.peer_address
|
||||
not in self.preset_changed_operations_history_per_device
|
||||
):
|
||||
self.preset_changed_operations_history_per_device[
|
||||
connection.peer_address
|
||||
] = []
|
||||
return
|
||||
|
||||
async def on_connection_async() -> None:
|
||||
# Send all the PresetChangedOperation that occur when not connected
|
||||
await self._preset_changed_operation(connection)
|
||||
# Update the active preset index if needed
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
connection.abort_on('disconnection', on_connection_async())
|
||||
|
||||
def _on_read_active_preset_index(
|
||||
self, __connection__: Optional[Connection]
|
||||
) -> bytes:
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
# 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 struct
|
||||
from typing import List
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacket:
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> MediaPacket:
|
||||
version = (data[0] >> 6) & 0x03
|
||||
padding = (data[0] >> 5) & 0x01
|
||||
extension = (data[0] >> 4) & 0x01
|
||||
csrc_count = data[0] & 0x0F
|
||||
marker = (data[1] >> 7) & 0x01
|
||||
payload_type = data[1] & 0x7F
|
||||
sequence_number = struct.unpack_from('>H', data, 2)[0]
|
||||
timestamp = struct.unpack_from('>I', data, 4)[0]
|
||||
ssrc = struct.unpack_from('>I', data, 8)[0]
|
||||
csrc_list = [
|
||||
struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count)
|
||||
]
|
||||
payload = data[12 + csrc_count * 4 :]
|
||||
|
||||
return MediaPacket(
|
||||
version,
|
||||
padding,
|
||||
extension,
|
||||
marker,
|
||||
sequence_number,
|
||||
timestamp,
|
||||
ssrc,
|
||||
csrc_list,
|
||||
payload_type,
|
||||
payload,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: int,
|
||||
padding: int,
|
||||
extension: int,
|
||||
marker: int,
|
||||
sequence_number: int,
|
||||
timestamp: int,
|
||||
ssrc: int,
|
||||
csrc_list: List[int],
|
||||
payload_type: int,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.padding = padding
|
||||
self.extension = extension
|
||||
self.marker = marker
|
||||
self.sequence_number = sequence_number & 0xFFFF
|
||||
self.timestamp = timestamp & 0xFFFFFFFF
|
||||
self.timestamp_seconds = 0.0
|
||||
self.ssrc = ssrc
|
||||
self.csrc_list = csrc_list
|
||||
self.payload_type = payload_type
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
header = bytes(
|
||||
[
|
||||
self.version << 6
|
||||
| self.padding << 5
|
||||
| self.extension << 4
|
||||
| len(self.csrc_list),
|
||||
self.marker << 7 | self.payload_type,
|
||||
]
|
||||
) + struct.pack(
|
||||
'>HII',
|
||||
self.sequence_number,
|
||||
self.timestamp,
|
||||
self.ssrc,
|
||||
)
|
||||
for csrc in self.csrc_list:
|
||||
header += struct.pack('>I', csrc)
|
||||
return header + self.payload
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'RTP(v={self.version},'
|
||||
f'p={self.padding},'
|
||||
f'x={self.extension},'
|
||||
f'm={self.marker},'
|
||||
f'pt={self.payload_type},'
|
||||
f'sn={self.sequence_number},'
|
||||
f'ts={self.timestamp},'
|
||||
f'ssrc={self.ssrc},'
|
||||
f'csrcs={self.csrc_list},'
|
||||
f'payload_size={len(self.payload)})'
|
||||
)
|
||||
@@ -20,14 +20,12 @@ import atexit
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
import grpc.aio
|
||||
|
||||
import bumble
|
||||
from bumble.transport.common import (
|
||||
from .common import (
|
||||
ParserSource,
|
||||
PumpedTransport,
|
||||
PumpedPacketSource,
|
||||
@@ -38,15 +36,15 @@ from bumble.transport.common import (
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
||||
PacketStreamerStub,
|
||||
PacketStreamerServicer,
|
||||
add_PacketStreamerServicer_to_server,
|
||||
)
|
||||
from .grpc_protobuf.netsim.packet_streamer_pb2 import PacketRequest, PacketResponse
|
||||
from .grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
|
||||
from .grpc_protobuf.netsim.common_pb2 import ChipKind
|
||||
from .grpc_protobuf.packet_streamer_pb2 import PacketRequest, PacketResponse
|
||||
from .grpc_protobuf.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
|
||||
from .grpc_protobuf.common_pb2 import ChipKind
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -60,7 +58,6 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_NAME = 'bumble0'
|
||||
DEFAULT_MANUFACTURER = 'Bumble'
|
||||
DEFAULT_VARIANT = ''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -202,6 +199,7 @@ async def open_android_netsim_controller_transport(
|
||||
data = (
|
||||
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
||||
)
|
||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
||||
self.on_data_received(data)
|
||||
|
||||
async def send_packet(self, data):
|
||||
@@ -255,7 +253,7 @@ async def open_android_netsim_controller_transport(
|
||||
|
||||
# Check that we don't already have a device
|
||||
if self.device:
|
||||
logger.debug('Busy, already serving a device')
|
||||
logger.debug('busy, already serving a device')
|
||||
return PacketResponse(error='Busy')
|
||||
|
||||
# Instantiate a new device
|
||||
@@ -314,24 +312,16 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
):
|
||||
# Wrapper for I/O operations
|
||||
class HciDevice:
|
||||
def __init__(self, name, variant, manufacturer, hci_device):
|
||||
def __init__(self, name, manufacturer, hci_device):
|
||||
self.name = name
|
||||
self.variant = variant
|
||||
self.manufacturer = manufacturer
|
||||
self.hci_device = hci_device
|
||||
|
||||
async def start(self): # Send the startup info
|
||||
device_info = DeviceInfo(
|
||||
chip_info = ChipInfo(
|
||||
name=self.name,
|
||||
kind='BUMBLE',
|
||||
version=bumble.__version__,
|
||||
sdk_version=platform.python_version(),
|
||||
build_id=platform.platform(),
|
||||
arch=platform.machine(),
|
||||
variant=self.variant,
|
||||
chip=Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer),
|
||||
)
|
||||
chip = Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer)
|
||||
chip_info = ChipInfo(name=self.name, chip=chip, device_info=device_info)
|
||||
logger.debug(f'Sending chip info to netsim: {chip_info}')
|
||||
await self.hci_device.write(PacketRequest(initial_info=chip_info))
|
||||
|
||||
@@ -359,16 +349,12 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
)
|
||||
|
||||
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
|
||||
variant = (
|
||||
DEFAULT_VARIANT if options is None else options.get('variant', DEFAULT_VARIANT)
|
||||
)
|
||||
manufacturer = DEFAULT_MANUFACTURER
|
||||
|
||||
# Connect as a host
|
||||
service = PacketStreamerStub(channel)
|
||||
hci_device = HciDevice(
|
||||
name=name,
|
||||
variant=variant,
|
||||
manufacturer=manufacturer,
|
||||
hci_device=service.StreamPackets(),
|
||||
)
|
||||
@@ -418,9 +404,6 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
The "chip" name, used to identify the "chip" instance. This
|
||||
may be useful when several clients are connected, since each needs to use a
|
||||
different name.
|
||||
variant=<variant>
|
||||
The device info variant field, which may be used to convey a device or
|
||||
application type (ex: "virtual-speaker", or "keyboard")
|
||||
|
||||
In `controller` mode:
|
||||
The <host>:<port> part is required. <host> may be the address of a local network
|
||||
|
||||
+8
-9
@@ -1,12 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/common.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
# source: common.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
@@ -14,13 +13,13 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13netsim/common.proto\x12\rnetsim.common*S\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x12\x14\n\x10\x42LUETOOTH_BEACON\x10\x04\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\x12\rnetsim.common*=\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.common_pb2', _globals)
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_globals['_CHIPKIND']._serialized_start=38
|
||||
_globals['_CHIPKIND']._serialized_end=121
|
||||
_CHIPKIND._serialized_start=31
|
||||
_CHIPKIND._serialized_end=92
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
+5
-11
@@ -2,17 +2,11 @@ from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from typing import ClassVar as _ClassVar
|
||||
|
||||
BLUETOOTH: ChipKind
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
UNSPECIFIED: ChipKind
|
||||
UWB: ChipKind
|
||||
WIFI: ChipKind
|
||||
|
||||
class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
UNSPECIFIED: _ClassVar[ChipKind]
|
||||
BLUETOOTH: _ClassVar[ChipKind]
|
||||
WIFI: _ClassVar[ChipKind]
|
||||
UWB: _ClassVar[ChipKind]
|
||||
BLUETOOTH_BEACON: _ClassVar[ChipKind]
|
||||
UNSPECIFIED: ChipKind
|
||||
BLUETOOTH: ChipKind
|
||||
WIFI: ChipKind
|
||||
UWB: ChipKind
|
||||
BLUETOOTH_BEACON: ChipKind
|
||||
__slots__ = []
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: hci_packet.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'hci_packet_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
|
||||
_HCIPACKET._serialized_start=36
|
||||
_HCIPACKET._serialized_end=214
|
||||
_HCIPACKET_PACKETTYPE._serialized_start=123
|
||||
_HCIPACKET_PACKETTYPE._serialized_end=214
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
+7
-13
@@ -6,23 +6,17 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class HCIPacket(_message.Message):
|
||||
__slots__ = ("packet_type", "packet")
|
||||
__slots__ = ["packet", "packet_type"]
|
||||
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
HCI_PACKET_UNSPECIFIED: _ClassVar[HCIPacket.PacketType]
|
||||
COMMAND: _ClassVar[HCIPacket.PacketType]
|
||||
ACL: _ClassVar[HCIPacket.PacketType]
|
||||
SCO: _ClassVar[HCIPacket.PacketType]
|
||||
EVENT: _ClassVar[HCIPacket.PacketType]
|
||||
ISO: _ClassVar[HCIPacket.PacketType]
|
||||
HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
|
||||
COMMAND: HCIPacket.PacketType
|
||||
__slots__ = []
|
||||
ACL: HCIPacket.PacketType
|
||||
SCO: HCIPacket.PacketType
|
||||
COMMAND: HCIPacket.PacketType
|
||||
EVENT: HCIPacket.PacketType
|
||||
HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
|
||||
ISO: HCIPacket.PacketType
|
||||
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
packet_type: HCIPacket.PacketType
|
||||
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
SCO: HCIPacket.PacketType
|
||||
packet: bytes
|
||||
packet_type: HCIPacket.PacketType
|
||||
def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/hci_packet.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17netsim/hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.hci_packet_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
_globals['DESCRIPTOR']._options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
|
||||
_globals['_HCIPACKET']._serialized_start=43
|
||||
_globals['_HCIPACKET']._serialized_end=221
|
||||
_globals['_HCIPACKET_PACKETTYPE']._serialized_start=130
|
||||
_globals['_HCIPACKET_PACKETTYPE']._serialized_end=221
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
File diff suppressed because one or more lines are too long
@@ -1,238 +0,0 @@
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as _common_pb2
|
||||
from google.protobuf import timestamp_pb2 as _timestamp_pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as _configuration_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class PhyKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
NONE: _ClassVar[PhyKind]
|
||||
BLUETOOTH_CLASSIC: _ClassVar[PhyKind]
|
||||
BLUETOOTH_LOW_ENERGY: _ClassVar[PhyKind]
|
||||
WIFI: _ClassVar[PhyKind]
|
||||
UWB: _ClassVar[PhyKind]
|
||||
WIFI_RTT: _ClassVar[PhyKind]
|
||||
NONE: PhyKind
|
||||
BLUETOOTH_CLASSIC: PhyKind
|
||||
BLUETOOTH_LOW_ENERGY: PhyKind
|
||||
WIFI: PhyKind
|
||||
UWB: PhyKind
|
||||
WIFI_RTT: PhyKind
|
||||
|
||||
class Position(_message.Message):
|
||||
__slots__ = ("x", "y", "z")
|
||||
X_FIELD_NUMBER: _ClassVar[int]
|
||||
Y_FIELD_NUMBER: _ClassVar[int]
|
||||
Z_FIELD_NUMBER: _ClassVar[int]
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ..., z: _Optional[float] = ...) -> None: ...
|
||||
|
||||
class Orientation(_message.Message):
|
||||
__slots__ = ("yaw", "pitch", "roll")
|
||||
YAW_FIELD_NUMBER: _ClassVar[int]
|
||||
PITCH_FIELD_NUMBER: _ClassVar[int]
|
||||
ROLL_FIELD_NUMBER: _ClassVar[int]
|
||||
yaw: float
|
||||
pitch: float
|
||||
roll: float
|
||||
def __init__(self, yaw: _Optional[float] = ..., pitch: _Optional[float] = ..., roll: _Optional[float] = ...) -> None: ...
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ("kind", "id", "name", "manufacturer", "product_name", "bt", "ble_beacon", "uwb", "wifi", "offset")
|
||||
class Radio(_message.Message):
|
||||
__slots__ = ("state", "range", "tx_count", "rx_count")
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
RANGE_FIELD_NUMBER: _ClassVar[int]
|
||||
TX_COUNT_FIELD_NUMBER: _ClassVar[int]
|
||||
RX_COUNT_FIELD_NUMBER: _ClassVar[int]
|
||||
state: bool
|
||||
range: float
|
||||
tx_count: int
|
||||
rx_count: int
|
||||
def __init__(self, state: bool = ..., range: _Optional[float] = ..., tx_count: _Optional[int] = ..., rx_count: _Optional[int] = ...) -> None: ...
|
||||
class Bluetooth(_message.Message):
|
||||
__slots__ = ("low_energy", "classic", "address", "bt_properties")
|
||||
LOW_ENERGY_FIELD_NUMBER: _ClassVar[int]
|
||||
CLASSIC_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
low_energy: Chip.Radio
|
||||
classic: Chip.Radio
|
||||
address: str
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
def __init__(self, low_energy: _Optional[_Union[Chip.Radio, _Mapping]] = ..., classic: _Optional[_Union[Chip.Radio, _Mapping]] = ..., address: _Optional[str] = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ...) -> None: ...
|
||||
class BleBeacon(_message.Message):
|
||||
__slots__ = ("bt", "address", "settings", "adv_data", "scan_response")
|
||||
class AdvertiseSettings(_message.Message):
|
||||
__slots__ = ("advertise_mode", "milliseconds", "tx_power_level", "dbm", "scannable", "timeout")
|
||||
class AdvertiseMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
LOW_POWER: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
BALANCED: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
LOW_LATENCY: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
LOW_POWER: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
BALANCED: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
LOW_LATENCY: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
class AdvertiseTxPower(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
ULTRA_LOW: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
LOW: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
MEDIUM: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
HIGH: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
ULTRA_LOW: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
LOW: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
MEDIUM: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
HIGH: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
ADVERTISE_MODE_FIELD_NUMBER: _ClassVar[int]
|
||||
MILLISECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||
TX_POWER_LEVEL_FIELD_NUMBER: _ClassVar[int]
|
||||
DBM_FIELD_NUMBER: _ClassVar[int]
|
||||
SCANNABLE_FIELD_NUMBER: _ClassVar[int]
|
||||
TIMEOUT_FIELD_NUMBER: _ClassVar[int]
|
||||
advertise_mode: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
milliseconds: int
|
||||
tx_power_level: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
dbm: int
|
||||
scannable: bool
|
||||
timeout: int
|
||||
def __init__(self, advertise_mode: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode, str]] = ..., milliseconds: _Optional[int] = ..., tx_power_level: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower, str]] = ..., dbm: _Optional[int] = ..., scannable: bool = ..., timeout: _Optional[int] = ...) -> None: ...
|
||||
class AdvertiseData(_message.Message):
|
||||
__slots__ = ("include_device_name", "include_tx_power_level", "manufacturer_data", "services")
|
||||
class Service(_message.Message):
|
||||
__slots__ = ("uuid", "data")
|
||||
UUID_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
uuid: str
|
||||
data: bytes
|
||||
def __init__(self, uuid: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ...
|
||||
INCLUDE_DEVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
INCLUDE_TX_POWER_LEVEL_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
include_device_name: bool
|
||||
include_tx_power_level: bool
|
||||
manufacturer_data: bytes
|
||||
services: _containers.RepeatedCompositeFieldContainer[Chip.BleBeacon.AdvertiseData.Service]
|
||||
def __init__(self, include_device_name: bool = ..., include_tx_power_level: bool = ..., manufacturer_data: _Optional[bytes] = ..., services: _Optional[_Iterable[_Union[Chip.BleBeacon.AdvertiseData.Service, _Mapping]]] = ...) -> None: ...
|
||||
BT_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
SETTINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
ADV_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SCAN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
bt: Chip.Bluetooth
|
||||
address: str
|
||||
settings: Chip.BleBeacon.AdvertiseSettings
|
||||
adv_data: Chip.BleBeacon.AdvertiseData
|
||||
scan_response: Chip.BleBeacon.AdvertiseData
|
||||
def __init__(self, bt: _Optional[_Union[Chip.Bluetooth, _Mapping]] = ..., address: _Optional[str] = ..., settings: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings, _Mapping]] = ..., adv_data: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ..., scan_response: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ...) -> None: ...
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_FIELD_NUMBER: _ClassVar[int]
|
||||
BLE_BEACON_FIELD_NUMBER: _ClassVar[int]
|
||||
UWB_FIELD_NUMBER: _ClassVar[int]
|
||||
WIFI_FIELD_NUMBER: _ClassVar[int]
|
||||
OFFSET_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
id: int
|
||||
name: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
bt: Chip.Bluetooth
|
||||
ble_beacon: Chip.BleBeacon
|
||||
uwb: Chip.Radio
|
||||
wifi: Chip.Radio
|
||||
offset: Position
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[int] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., bt: _Optional[_Union[Chip.Bluetooth, _Mapping]] = ..., ble_beacon: _Optional[_Union[Chip.BleBeacon, _Mapping]] = ..., uwb: _Optional[_Union[Chip.Radio, _Mapping]] = ..., wifi: _Optional[_Union[Chip.Radio, _Mapping]] = ..., offset: _Optional[_Union[Position, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class ChipCreate(_message.Message):
|
||||
__slots__ = ("kind", "address", "name", "manufacturer", "product_name", "ble_beacon", "bt_properties")
|
||||
class BleBeaconCreate(_message.Message):
|
||||
__slots__ = ("address", "settings", "adv_data", "scan_response")
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
SETTINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
ADV_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SCAN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
address: str
|
||||
settings: Chip.BleBeacon.AdvertiseSettings
|
||||
adv_data: Chip.BleBeacon.AdvertiseData
|
||||
scan_response: Chip.BleBeacon.AdvertiseData
|
||||
def __init__(self, address: _Optional[str] = ..., settings: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings, _Mapping]] = ..., adv_data: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ..., scan_response: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ...) -> None: ...
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
BLE_BEACON_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
address: str
|
||||
name: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
ble_beacon: ChipCreate.BleBeaconCreate
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., address: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., ble_beacon: _Optional[_Union[ChipCreate.BleBeaconCreate, _Mapping]] = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Device(_message.Message):
|
||||
__slots__ = ("id", "name", "visible", "position", "orientation", "chips")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
VISIBLE_FIELD_NUMBER: _ClassVar[int]
|
||||
POSITION_FIELD_NUMBER: _ClassVar[int]
|
||||
ORIENTATION_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
name: str
|
||||
visible: bool
|
||||
position: Position
|
||||
orientation: Orientation
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
def __init__(self, id: _Optional[int] = ..., name: _Optional[str] = ..., visible: bool = ..., position: _Optional[_Union[Position, _Mapping]] = ..., orientation: _Optional[_Union[Orientation, _Mapping]] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class DeviceCreate(_message.Message):
|
||||
__slots__ = ("name", "position", "orientation", "chips")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
POSITION_FIELD_NUMBER: _ClassVar[int]
|
||||
ORIENTATION_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
position: Position
|
||||
orientation: Orientation
|
||||
chips: _containers.RepeatedCompositeFieldContainer[ChipCreate]
|
||||
def __init__(self, name: _Optional[str] = ..., position: _Optional[_Union[Position, _Mapping]] = ..., orientation: _Optional[_Union[Orientation, _Mapping]] = ..., chips: _Optional[_Iterable[_Union[ChipCreate, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class Scene(_message.Message):
|
||||
__slots__ = ("devices",)
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[Device, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class Capture(_message.Message):
|
||||
__slots__ = ("id", "chip_kind", "device_name", "state", "size", "records", "timestamp", "valid")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIP_KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
SIZE_FIELD_NUMBER: _ClassVar[int]
|
||||
RECORDS_FIELD_NUMBER: _ClassVar[int]
|
||||
TIMESTAMP_FIELD_NUMBER: _ClassVar[int]
|
||||
VALID_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
chip_kind: _common_pb2.ChipKind
|
||||
device_name: str
|
||||
state: bool
|
||||
size: int
|
||||
records: int
|
||||
timestamp: _timestamp_pb2.Timestamp
|
||||
valid: bool
|
||||
def __init__(self, id: _Optional[int] = ..., chip_kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., device_name: _Optional[str] = ..., state: bool = ..., size: _Optional[int] = ..., records: _Optional[int] = ..., timestamp: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., valid: bool = ...) -> None: ...
|
||||
@@ -1,4 +0,0 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/packet_streamer.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from bumble.transport.grpc_protobuf.netsim import hci_packet_pb2 as netsim_dot_hci__packet__pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import startup_pb2 as netsim_dot_startup__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cnetsim/packet_streamer.proto\x12\rnetsim.packet\x1a\x17netsim/hci_packet.proto\x1a\x14netsim/startup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.packet_streamer_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
_globals['_PACKETREQUEST']._serialized_start=95
|
||||
_globals['_PACKETREQUEST']._serialized_end=242
|
||||
_globals['_PACKETRESPONSE']._serialized_start=244
|
||||
_globals['_PACKETRESPONSE']._serialized_end=360
|
||||
_globals['_PACKETSTREAMER']._serialized_start=362
|
||||
_globals['_PACKETSTREAMER']._serialized_end=460
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,41 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/startup.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as netsim_dot_common__pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import model_pb2 as netsim_dot_model__pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as rootcanal_dot_configuration__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14netsim/startup.proto\x12\x0enetsim.startup\x1a\x13netsim/common.proto\x1a\x12netsim/model.proto\x1a\x1drootcanal/configuration.proto\"\xb4\x01\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1ap\n\x06\x44\x65vice\x12\x10\n\x04name\x18\x01 \x01(\tB\x02\x18\x01\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\x12/\n\x0b\x64\x65vice_info\x18\x03 \x01(\x0b\x32\x1a.netsim.startup.DeviceInfo\"q\n\x08\x43hipInfo\x12\x10\n\x04name\x18\x01 \x01(\tB\x02\x18\x01\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\x12/\n\x0b\x64\x65vice_info\x18\x03 \x01(\x0b\x32\x1a.netsim.startup.DeviceInfo\"\x7f\n\nDeviceInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x13\n\x0bsdk_version\x18\x04 \x01(\t\x12\x10\n\x08\x62uild_id\x18\x05 \x01(\t\x12\x0f\n\x07variant\x18\x06 \x01(\t\x12\x0c\n\x04\x61rch\x18\x07 \x01(\t\"\x9b\x02\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x12:\n\rbt_properties\x18\x08 \x01(\x0b\x32#.rootcanal.configuration.Controller\x12\x0f\n\x07\x61\x64\x64ress\x18\t \x01(\t\x12+\n\x06offset\x18\n \x01(\x0b\x32\x16.netsim.model.PositionH\x00\x88\x01\x01\x42\t\n\x07_offsetb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.startup_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
_globals['_STARTUPINFO_DEVICE'].fields_by_name['name']._options = None
|
||||
_globals['_STARTUPINFO_DEVICE'].fields_by_name['name']._serialized_options = b'\030\001'
|
||||
_globals['_CHIPINFO'].fields_by_name['name']._options = None
|
||||
_globals['_CHIPINFO'].fields_by_name['name']._serialized_options = b'\030\001'
|
||||
_globals['_STARTUPINFO']._serialized_start=113
|
||||
_globals['_STARTUPINFO']._serialized_end=293
|
||||
_globals['_STARTUPINFO_DEVICE']._serialized_start=181
|
||||
_globals['_STARTUPINFO_DEVICE']._serialized_end=293
|
||||
_globals['_CHIPINFO']._serialized_start=295
|
||||
_globals['_CHIPINFO']._serialized_end=408
|
||||
_globals['_DEVICEINFO']._serialized_start=410
|
||||
_globals['_DEVICEINFO']._serialized_end=537
|
||||
_globals['_CHIP']._serialized_start=540
|
||||
_globals['_CHIP']._serialized_end=823
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,76 +0,0 @@
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as _common_pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import model_pb2 as _model_pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as _configuration_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class StartupInfo(_message.Message):
|
||||
__slots__ = ("devices",)
|
||||
class Device(_message.Message):
|
||||
__slots__ = ("name", "chips", "device_info")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class ChipInfo(_message.Message):
|
||||
__slots__ = ("name", "chip", "device_info")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIP_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
chip: Chip
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class DeviceInfo(_message.Message):
|
||||
__slots__ = ("name", "kind", "version", "sdk_version", "build_id", "variant", "arch")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
SDK_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
BUILD_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
VARIANT_FIELD_NUMBER: _ClassVar[int]
|
||||
ARCH_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
kind: str
|
||||
version: str
|
||||
sdk_version: str
|
||||
build_id: str
|
||||
variant: str
|
||||
arch: str
|
||||
def __init__(self, name: _Optional[str] = ..., kind: _Optional[str] = ..., version: _Optional[str] = ..., sdk_version: _Optional[str] = ..., build_id: _Optional[str] = ..., variant: _Optional[str] = ..., arch: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ("kind", "id", "manufacturer", "product_name", "fd_in", "fd_out", "loopback", "bt_properties", "address", "offset")
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_IN_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_OUT_FIELD_NUMBER: _ClassVar[int]
|
||||
LOOPBACK_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
OFFSET_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
id: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
fd_in: int
|
||||
fd_out: int
|
||||
loopback: bool
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
address: str
|
||||
offset: _model_pb2.Position
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ..., address: _Optional[str] = ..., offset: _Optional[_Union[_model_pb2.Position, _Mapping]] = ...) -> None: ...
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: packet_streamer.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import hci_packet_pb2 as hci__packet__pb2
|
||||
from . import startup_pb2 as startup__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15packet_streamer.proto\x12\rnetsim.packet\x1a\x10hci_packet.proto\x1a\rstartup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'packet_streamer_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_PACKETREQUEST._serialized_start=74
|
||||
_PACKETREQUEST._serialized_end=221
|
||||
_PACKETRESPONSE._serialized_start=223
|
||||
_PACKETRESPONSE._serialized_end=339
|
||||
_PACKETSTREAMER._serialized_start=341
|
||||
_PACKETSTREAMER._serialized_end=439
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
from bumble.transport.grpc_protobuf.netsim import hci_packet_pb2 as _hci_packet_pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import startup_pb2 as _startup_pb2
|
||||
from . import hci_packet_pb2 as _hci_packet_pb2
|
||||
from . import startup_pb2 as _startup_pb2
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
@@ -7,17 +7,17 @@ from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Opti
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class PacketRequest(_message.Message):
|
||||
__slots__ = ("initial_info", "hci_packet", "packet")
|
||||
INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
__slots__ = ["hci_packet", "initial_info", "packet"]
|
||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
initial_info: _startup_pb2.ChipInfo
|
||||
hci_packet: _hci_packet_pb2.HCIPacket
|
||||
initial_info: _startup_pb2.ChipInfo
|
||||
packet: bytes
|
||||
def __init__(self, initial_info: _Optional[_Union[_startup_pb2.ChipInfo, _Mapping]] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
||||
|
||||
class PacketResponse(_message.Message):
|
||||
__slots__ = ("error", "hci_packet", "packet")
|
||||
__slots__ = ["error", "hci_packet", "packet"]
|
||||
ERROR_FIELD_NUMBER: _ClassVar[int]
|
||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
+7
-7
@@ -2,7 +2,7 @@
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
from bumble.transport.grpc_protobuf.netsim import packet_streamer_pb2 as netsim_dot_packet__streamer__pb2
|
||||
from . import packet_streamer_pb2 as packet__streamer__pb2
|
||||
|
||||
|
||||
class PacketStreamerStub(object):
|
||||
@@ -30,8 +30,8 @@ class PacketStreamerStub(object):
|
||||
"""
|
||||
self.StreamPackets = channel.stream_stream(
|
||||
'/netsim.packet.PacketStreamer/StreamPackets',
|
||||
request_serializer=netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
response_deserializer=netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||
request_serializer=packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
response_deserializer=packet__streamer__pb2.PacketResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ def add_PacketStreamerServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'StreamPackets': grpc.stream_stream_rpc_method_handler(
|
||||
servicer.StreamPackets,
|
||||
request_deserializer=netsim_dot_packet__streamer__pb2.PacketRequest.FromString,
|
||||
response_serializer=netsim_dot_packet__streamer__pb2.PacketResponse.SerializeToString,
|
||||
request_deserializer=packet__streamer__pb2.PacketRequest.FromString,
|
||||
response_serializer=packet__streamer__pb2.PacketResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
@@ -103,7 +103,7 @@ class PacketStreamer(object):
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets',
|
||||
netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||
packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
packet__streamer__pb2.PacketResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
@@ -1,39 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: rootcanal/configuration.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1drootcanal/configuration.proto\x12\x17rootcanal.configuration\"\xbc\x01\n\x12\x43ontrollerFeatures\x12\x1f\n\x17le_extended_advertising\x18\x01 \x01(\x08\x12\x1f\n\x17le_periodic_advertising\x18\x02 \x01(\x08\x12\x12\n\nll_privacy\x18\x03 \x01(\x08\x12\x11\n\tle_2m_phy\x18\x04 \x01(\x08\x12\x14\n\x0cle_coded_phy\x18\x05 \x01(\x08\x12\'\n\x1fle_connected_isochronous_stream\x18\x06 \x01(\x08\"\x8d\x01\n\x10\x43ontrollerQuirks\x12\x30\n(send_acl_data_before_connection_complete\x18\x01 \x01(\x08\x12\"\n\x1ahas_default_random_address\x18\x02 \x01(\x08\x12#\n\x1bhardware_error_before_reset\x18\x03 \x01(\x08\".\n\x0eVendorFeatures\x12\x0b\n\x03\x63sr\x18\x01 \x01(\x08\x12\x0f\n\x07\x61ndroid\x18\x02 \x01(\x08\"\x8a\x02\n\nController\x12\x39\n\x06preset\x18\x01 \x01(\x0e\x32).rootcanal.configuration.ControllerPreset\x12=\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32+.rootcanal.configuration.ControllerFeatures\x12\x39\n\x06quirks\x18\x03 \x01(\x0b\x32).rootcanal.configuration.ControllerQuirks\x12\x0e\n\x06strict\x18\x04 \x01(\x08\x12\x37\n\x06vendor\x18\x05 \x01(\x0b\x32\'.rootcanal.configuration.VendorFeatures\"Y\n\tTcpServer\x12\x10\n\x08tcp_port\x18\x01 \x02(\x05\x12:\n\rconfiguration\x18\x02 \x01(\x0b\x32#.rootcanal.configuration.Controller\"G\n\rConfiguration\x12\x36\n\ntcp_server\x18\x01 \x03(\x0b\x32\".rootcanal.configuration.TcpServer*H\n\x10\x43ontrollerPreset\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x0f\n\x0bLAIRD_BL654\x10\x01\x12\x16\n\x12\x43SR_RCK_PTS_DONGLE\x10\x02\x42\x02H\x02')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rootcanal.configuration_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
_globals['DESCRIPTOR']._options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'H\002'
|
||||
_globals['_CONTROLLERPRESET']._serialized_start=874
|
||||
_globals['_CONTROLLERPRESET']._serialized_end=946
|
||||
_globals['_CONTROLLERFEATURES']._serialized_start=59
|
||||
_globals['_CONTROLLERFEATURES']._serialized_end=247
|
||||
_globals['_CONTROLLERQUIRKS']._serialized_start=250
|
||||
_globals['_CONTROLLERQUIRKS']._serialized_end=391
|
||||
_globals['_VENDORFEATURES']._serialized_start=393
|
||||
_globals['_VENDORFEATURES']._serialized_end=439
|
||||
_globals['_CONTROLLER']._serialized_start=442
|
||||
_globals['_CONTROLLER']._serialized_end=708
|
||||
_globals['_TCPSERVER']._serialized_start=710
|
||||
_globals['_TCPSERVER']._serialized_end=799
|
||||
_globals['_CONFIGURATION']._serialized_start=801
|
||||
_globals['_CONFIGURATION']._serialized_end=872
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,78 +0,0 @@
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class ControllerPreset(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
DEFAULT: _ClassVar[ControllerPreset]
|
||||
LAIRD_BL654: _ClassVar[ControllerPreset]
|
||||
CSR_RCK_PTS_DONGLE: _ClassVar[ControllerPreset]
|
||||
DEFAULT: ControllerPreset
|
||||
LAIRD_BL654: ControllerPreset
|
||||
CSR_RCK_PTS_DONGLE: ControllerPreset
|
||||
|
||||
class ControllerFeatures(_message.Message):
|
||||
__slots__ = ("le_extended_advertising", "le_periodic_advertising", "ll_privacy", "le_2m_phy", "le_coded_phy", "le_connected_isochronous_stream")
|
||||
LE_EXTENDED_ADVERTISING_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_PERIODIC_ADVERTISING_FIELD_NUMBER: _ClassVar[int]
|
||||
LL_PRIVACY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_2M_PHY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_CODED_PHY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_CONNECTED_ISOCHRONOUS_STREAM_FIELD_NUMBER: _ClassVar[int]
|
||||
le_extended_advertising: bool
|
||||
le_periodic_advertising: bool
|
||||
ll_privacy: bool
|
||||
le_2m_phy: bool
|
||||
le_coded_phy: bool
|
||||
le_connected_isochronous_stream: bool
|
||||
def __init__(self, le_extended_advertising: bool = ..., le_periodic_advertising: bool = ..., ll_privacy: bool = ..., le_2m_phy: bool = ..., le_coded_phy: bool = ..., le_connected_isochronous_stream: bool = ...) -> None: ...
|
||||
|
||||
class ControllerQuirks(_message.Message):
|
||||
__slots__ = ("send_acl_data_before_connection_complete", "has_default_random_address", "hardware_error_before_reset")
|
||||
SEND_ACL_DATA_BEFORE_CONNECTION_COMPLETE_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_DEFAULT_RANDOM_ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
HARDWARE_ERROR_BEFORE_RESET_FIELD_NUMBER: _ClassVar[int]
|
||||
send_acl_data_before_connection_complete: bool
|
||||
has_default_random_address: bool
|
||||
hardware_error_before_reset: bool
|
||||
def __init__(self, send_acl_data_before_connection_complete: bool = ..., has_default_random_address: bool = ..., hardware_error_before_reset: bool = ...) -> None: ...
|
||||
|
||||
class VendorFeatures(_message.Message):
|
||||
__slots__ = ("csr", "android")
|
||||
CSR_FIELD_NUMBER: _ClassVar[int]
|
||||
ANDROID_FIELD_NUMBER: _ClassVar[int]
|
||||
csr: bool
|
||||
android: bool
|
||||
def __init__(self, csr: bool = ..., android: bool = ...) -> None: ...
|
||||
|
||||
class Controller(_message.Message):
|
||||
__slots__ = ("preset", "features", "quirks", "strict", "vendor")
|
||||
PRESET_FIELD_NUMBER: _ClassVar[int]
|
||||
FEATURES_FIELD_NUMBER: _ClassVar[int]
|
||||
QUIRKS_FIELD_NUMBER: _ClassVar[int]
|
||||
STRICT_FIELD_NUMBER: _ClassVar[int]
|
||||
VENDOR_FIELD_NUMBER: _ClassVar[int]
|
||||
preset: ControllerPreset
|
||||
features: ControllerFeatures
|
||||
quirks: ControllerQuirks
|
||||
strict: bool
|
||||
vendor: VendorFeatures
|
||||
def __init__(self, preset: _Optional[_Union[ControllerPreset, str]] = ..., features: _Optional[_Union[ControllerFeatures, _Mapping]] = ..., quirks: _Optional[_Union[ControllerQuirks, _Mapping]] = ..., strict: bool = ..., vendor: _Optional[_Union[VendorFeatures, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class TcpServer(_message.Message):
|
||||
__slots__ = ("tcp_port", "configuration")
|
||||
TCP_PORT_FIELD_NUMBER: _ClassVar[int]
|
||||
CONFIGURATION_FIELD_NUMBER: _ClassVar[int]
|
||||
tcp_port: int
|
||||
configuration: Controller
|
||||
def __init__(self, tcp_port: _Optional[int] = ..., configuration: _Optional[_Union[Controller, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Configuration(_message.Message):
|
||||
__slots__ = ("tcp_server",)
|
||||
TCP_SERVER_FIELD_NUMBER: _ClassVar[int]
|
||||
tcp_server: _containers.RepeatedCompositeFieldContainer[TcpServer]
|
||||
def __init__(self, tcp_server: _Optional[_Iterable[_Union[TcpServer, _Mapping]]] = ...) -> None: ...
|
||||
@@ -1,4 +0,0 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: startup.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as common__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rstartup.proto\x12\x0enetsim.startup\x1a\x0c\x63ommon.proto\"\x7f\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1a;\n\x06\x44\x65vice\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\"<\n\x08\x43hipInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\"\x96\x01\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x62\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'startup_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_STARTUPINFO._serialized_start=47
|
||||
_STARTUPINFO._serialized_end=174
|
||||
_STARTUPINFO_DEVICE._serialized_start=115
|
||||
_STARTUPINFO_DEVICE._serialized_end=174
|
||||
_CHIPINFO._serialized_start=176
|
||||
_CHIPINFO._serialized_end=236
|
||||
_CHIP._serialized_start=239
|
||||
_CHIP._serialized_end=389
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,46 @@
|
||||
from . import common_pb2 as _common_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ["fd_in", "fd_out", "id", "kind", "loopback", "manufacturer", "product_name"]
|
||||
FD_IN_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_OUT_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
LOOPBACK_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
fd_in: int
|
||||
fd_out: int
|
||||
id: str
|
||||
kind: _common_pb2.ChipKind
|
||||
loopback: bool
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ...) -> None: ...
|
||||
|
||||
class ChipInfo(_message.Message):
|
||||
__slots__ = ["chip", "name"]
|
||||
CHIP_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
chip: Chip
|
||||
name: str
|
||||
def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StartupInfo(_message.Message):
|
||||
__slots__ = ["devices"]
|
||||
class Device(_message.Message):
|
||||
__slots__ = ["chips", "name"]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
name: str
|
||||
def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ...
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ...
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -25,9 +25,6 @@ An app that implements a virtual Bluetooth speaker that can receive audio.
|
||||
## `run_advertiser.py`
|
||||
An app that runs a simple device that just advertises (BLE).
|
||||
|
||||
## `run_cig_setup.py`
|
||||
An app that creates a simple CIG containing two CISes. **Note**: If using the example config file (e.g. `device1.json`), the `address` needs to be removed, so that the devices are given different random addresses.
|
||||
|
||||
## `run_classic_connect.py`
|
||||
An app that connects to a Bluetooth Classic device and prints its services.
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+14
-17
@@ -61,23 +61,20 @@ def codec_capabilities():
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||
| SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
|
||||
+16
-10
@@ -33,6 +33,8 @@ from bumble.avdtp import (
|
||||
Listener,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
make_audio_source_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
SbcMediaCodecInformation,
|
||||
@@ -57,12 +59,12 @@ def codec_capabilities():
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||
media_codec_information=SbcMediaCodecInformation.from_discrete_values(
|
||||
sampling_frequency=44100,
|
||||
channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
block_length=16,
|
||||
subbands=8,
|
||||
allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
@@ -71,9 +73,11 @@ def codec_capabilities():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_avdtp_connection(read_function, protocol):
|
||||
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
|
||||
packet_source = SbcPacketSource(
|
||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||
)
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
protocol.add_source(codec_capabilities(), packet_pump)
|
||||
protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -93,9 +97,11 @@ async def stream_packets(read_function, protocol):
|
||||
print(f'### Selected sink: {sink.seid}')
|
||||
|
||||
# Stream the packets
|
||||
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
|
||||
packet_source = SbcPacketSource(
|
||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||
)
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
source = protocol.add_source(codec_capabilities(), packet_pump)
|
||||
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||
stream = await protocol.create_stream(source, sink)
|
||||
await stream.start()
|
||||
await asyncio.sleep(5)
|
||||
|
||||
+14
-17
@@ -60,23 +60,20 @@ def codec_capabilities():
|
||||
return avdtp.MediaCodecCapabilities(
|
||||
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=a2dp.SbcMediaCodecInformation(
|
||||
sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=a2dp.SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| a2dp.SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| a2dp.SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| a2dp.SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=a2dp.SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| a2dp.SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| a2dp.SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| a2dp.SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=a2dp.SbcMediaCodecInformation.Subbands.S_4
|
||||
| a2dp.SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=a2dp.SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| a2dp.SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
a2dp.SBC_MONO_CHANNEL_MODE,
|
||||
a2dp.SBC_DUAL_CHANNEL_MODE,
|
||||
a2dp.SBC_STEREO_CHANNEL_MODE,
|
||||
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
a2dp.SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
|
||||
@@ -36,10 +36,13 @@ from bumble.transport import open_transport_or_link
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
'Usage: run_cig_setup.py <config-file> '
|
||||
'Usage: run_cig_setup.py <config-file>'
|
||||
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
|
||||
)
|
||||
print('example: run_cig_setup.py device1.json hci-socket:0 hci-socket:1')
|
||||
print(
|
||||
'example: run_cig_setup.py device1.json'
|
||||
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
|
||||
)
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
@@ -62,18 +65,18 @@ async def main() -> None:
|
||||
advertising_set = await devices[0].create_advertising_set()
|
||||
|
||||
connection = await devices[1].connect(
|
||||
devices[0].random_address, own_address_type=OwnAddressType.RANDOM
|
||||
devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
|
||||
)
|
||||
|
||||
cid_ids = [2, 3]
|
||||
cis_handles = await devices[1].setup_cig(
|
||||
cig_id=1,
|
||||
cis_id=cid_ids,
|
||||
sdu_interval=(10000, 255),
|
||||
sdu_interval=(10000, 0),
|
||||
framing=0,
|
||||
max_sdu=(120, 0),
|
||||
retransmission_number=13,
|
||||
max_transport_latency=(100, 5),
|
||||
max_transport_latency=(100, 0),
|
||||
)
|
||||
|
||||
def on_cis_request(
|
||||
|
||||
@@ -60,6 +60,8 @@ dependencies {
|
||||
implementation(libs.ui.graphics)
|
||||
implementation(libs.ui.tooling.preview)
|
||||
implementation(libs.material3)
|
||||
implementation(libs.mobly.snippet)
|
||||
implementation(libs.androidx.core)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BTBench"
|
||||
>
|
||||
<meta-data
|
||||
android:name="mobly-snippets"
|
||||
android:value="com.github.google.bumble.btbench.AutomationSnippet"/>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -35,5 +38,7 @@
|
||||
</activity>
|
||||
<!-- <profileable android:shell="true"/>-->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
<instrumentation
|
||||
android:name="com.google.android.mobly.snippet.SnippetRunner"
|
||||
android:targetPackage="com.github.google.bumble.btbench" />
|
||||
</manifest>
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.github.google.bumble.btbench;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import com.google.android.mobly.snippet.Snippet;
|
||||
import com.google.android.mobly.snippet.rpc.Rpc;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.security.InvalidParameterException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
class Runner {
|
||||
public UUID mId;
|
||||
private final Mode mMode;
|
||||
private final String mModeName;
|
||||
private final String mScenario;
|
||||
private final AppViewModel mModel;
|
||||
|
||||
Runner(Mode mode, String modeName, String scenario, AppViewModel model) {
|
||||
this.mId = UUID.randomUUID();
|
||||
this.mMode = mode;
|
||||
this.mModeName = modeName;
|
||||
this.mScenario = scenario;
|
||||
this.mModel = model;
|
||||
}
|
||||
|
||||
public JSONObject toJson() throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("id", mId.toString());
|
||||
result.put("mode", mModeName);
|
||||
result.put("scenario", mScenario);
|
||||
result.put("model", AutomationSnippet.modelToJson(mModel));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
mModel.abort();
|
||||
}
|
||||
|
||||
public void waitForCompletion() {
|
||||
mMode.waitForCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
public class AutomationSnippet implements Snippet {
|
||||
private static final String TAG = "btbench.snippet";
|
||||
private final BluetoothAdapter mBluetoothAdapter;
|
||||
private final Context mContext;
|
||||
private final ArrayList<Runner> mRunners = new ArrayList<>();
|
||||
|
||||
public AutomationSnippet() {
|
||||
mContext = ApplicationProvider.getApplicationContext();
|
||||
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
|
||||
mBluetoothAdapter = bluetoothManager.getAdapter();
|
||||
if (mBluetoothAdapter == null) {
|
||||
throw new RuntimeException("bluetooth not supported");
|
||||
}
|
||||
}
|
||||
|
||||
private Runner runScenario(AppViewModel model, String mode, String scenario) {
|
||||
Mode runnable;
|
||||
switch (mode) {
|
||||
case "rfcomm-client":
|
||||
runnable = new RfcommClient(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "rfcomm-server":
|
||||
runnable = new RfcommServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-client":
|
||||
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-server":
|
||||
runnable = new L2capServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
runnable.run();
|
||||
Runner runner = new Runner(runnable, mode, scenario, model);
|
||||
mRunners.add(runner);
|
||||
return runner;
|
||||
}
|
||||
|
||||
private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) {
|
||||
switch (scenario) {
|
||||
case "send":
|
||||
return new Sender(model, packetIO);
|
||||
|
||||
case "receive":
|
||||
return new Receiver(model, packetIO);
|
||||
|
||||
case "ping":
|
||||
return new Pinger(model, packetIO);
|
||||
|
||||
case "pong":
|
||||
return new Ponger(model, packetIO);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static JSONObject modelToJson(AppViewModel model) throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("status", model.getStatus());
|
||||
result.put("running", model.getRunning());
|
||||
result.put("l2cap_psm", model.getL2capPsm());
|
||||
if (model.getStatus().equals("OK")) {
|
||||
JSONObject stats = new JSONObject();
|
||||
result.put("stats", stats);
|
||||
stats.put("throughput", model.getThroughput());
|
||||
JSONObject rttStats = new JSONObject();
|
||||
stats.put("rtt", rttStats);
|
||||
rttStats.put("compound", model.getStats());
|
||||
} else {
|
||||
result.put("last_error", model.getLastError());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Runner findRunner(String runnerId) {
|
||||
for (Runner runner : mRunners) {
|
||||
if (runner.mId.toString().equals(runnerId)) {
|
||||
return runner;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in RFComm Client mode")
|
||||
public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount,
|
||||
int packetSize, int packetInterval) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
// We only support "send" and "ping" for this mode for now
|
||||
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
model.setPeerBluetoothAddress(peerBluetoothAddress);
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-client", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in RFComm Server mode")
|
||||
public JSONObject runRfcommServer(String scenario) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
// We only support "receive" and "pong" for this mode for now
|
||||
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in L2CAP Client mode")
|
||||
public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm,
|
||||
boolean use_2m_phy, int packetCount, int packetSize,
|
||||
int packetInterval) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
// We only support "send" and "ping" for this mode for now
|
||||
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
model.setPeerBluetoothAddress(peerBluetoothAddress);
|
||||
model.setL2capPsm(psm);
|
||||
model.setUse2mPhy(use_2m_phy);
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
|
||||
Runner runner = runScenario(model, "l2cap-client", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in L2CAP Server mode")
|
||||
public JSONObject runL2capServer(String scenario) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
// We only support "receive" and "pong" for this mode for now
|
||||
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
|
||||
Runner runner = runScenario(model, "l2cap-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Stop a Runner")
|
||||
public JSONObject stopRunner(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
runner.stop();
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Wait for a Runner to complete")
|
||||
public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
runner.waitForCompletion();
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Get a Runner by ID")
|
||||
public JSONObject getRunner(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
if (runner == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Get all Runners")
|
||||
public JSONObject getRunners() throws JSONException {
|
||||
JSONObject result = new JSONObject();
|
||||
JSONArray runners = new JSONArray();
|
||||
result.put("runners", runners);
|
||||
for (Runner runner: mRunners) {
|
||||
runners.put(runner.toJson());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
interface IoClient {
|
||||
fun run()
|
||||
fun abort()
|
||||
}
|
||||
+14
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+168
-48
@@ -34,12 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -54,6 +57,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -69,6 +73,9 @@ private val Log = Logger.getLogger("bumble.main-activity")
|
||||
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
||||
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
||||
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
|
||||
const val SENDER_PACKET_INTERVAL_PREF_KEY = "sender_packet_interval"
|
||||
const val SCENARIO_PREF_KEY = "scenario"
|
||||
const val MODE_PREF_KEY = "mode"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appViewModel = AppViewModel()
|
||||
@@ -139,10 +146,7 @@ class MainActivity : ComponentActivity() {
|
||||
MainView(
|
||||
appViewModel,
|
||||
::becomeDiscoverable,
|
||||
::runRfcommClient,
|
||||
::runRfcommServer,
|
||||
::runL2capClient,
|
||||
::runL2capServer,
|
||||
::runScenario
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,37 +163,54 @@ class MainActivity : ComponentActivity() {
|
||||
if (packetSize > 0) {
|
||||
appViewModel.senderPacketSize = packetSize
|
||||
}
|
||||
val packetInterval = intent.getIntExtra("packet-interval", 0)
|
||||
if (packetInterval > 0) {
|
||||
appViewModel.senderPacketInterval = packetInterval
|
||||
}
|
||||
appViewModel.updateSenderPacketSizeSlider()
|
||||
intent.getStringExtra("scenario")?.let {
|
||||
when (it) {
|
||||
"send" -> appViewModel.scenario = SEND_SCENARIO
|
||||
"receive" -> appViewModel.scenario = RECEIVE_SCENARIO
|
||||
"ping" -> appViewModel.scenario = PING_SCENARIO
|
||||
"pong" -> appViewModel.scenario = PONG_SCENARIO
|
||||
}
|
||||
}
|
||||
intent.getStringExtra("mode")?.let {
|
||||
when (it) {
|
||||
"rfcomm-client" -> appViewModel.mode = RFCOMM_CLIENT_MODE
|
||||
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
|
||||
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
|
||||
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
|
||||
}
|
||||
}
|
||||
intent.getStringExtra("autostart")?.let {
|
||||
when (it) {
|
||||
"rfcomm-client" -> runRfcommClient()
|
||||
"rfcomm-server" -> runRfcommServer()
|
||||
"l2cap-client" -> runL2capClient()
|
||||
"l2cap-server" -> runL2capServer()
|
||||
"run-scenario" -> runScenario()
|
||||
"scan-start" -> runScan(true)
|
||||
"stop-start" -> runScan(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runRfcommClient() {
|
||||
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
|
||||
rfcommClient?.run()
|
||||
}
|
||||
private fun runScenario() {
|
||||
if (bluetoothAdapter == null) {
|
||||
return
|
||||
}
|
||||
|
||||
private fun runRfcommServer() {
|
||||
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
|
||||
rfcommServer?.run()
|
||||
}
|
||||
|
||||
private fun runL2capClient() {
|
||||
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
|
||||
l2capClient?.run()
|
||||
}
|
||||
|
||||
private fun runL2capServer() {
|
||||
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
|
||||
l2capServer?.run()
|
||||
val runner = when (appViewModel.mode) {
|
||||
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
L2CAP_CLIENT_MODE -> L2capClient(
|
||||
appViewModel,
|
||||
bluetoothAdapter!!,
|
||||
baseContext,
|
||||
::createIoClient
|
||||
)
|
||||
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
runner.run()
|
||||
}
|
||||
|
||||
private fun runScan(startScan: Boolean) {
|
||||
@@ -197,6 +218,17 @@ class MainActivity : ComponentActivity() {
|
||||
scan?.run(startScan)
|
||||
}
|
||||
|
||||
private fun createIoClient(packetIo: PacketIO): IoClient {
|
||||
return when (appViewModel.scenario) {
|
||||
SEND_SCENARIO -> Sender(appViewModel, packetIo)
|
||||
RECEIVE_SCENARIO -> Receiver(appViewModel, packetIo)
|
||||
PING_SCENARIO -> Pinger(appViewModel, packetIo)
|
||||
PONG_SCENARIO -> Ponger(appViewModel, packetIo)
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun becomeDiscoverable() {
|
||||
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||
@@ -210,10 +242,7 @@ class MainActivity : ComponentActivity() {
|
||||
fun MainView(
|
||||
appViewModel: AppViewModel,
|
||||
becomeDiscoverable: () -> Unit,
|
||||
runRfcommClient: () -> Unit,
|
||||
runRfcommServer: () -> Unit,
|
||||
runL2capClient: () -> Unit,
|
||||
runL2capServer: () -> Unit,
|
||||
runScenario: () -> Unit,
|
||||
) {
|
||||
BTBenchTheme {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -239,7 +268,9 @@ fun MainView(
|
||||
Text(text = "Peer Bluetooth Address")
|
||||
},
|
||||
value = appViewModel.peerBluetoothAddress,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||
),
|
||||
@@ -249,14 +280,18 @@ fun MainView(
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}),
|
||||
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||
)
|
||||
Divider()
|
||||
TextField(label = {
|
||||
Text(text = "L2CAP PSM")
|
||||
},
|
||||
TextField(
|
||||
label = {
|
||||
Text(text = "L2CAP PSM")
|
||||
},
|
||||
value = appViewModel.l2capPsm.toString(),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
@@ -271,7 +306,8 @@ fun MainView(
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}),
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||
)
|
||||
Divider()
|
||||
Slider(
|
||||
@@ -290,6 +326,32 @@ fun MainView(
|
||||
)
|
||||
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
||||
Divider()
|
||||
TextField(
|
||||
label = {
|
||||
Text(text = "Packet Interval (ms)")
|
||||
},
|
||||
value = appViewModel.senderPacketInterval.toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
onValueChange = {
|
||||
if (it.isNotEmpty()) {
|
||||
val interval = it.toIntOrNull()
|
||||
if (interval != null) {
|
||||
appViewModel.updateSenderPacketInterval(interval)
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}),
|
||||
enabled = (appViewModel.scenario == PING_SCENARIO)
|
||||
)
|
||||
Divider()
|
||||
ActionButton(
|
||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||
)
|
||||
@@ -300,25 +362,78 @@ fun MainView(
|
||||
Text(text = "2M PHY")
|
||||
Spacer(modifier = Modifier.padding(start = 8.dp))
|
||||
Switch(
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
|
||||
checked = appViewModel.use2mPhy,
|
||||
onCheckedChange = { appViewModel.use2mPhy = it }
|
||||
)
|
||||
|
||||
}
|
||||
Row {
|
||||
ActionButton(
|
||||
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
||||
)
|
||||
ActionButton(
|
||||
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
||||
)
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
RFCOMM_CLIENT_MODE,
|
||||
RFCOMM_SERVER_MODE,
|
||||
L2CAP_CLIENT_MODE,
|
||||
L2CAP_SERVER_MODE
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
.selectable(
|
||||
selected = (text == appViewModel.mode),
|
||||
onClick = { appViewModel.updateMode(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.mode),
|
||||
onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
SEND_SCENARIO,
|
||||
RECEIVE_SCENARIO,
|
||||
PING_SCENARIO,
|
||||
PONG_SCENARIO
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
.selectable(
|
||||
selected = (text == appViewModel.scenario),
|
||||
onClick = { appViewModel.updateScenario(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.scenario),
|
||||
onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
ActionButton(
|
||||
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
||||
text = "Start", onClick = runScenario, enabled = !appViewModel.running
|
||||
)
|
||||
ActionButton(
|
||||
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
|
||||
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
@@ -328,6 +443,12 @@ fun MainView(
|
||||
Text(
|
||||
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
||||
)
|
||||
Text(
|
||||
text = "Status: ${appViewModel.status}"
|
||||
)
|
||||
Text(
|
||||
text = "Last Error: ${appViewModel.lastError}"
|
||||
)
|
||||
Text(
|
||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||
)
|
||||
@@ -337,9 +458,8 @@ fun MainView(
|
||||
Text(
|
||||
text = "Throughput: ${appViewModel.throughput}"
|
||||
)
|
||||
Divider()
|
||||
ActionButton(
|
||||
text = "Abort", onClick = appViewModel::abort, appViewModel.running
|
||||
Text(
|
||||
text = "Stats: ${appViewModel.stats}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -351,4 +471,4 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
|
||||
Button(onClick = onClick, enabled = enabled) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
interface Mode {
|
||||
fun run()
|
||||
fun waitForCompletion()
|
||||
}
|
||||
@@ -27,10 +27,25 @@ val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF
|
||||
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
|
||||
const val DEFAULT_PSM = 128
|
||||
|
||||
const val L2CAP_CLIENT_MODE = "L2CAP Client"
|
||||
const val L2CAP_SERVER_MODE = "L2CAP Server"
|
||||
const val RFCOMM_CLIENT_MODE = "RFCOMM Client"
|
||||
const val RFCOMM_SERVER_MODE = "RFCOMM Server"
|
||||
|
||||
const val SEND_SCENARIO = "Send"
|
||||
const val RECEIVE_SCENARIO = "Receive"
|
||||
const val PING_SCENARIO = "Ping"
|
||||
const val PONG_SCENARIO = "Pong"
|
||||
|
||||
class AppViewModel : ViewModel() {
|
||||
private var preferences: SharedPreferences? = null
|
||||
var status by mutableStateOf("")
|
||||
var lastError by mutableStateOf("")
|
||||
var mode by mutableStateOf(RFCOMM_SERVER_MODE)
|
||||
var scenario by mutableStateOf(RECEIVE_SCENARIO)
|
||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||
var use2mPhy by mutableStateOf(true)
|
||||
@@ -41,9 +56,11 @@ class AppViewModel : ViewModel() {
|
||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
||||
var senderPacketInterval by mutableIntStateOf(DEFAULT_SENDER_PACKET_INTERVAL)
|
||||
var packetsSent by mutableIntStateOf(0)
|
||||
var packetsReceived by mutableIntStateOf(0)
|
||||
var throughput by mutableIntStateOf(0)
|
||||
var stats by mutableStateOf("")
|
||||
var running by mutableStateOf(false)
|
||||
var aborter: (() -> Unit)? = null
|
||||
|
||||
@@ -66,6 +83,21 @@ class AppViewModel : ViewModel() {
|
||||
senderPacketSize = savedSenderPacketSize
|
||||
}
|
||||
updateSenderPacketSizeSlider()
|
||||
|
||||
val savedSenderPacketInterval = preferences.getInt(SENDER_PACKET_INTERVAL_PREF_KEY, -1)
|
||||
if (savedSenderPacketInterval != -1) {
|
||||
senderPacketInterval = savedSenderPacketInterval
|
||||
}
|
||||
|
||||
val savedMode = preferences.getString(MODE_PREF_KEY, null)
|
||||
if (savedMode != null) {
|
||||
mode = savedMode
|
||||
}
|
||||
|
||||
val savedScenario = preferences.getString(SCENARIO_PREF_KEY, null)
|
||||
if (savedScenario != null) {
|
||||
scenario = savedScenario
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||
@@ -164,6 +196,42 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSenderPacketInterval(senderPacketInterval: Int) {
|
||||
this.senderPacketInterval = senderPacketInterval
|
||||
with(preferences!!.edit()) {
|
||||
putInt(SENDER_PACKET_INTERVAL_PREF_KEY, senderPacketInterval)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateScenario(scenario: String) {
|
||||
this.scenario = scenario
|
||||
with(preferences!!.edit()) {
|
||||
putString(SCENARIO_PREF_KEY, scenario)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMode(mode: String) {
|
||||
this.mode = mode
|
||||
with(preferences!!.edit()) {
|
||||
putString(MODE_PREF_KEY, mode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
status = ""
|
||||
lastError = ""
|
||||
mtu = 0
|
||||
rxPhy = 0
|
||||
txPhy = 0
|
||||
packetsSent = 0
|
||||
packetsReceived = 0
|
||||
throughput = 0
|
||||
stats = ""
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
aborter?.let { it() }
|
||||
}
|
||||
|
||||
@@ -74,13 +74,13 @@ abstract class PacketSink {
|
||||
fun onPacket(packet: Packet) {
|
||||
when (packet) {
|
||||
is ResetPacket -> onResetPacket()
|
||||
is AckPacket -> onAckPacket()
|
||||
is AckPacket -> onAckPacket(packet)
|
||||
is SequencePacket -> onSequencePacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun onResetPacket()
|
||||
abstract fun onAckPacket()
|
||||
abstract fun onAckPacket(packet: AckPacket)
|
||||
abstract fun onSequencePacket(packet: SequencePacket)
|
||||
}
|
||||
|
||||
@@ -175,4 +175,4 @@ class SocketDataSource(
|
||||
} while (true)
|
||||
Log.info("end of stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||
|
||||
private val Log = Logger.getLogger("btbench.pinger")
|
||||
|
||||
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||
PacketSink() {
|
||||
private val pingTimes: ArrayList<TimeSource.Monotonic.ValueTimeMark> = ArrayList()
|
||||
private val rtts: ArrayList<Long> = ArrayList()
|
||||
private val done = Semaphore(0)
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
|
||||
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("running")
|
||||
|
||||
Log.info("sending reset")
|
||||
packetIO.sendPacket(ResetPacket())
|
||||
|
||||
val packetCount = viewModel.senderPacketCount
|
||||
val packetSize = viewModel.senderPacketSize
|
||||
|
||||
val startTime = TimeSource.Monotonic.markNow()
|
||||
for (i in 0..<packetCount) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||
val delay = targetTime - now
|
||||
if (delay.isPositive()) {
|
||||
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||
Thread.sleep(delay.inWholeMilliseconds)
|
||||
}
|
||||
pingTimes.add(TimeSource.Monotonic.markNow())
|
||||
packetIO.sendPacket(
|
||||
SequencePacket(
|
||||
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
|
||||
i,
|
||||
ByteArray(packetSize - 6)
|
||||
)
|
||||
)
|
||||
viewModel.packetsSent = i + 1
|
||||
}
|
||||
|
||||
// Wait for the last ACK
|
||||
Log.info("waiting for last ACK")
|
||||
done.acquire()
|
||||
Log.info("got last ACK")
|
||||
}
|
||||
|
||||
override fun abort() {
|
||||
done.release()
|
||||
}
|
||||
|
||||
override fun onResetPacket() {
|
||||
}
|
||||
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
viewModel.packetsReceived += 1
|
||||
if (packet.sequenceNumber < pingTimes.size) {
|
||||
val rtt = (now - pingTimes[packet.sequenceNumber]).inWholeMilliseconds
|
||||
rtts.add(rtt)
|
||||
Log.info("received ACK ${packet.sequenceNumber}, RTT=$rtt")
|
||||
} else {
|
||||
Log.warning("received ACK with unexpected sequence ${packet.sequenceNumber}")
|
||||
}
|
||||
|
||||
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||
Log.info("last packet received")
|
||||
val stats = "RTTs: min=${rtts.min()}, max=${rtts.max()}, avg=${rtts.sum() / rtts.size}"
|
||||
Log.info(stats)
|
||||
viewModel.stats = stats
|
||||
done.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSequencePacket(packet: SequencePacket) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private val Log = Logger.getLogger("btbench.receiver")
|
||||
|
||||
class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var expectedSequenceNumber: Int = 0
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
}
|
||||
|
||||
override fun abort() {}
|
||||
|
||||
override fun onResetPacket() {
|
||||
startTime = TimeSource.Monotonic.markNow()
|
||||
lastPacketTime = startTime
|
||||
expectedSequenceNumber = 0
|
||||
viewModel.packetsSent = 0
|
||||
viewModel.packetsReceived = 0
|
||||
viewModel.stats = ""
|
||||
}
|
||||
|
||||
override fun onAckPacket(packet: AckPacket) {
|
||||
}
|
||||
|
||||
override fun onSequencePacket(packet: SequencePacket) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
lastPacketTime = now
|
||||
viewModel.packetsReceived += 1
|
||||
|
||||
if (packet.sequenceNumber != expectedSequenceNumber) {
|
||||
Log.warning("unexpected packet sequence number (expected ${expectedSequenceNumber}, got ${packet.sequenceNumber})")
|
||||
}
|
||||
expectedSequenceNumber += 1
|
||||
|
||||
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||
viewModel.packetsSent += 1
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -16,20 +16,27 @@ package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import java.io.IOException
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
||||
|
||||
class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||
class RfcommServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var socketServer: SocketServer? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun run() {
|
||||
override fun run() {
|
||||
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
||||
"BumbleBench", DEFAULT_RFCOMM_UUID
|
||||
)
|
||||
|
||||
val server = SocketServer(viewModel, serverSocket)
|
||||
server.run({}, {})
|
||||
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||
socketServer!!.run({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
socketServer?.waitForCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ class Scan(val bluetoothAdapter: BluetoothAdapter) {
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-13
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -21,7 +21,13 @@ import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.socket-server")
|
||||
|
||||
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
||||
class SocketServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val serverSocket: BluetoothServerSocket,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) {
|
||||
private var serverThread: Thread? = null
|
||||
|
||||
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
|
||||
var aborted = false
|
||||
viewModel.running = true
|
||||
@@ -31,7 +37,7 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
viewModel.running = false
|
||||
}
|
||||
|
||||
thread(name = "SocketServer") {
|
||||
serverThread = thread(name = "SocketServer") {
|
||||
while (!aborted) {
|
||||
viewModel.aborter = {
|
||||
serverSocket.close()
|
||||
@@ -46,6 +52,8 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
return@thread
|
||||
}
|
||||
Log.info("got connection from ${socket.remoteDevice.address}")
|
||||
Log.info("maxReceivePacketSize=${socket.maxReceivePacketSize}")
|
||||
Log.info("maxTransmitPacketSize=${socket.maxTransmitPacketSize}")
|
||||
onConnected()
|
||||
|
||||
viewModel.aborter = {
|
||||
@@ -57,11 +65,22 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
||||
val socketDataSink = SocketDataSink(socket)
|
||||
val streamIO = StreamedPacketIO(socketDataSink)
|
||||
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||
val receiver = Receiver(viewModel, streamIO)
|
||||
|
||||
val ioThread = thread(name = "IoClient") {
|
||||
val ioClient = createIoClient(streamIO)
|
||||
ioClient.run()
|
||||
}
|
||||
|
||||
socketDataSource.receive()
|
||||
socket.close()
|
||||
ioThread.join()
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForCompletion() {
|
||||
serverThread?.join()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.2.0"
|
||||
agp = "8.4.0"
|
||||
kotlin = "1.9.0"
|
||||
core-ktx = "1.12.0"
|
||||
junit = "4.13.2"
|
||||
@@ -8,6 +8,8 @@ espresso-core = "3.5.1"
|
||||
lifecycle-runtime-ktx = "2.6.2"
|
||||
activity-compose = "1.7.2"
|
||||
compose-bom = "2023.08.00"
|
||||
mobly-snippet = "1.4.0"
|
||||
core = "1.6.1"
|
||||
|
||||
[libraries]
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
||||
@@ -24,6 +26,8 @@ ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview
|
||||
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
mobly-snippet = { group = "com.google.android.mobly", name = "mobly-snippet-lib", version.ref = "mobly.snippet" }
|
||||
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#Wed Oct 25 07:40:52 PDT 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -55,7 +55,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.*"
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
# Invoke this script with two arguments:
|
||||
# Arg 1: directory path where the AOSP `tools/netsim/proto` directory is located
|
||||
# Arg 2: directory path where the RootCanal `proto/rootcanal` directory is located
|
||||
# Invoke this script with an argument pointing to where the AOSP `tools/netsim/src/proto` is
|
||||
PROTOC_OUT=bumble/transport/grpc_protobuf
|
||||
|
||||
netsim_proto_files=(netsim/common.proto netsim/packet_streamer.proto netsim/hci_packet.proto netsim/startup.proto netsim/model.proto)
|
||||
for proto_file in "${netsim_proto_files[@]}"
|
||||
proto_files=(common.proto packet_streamer.proto hci_packet.proto startup.proto)
|
||||
for proto_file in "${proto_files[@]}"
|
||||
do
|
||||
python -m grpc_tools.protoc -I$1 -I$2 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file
|
||||
python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file
|
||||
done
|
||||
|
||||
rootcanal_proto_files=(rootcanal/configuration.proto)
|
||||
for proto_file in "${rootcanal_proto_files[@]}"
|
||||
do
|
||||
python -m grpc_tools.protoc -I$1 -I$2 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $2/$proto_file
|
||||
done
|
||||
|
||||
python_files=(netsim/*.py netsim/*.pyi)
|
||||
python_files=(packet_streamer_pb2_grpc.py packet_streamer_pb2.py hci_packet_pb2.py startup_pb2.py)
|
||||
for python_file in "${python_files[@]}"
|
||||
do
|
||||
sed -i '' 's/^from netsim/from bumble.transport.grpc_protobuf.netsim/' $PROTOC_OUT/$python_file
|
||||
sed -i '' 's/^from rootcanal/from bumble.transport.grpc_protobuf.rootcanal/' $PROTOC_OUT/$python_file
|
||||
sed -i 's/^import .*_pb2 as/from . \0/' $PROTOC_OUT/$python_file
|
||||
done
|
||||
|
||||
@@ -70,7 +70,6 @@ console_scripts =
|
||||
bumble-usb-probe = bumble.apps.usb_probe:main
|
||||
bumble-link-relay = bumble.apps.link_relay.link_relay:main
|
||||
bumble-bench = bumble.apps.bench:main
|
||||
bumble-player = bumble.apps.player.player:main
|
||||
bumble-speaker = bumble.apps.speaker.speaker:main
|
||||
bumble-pandora-server = bumble.apps.pandora_server:main
|
||||
bumble-rtk-util = bumble.tools.rtk_util:main
|
||||
@@ -91,6 +90,7 @@ development =
|
||||
black == 24.3
|
||||
grpcio-tools >= 1.62.1
|
||||
invoke >= 1.7.3
|
||||
mobly >= 1.12.2
|
||||
mypy == 1.10.0
|
||||
nox >= 2022
|
||||
pylint == 3.1.0
|
||||
|
||||
+29
-146
@@ -33,16 +33,20 @@ from bumble.avdtp import (
|
||||
Protocol,
|
||||
Listener,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacket,
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
AVDTP_TSEP_SNK,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
AacMediaCodecInformation,
|
||||
OpusMediaCodecInformation,
|
||||
SbcMediaCodecInformation,
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
)
|
||||
from bumble.rtp import MediaPacket
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -121,12 +125,12 @@ def source_codec_capabilities():
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||
media_codec_information=SbcMediaCodecInformation.from_discrete_values(
|
||||
sampling_frequency=44100,
|
||||
channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
block_length=16,
|
||||
subbands=8,
|
||||
allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
@@ -138,23 +142,20 @@ def sink_codec_capabilities():
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||
| SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
@@ -272,125 +273,7 @@ async def test_source_sink_1():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_sbc_codec_specific_information():
|
||||
sbc_info = SbcMediaCodecInformation.from_bytes(bytes.fromhex("3fff0235"))
|
||||
assert (
|
||||
sbc_info.sampling_frequency
|
||||
== SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
)
|
||||
assert (
|
||||
sbc_info.channel_mode
|
||||
== SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO
|
||||
)
|
||||
assert (
|
||||
sbc_info.block_length
|
||||
== SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16
|
||||
)
|
||||
assert (
|
||||
sbc_info.subbands
|
||||
== SbcMediaCodecInformation.Subbands.S_4 | SbcMediaCodecInformation.Subbands.S_8
|
||||
)
|
||||
assert (
|
||||
sbc_info.allocation_method
|
||||
== SbcMediaCodecInformation.AllocationMethod.SNR
|
||||
| SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
)
|
||||
assert sbc_info.minimum_bitpool_value == 2
|
||||
assert sbc_info.maximum_bitpool_value == 53
|
||||
|
||||
sbc_info2 = SbcMediaCodecInformation(
|
||||
SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_48000,
|
||||
SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
SbcMediaCodecInformation.Subbands.S_4 | SbcMediaCodecInformation.Subbands.S_8,
|
||||
SbcMediaCodecInformation.AllocationMethod.SNR
|
||||
| SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||
2,
|
||||
53,
|
||||
)
|
||||
assert sbc_info == sbc_info2
|
||||
assert bytes(sbc_info2) == bytes.fromhex("3fff0235")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_aac_codec_specific_information():
|
||||
aac_info = AacMediaCodecInformation.from_bytes(bytes.fromhex("f0018c83e800"))
|
||||
assert (
|
||||
aac_info.object_type
|
||||
== AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LC
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE
|
||||
)
|
||||
assert (
|
||||
aac_info.sampling_frequency
|
||||
== AacMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
)
|
||||
assert (
|
||||
aac_info.channels
|
||||
== AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO
|
||||
)
|
||||
assert aac_info.vbr == 1
|
||||
assert aac_info.bitrate == 256000
|
||||
|
||||
aac_info2 = AacMediaCodecInformation(
|
||||
AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LC
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP
|
||||
| AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE,
|
||||
AacMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_48000,
|
||||
AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO,
|
||||
1,
|
||||
256000,
|
||||
)
|
||||
assert aac_info == aac_info2
|
||||
assert bytes(aac_info2) == bytes.fromhex("f0018c83e800")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_opus_codec_specific_information():
|
||||
opus_info = OpusMediaCodecInformation.from_bytes(bytes([0x92]))
|
||||
assert opus_info.vendor_id == OpusMediaCodecInformation.VENDOR_ID
|
||||
assert opus_info.codec_id == OpusMediaCodecInformation.CODEC_ID
|
||||
assert opus_info.frame_size == OpusMediaCodecInformation.FrameSize.FS_20MS
|
||||
assert opus_info.channel_mode == OpusMediaCodecInformation.ChannelMode.STEREO
|
||||
assert (
|
||||
opus_info.sampling_frequency
|
||||
== OpusMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
)
|
||||
|
||||
opus_info2 = OpusMediaCodecInformation(
|
||||
OpusMediaCodecInformation.ChannelMode.STEREO,
|
||||
OpusMediaCodecInformation.FrameSize.FS_20MS,
|
||||
OpusMediaCodecInformation.SamplingFrequency.SF_48000,
|
||||
)
|
||||
assert opus_info2 == opus_info
|
||||
assert opus_info2.value == bytes([0x92])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main():
|
||||
test_sbc_codec_specific_information()
|
||||
test_aac_codec_specific_information()
|
||||
test_opus_codec_specific_information()
|
||||
async def run_test_self():
|
||||
await test_self_connection()
|
||||
await test_source_sink_1()
|
||||
|
||||
@@ -398,4 +281,4 @@ async def async_main():
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
asyncio.run(async_main())
|
||||
asyncio.run(run_test_self())
|
||||
|
||||
+2
-1
@@ -23,12 +23,13 @@ from bumble.avdtp import (
|
||||
AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY,
|
||||
AVDTP_SET_CONFIGURATION,
|
||||
Message,
|
||||
MediaPacket,
|
||||
Get_Capabilities_Response,
|
||||
Set_Configuration_Command,
|
||||
Set_Configuration_Response,
|
||||
ServiceCapabilities,
|
||||
MediaCodecCapabilities,
|
||||
)
|
||||
from bumble.rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+2
-42
@@ -15,9 +15,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import random
|
||||
import pytest
|
||||
from bumble.codecs import AacAudioRtpPacket, BitReader, BitWriter
|
||||
from bumble.codecs import AacAudioRtpPacket, BitReader
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -50,58 +49,19 @@ def test_reader():
|
||||
assert value == int.from_bytes(data, byteorder='big')
|
||||
|
||||
|
||||
def test_writer():
|
||||
writer = BitWriter()
|
||||
assert bytes(writer) == b''
|
||||
|
||||
for i in range(100):
|
||||
for j in range(1, 10):
|
||||
writer = BitWriter()
|
||||
chunks = []
|
||||
for k in range(j):
|
||||
n_bits = random.randint(1, 32)
|
||||
random_bits = random.getrandbits(n_bits)
|
||||
chunks.append((n_bits, random_bits))
|
||||
writer.write(random_bits, n_bits)
|
||||
|
||||
written_data = bytes(writer)
|
||||
reader = BitReader(written_data)
|
||||
for n_bits, written_bits in chunks:
|
||||
read_bits = reader.read(n_bits)
|
||||
assert read_bits == written_bits
|
||||
|
||||
|
||||
def test_aac_rtp():
|
||||
# pylint: disable=line-too-long
|
||||
packet_data = bytes.fromhex(
|
||||
'47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c'
|
||||
)
|
||||
packet = AacAudioRtpPacket.from_bytes(packet_data)
|
||||
packet = AacAudioRtpPacket(packet_data)
|
||||
adts = packet.to_adts()
|
||||
assert adts == bytes.fromhex(
|
||||
'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c'
|
||||
)
|
||||
|
||||
payload = bytes(list(range(1, 200)))
|
||||
rtp = AacAudioRtpPacket.for_simple_aac(44100, 2, payload)
|
||||
assert rtp.audio_mux_element.payload == payload
|
||||
assert (
|
||||
rtp.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency
|
||||
== 44100
|
||||
)
|
||||
assert (
|
||||
rtp.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration
|
||||
== 2
|
||||
)
|
||||
rtp2 = AacAudioRtpPacket.from_bytes(bytes(rtp))
|
||||
assert str(rtp2.audio_mux_element.stream_mux_config) == str(
|
||||
rtp.audio_mux_element.stream_mux_config
|
||||
)
|
||||
assert rtp2.audio_mux_element.payload == rtp.audio_mux_element.payload
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_reader()
|
||||
test_writer()
|
||||
test_aac_rtp()
|
||||
|
||||
@@ -25,7 +25,6 @@ import sys
|
||||
from bumble import att, device
|
||||
from bumble.profiles import hap
|
||||
from .test_utils import TwoDevices
|
||||
from bumble.keys import PairingKeys
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -87,10 +86,6 @@ async def hap_client():
|
||||
devices.connections[0].encryption = 1 # type: ignore
|
||||
devices.connections[1].encryption = 1 # type: ignore
|
||||
|
||||
devices[0].on_pairing(
|
||||
devices.connections[0], devices.connections[0].peer_address, PairingKeys(), True
|
||||
)
|
||||
|
||||
peer = device.Peer(devices.connections[1]) # type: ignore
|
||||
hap_client = await peer.discover_service_and_create_proxy(
|
||||
hap.HearingAccessServiceProxy
|
||||
|
||||
@@ -569,37 +569,6 @@ async def test_sco_setup():
|
||||
await asyncio.gather(*sco_disconnection_futures)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_hf_batched_response(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
|
||||
ag.dlc.write(b'\r\n+BIND: (1,2)\r\n\r\nOK\r\n')
|
||||
|
||||
await hf.execute_command("AT+BIND=?", response_type=hfp.AtResponseType.SINGLE)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_ag_batched_commands(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
|
||||
answer_future = asyncio.get_running_loop().create_future()
|
||||
ag.on('answer', lambda: answer_future.set_result(None))
|
||||
|
||||
hang_up_future = asyncio.get_running_loop().create_future()
|
||||
ag.on('hang_up', lambda: hang_up_future.set_result(None))
|
||||
|
||||
hf.dlc.write(b'ATA\rAT+CHUP\r')
|
||||
|
||||
await answer_future
|
||||
await hang_up_future
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run():
|
||||
await test_slc()
|
||||
|
||||
+31
-28
@@ -28,18 +28,26 @@ from bumble.avdtp import (
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
Listener,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacket,
|
||||
Protocol,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
make_audio_sink_service_sdp_records,
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SbcMediaCodecInformation,
|
||||
AacMediaCodecInformation,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.codecs import AacAudioRtpPacket
|
||||
from bumble.hci import HCI_Reset_Command
|
||||
from bumble.rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -64,7 +72,7 @@ class AudioExtractor:
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
return AacAudioRtpPacket.from_bytes(packet.payload).to_adts()
|
||||
return AacAudioRtpPacket(packet.payload).to_adts()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -122,12 +130,10 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
channels=AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO,
|
||||
media_codec_information=AacMediaCodecInformation.from_lists(
|
||||
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
||||
sampling_frequencies=[48000, 44100],
|
||||
channels=[1, 2],
|
||||
vbr=1,
|
||||
bitrate=256000,
|
||||
),
|
||||
@@ -137,23 +143,20 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||
| SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
@@ -279,6 +282,9 @@ class Speaker:
|
||||
mitm=False
|
||||
)
|
||||
|
||||
# Start the controller
|
||||
await self.device.power_on()
|
||||
|
||||
# Listen for Bluetooth connections
|
||||
self.device.on('connection', self.on_bluetooth_connection)
|
||||
|
||||
@@ -289,9 +295,6 @@ class Speaker:
|
||||
self.avdtp_listener = Listener.for_device(self.device)
|
||||
self.avdtp_listener.on('connection', self.on_avdtp_connection)
|
||||
|
||||
# Start the controller
|
||||
await self.device.power_on()
|
||||
|
||||
print(f'Speaker ready to play, codec={self.codec}')
|
||||
|
||||
if connect_address:
|
||||
|
||||
Reference in New Issue
Block a user