forked from auracaster/bumble_mirror
Compare commits
81 Commits
gbg/usb-th
...
gbg/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00abd65b3 | ||
|
|
f169ceaebb | ||
|
|
528af0d338 | ||
|
|
4b25eed869 | ||
|
|
fcd6bd7136 | ||
|
|
32642c5d7c | ||
|
|
ff8b0c375d | ||
|
|
ae0228aeb8 | ||
|
|
5d2dac18c8 | ||
|
|
d03fc14cfd | ||
|
|
ad7ce79bc4 | ||
|
|
c6bf27fd2c | ||
|
|
7584daa3f9 | ||
|
|
654030e789 | ||
|
|
1de7d2cd6f | ||
|
|
68db78c833 | ||
|
|
e1714c16cc | ||
|
|
0a20f14ea9 | ||
|
|
23f46b36b3 | ||
|
|
009649abd1 | ||
|
|
855a007116 | ||
|
|
d064de35e0 | ||
|
|
dab4d13303 | ||
|
|
2bed50b353 | ||
|
|
1fe3778a74 | ||
|
|
f5443a9826 | ||
|
|
db723a5196 | ||
|
|
5e31bcf23d | ||
|
|
fe429cb2eb | ||
|
|
c91695c23a | ||
|
|
55f99e6887 | ||
|
|
b190069f48 | ||
|
|
e16be1a8f4 | ||
|
|
2fa8075fb0 | ||
|
|
566ca13d23 | ||
|
|
e5666c0510 | ||
|
|
46ec39ccfb | ||
|
|
eef418ae5f | ||
|
|
9e663ad051 | ||
|
|
f28eac4c14 | ||
|
|
669bb3f3a8 | ||
|
|
347fe8b272 | ||
|
|
d56c4d0a11 | ||
|
|
034140ccbd | ||
|
|
35bef7d7b7 | ||
|
|
d069708c79 | ||
|
|
bdba5c9d95 | ||
|
|
ff659383f9 | ||
|
|
f06a35713f | ||
|
|
737abdc481 | ||
|
|
02eb4d2e1c | ||
|
|
e7f9acb421 | ||
|
|
976e6cce57 | ||
|
|
dfdf37019c | ||
|
|
56ca19600b | ||
|
|
cd9feeb455 | ||
|
|
f8e5b88be6 | ||
|
|
0f71a63b42 | ||
|
|
b7259abe3c | ||
|
|
00e660d410 | ||
|
|
88e3a2b87f | ||
|
|
aa658418bc | ||
|
|
ac0cff43b6 | ||
|
|
8051c23375 | ||
|
|
7b34bb4050 | ||
|
|
fe38ab35cf | ||
|
|
65a9102ba1 | ||
|
|
1256170985 | ||
|
|
4394a36332 | ||
|
|
0c9fd64434 | ||
|
|
2e99153696 | ||
|
|
54a6f3cb36 | ||
|
|
4a691c11d4 | ||
|
|
b114c0d63f | ||
|
|
04311b4c90 | ||
|
|
c44c89cc6e | ||
|
|
414f2f3efb | ||
|
|
ed00d44ae1 | ||
|
|
b164524380 | ||
|
|
29e4a843df | ||
|
|
619b32d36e |
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
7
.github/workflows/python-avatar.yml
vendored
7
.github/workflows/python-avatar.yml
vendored
@@ -40,4 +40,11 @@ jobs:
|
|||||||
avatar --list | grep -Ev '^=' > test-names.txt
|
avatar --list | grep -Ev '^=' > test-names.txt
|
||||||
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
|
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
|
||||||
- name: Rootcanal Logs
|
- name: Rootcanal Logs
|
||||||
|
if: always()
|
||||||
run: cat rootcanal.log
|
run: cat rootcanal.log
|
||||||
|
- name: Upload Mobly logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mobly-logs
|
||||||
|
path: /tmp/logs/mobly/bumble.bumbles/
|
||||||
|
|||||||
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
rust-version: [ "1.76.0", "stable" ]
|
rust-version: [ "1.76.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
231
apps/bench.py
231
apps/bench.py
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import statistics
|
||||||
import struct
|
import struct
|
||||||
import time
|
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_min = min(stats)
|
||||||
stats_max = max(stats)
|
stats_max = max(stats)
|
||||||
stats_avg = sum(stats) / len(stats)
|
stats_avg = statistics.mean(stats)
|
||||||
|
stats_stdev = statistics.stdev(stats)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
(
|
(
|
||||||
f'### {title} stats: '
|
f'### {title} stats: '
|
||||||
f'min={stats_min:.2f}, '
|
f'min={stats_min:.{precision}f}, '
|
||||||
f'max={stats_max:.2f}, '
|
f'max={stats_max:.{precision}f}, '
|
||||||
f'average={stats_avg:.2f}'
|
f'average={stats_avg:.{precision}f}, '
|
||||||
|
f'stdev={stats_stdev:.{precision}f}'
|
||||||
),
|
),
|
||||||
'cyan',
|
'cyan',
|
||||||
)
|
)
|
||||||
@@ -448,9 +451,9 @@ class Ping:
|
|||||||
self.repeat_delay = repeat_delay
|
self.repeat_delay = repeat_delay
|
||||||
self.pace = pace
|
self.pace = pace
|
||||||
self.done = asyncio.Event()
|
self.done = asyncio.Event()
|
||||||
self.current_packet_index = 0
|
self.ping_times = []
|
||||||
self.ping_sent_time = 0.0
|
self.rtts = []
|
||||||
self.latencies = []
|
self.next_expected_packet_index = 0
|
||||||
self.min_stats = []
|
self.min_stats = []
|
||||||
self.max_stats = []
|
self.max_stats = []
|
||||||
self.avg_stats = []
|
self.avg_stats = []
|
||||||
@@ -477,60 +480,57 @@ class Ping:
|
|||||||
logging.info(color('=== Sending RESET', 'magenta'))
|
logging.info(color('=== Sending RESET', 'magenta'))
|
||||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||||
|
|
||||||
self.current_packet_index = 0
|
packet_interval = self.pace / 1000
|
||||||
self.latencies = []
|
start_time = time.time()
|
||||||
await self.send_next_ping()
|
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()
|
await self.done.wait()
|
||||||
|
|
||||||
min_latency = min(self.latencies)
|
min_rtt = min(self.rtts)
|
||||||
max_latency = max(self.latencies)
|
max_rtt = max(self.rtts)
|
||||||
avg_latency = sum(self.latencies) / len(self.latencies)
|
avg_rtt = statistics.mean(self.rtts)
|
||||||
|
stdev_rtt = statistics.stdev(self.rtts)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
'@@@ Latencies: '
|
'@@@ RTTs: '
|
||||||
f'min={min_latency:.2f}, '
|
f'min={min_rtt:.2f}, '
|
||||||
f'max={max_latency:.2f}, '
|
f'max={max_rtt:.2f}, '
|
||||||
f'average={avg_latency:.2f}'
|
f'average={avg_rtt:.2f}, '
|
||||||
|
f'stdev={stdev_rtt:.2f}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.min_stats.append(min_latency)
|
self.min_stats.append(min_rtt)
|
||||||
self.max_stats.append(max_latency)
|
self.max_stats.append(max_rtt)
|
||||||
self.avg_stats.append(avg_latency)
|
self.avg_stats.append(avg_rtt)
|
||||||
|
|
||||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||||
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
||||||
|
|
||||||
if self.repeat:
|
if self.repeat:
|
||||||
log_stats('Min Latency', self.min_stats)
|
log_stats('Min RTT', self.min_stats)
|
||||||
log_stats('Max Latency', self.max_stats)
|
log_stats('Max RTT', self.max_stats)
|
||||||
log_stats('Average Latency', self.avg_stats)
|
log_stats('Average RTT', self.avg_stats)
|
||||||
|
|
||||||
if self.repeat:
|
if self.repeat:
|
||||||
logging.info(color('--- End of runs', 'blue'))
|
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):
|
def on_packet_received(self, packet):
|
||||||
elapsed = time.time() - self.ping_sent_time
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packet_type, packet_data = parse_packet(packet)
|
packet_type, packet_data = parse_packet(packet)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -542,21 +542,23 @@ class Ping:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if packet_type == PacketType.ACK:
|
if packet_type == PacketType.ACK:
|
||||||
latency = elapsed * 1000
|
elapsed = time.time() - self.ping_times[packet_index]
|
||||||
self.latencies.append(latency)
|
rtt = elapsed * 1000
|
||||||
|
self.rtts.append(rtt)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
|
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
|
||||||
'green',
|
'green',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if packet_index == self.current_packet_index:
|
if packet_index == self.next_expected_packet_index:
|
||||||
self.current_packet_index += 1
|
self.next_expected_packet_index += 1
|
||||||
else:
|
else:
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'!!! Unexpected packet, expected {self.current_packet_index} '
|
f'!!! Unexpected packet, '
|
||||||
|
f'expected {self.next_expected_packet_index} '
|
||||||
f'but received {packet_index}'
|
f'but received {packet_index}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -565,8 +567,6 @@ class Ping:
|
|||||||
self.done.set()
|
self.done.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
AsyncRunner.spawn(self.send_next_ping())
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Pong
|
# Pong
|
||||||
@@ -583,8 +583,11 @@ class Pong:
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.expected_packet_index = 0
|
self.expected_packet_index = 0
|
||||||
|
self.receive_times = []
|
||||||
|
|
||||||
def on_packet_received(self, packet):
|
def on_packet_received(self, packet):
|
||||||
|
self.receive_times.append(time.time())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packet_type, packet_data = parse_packet(packet)
|
packet_type, packet_data = parse_packet(packet)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -599,10 +602,16 @@ class Pong:
|
|||||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
interval = (
|
||||||
|
self.receive_times[-1] - self.receive_times[-2]
|
||||||
|
if len(self.receive_times) >= 2
|
||||||
|
else 0
|
||||||
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'<<< Received packet {packet_index}: '
|
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',
|
'green',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -623,8 +632,35 @@ class Pong:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if packet_flags & PACKET_FLAG_LAST and not self.linger:
|
if packet_flags & PACKET_FLAG_LAST:
|
||||||
self.done.set()
|
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):
|
async def run(self):
|
||||||
await self.done.wait()
|
await self.done.wait()
|
||||||
@@ -942,9 +978,12 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
||||||
connection, self.uuid
|
connection, self.uuid
|
||||||
)
|
)
|
||||||
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
if channel:
|
||||||
if channel == 0:
|
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||||
logging.info(color('!!! No RFComm service with this UUID found', 'red'))
|
else:
|
||||||
|
logging.warning(
|
||||||
|
color('!!! No RFComm service with this UUID found', 'red')
|
||||||
|
)
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1054,6 +1093,8 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
if self.credits_threshold is not None:
|
if self.credits_threshold is not None:
|
||||||
dlc.rx_credits_threshold = self.credits_threshold
|
dlc.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
|
self.ready.set()
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self):
|
||||||
assert self.dlc
|
assert self.dlc
|
||||||
await self.dlc.drain()
|
await self.dlc.drain()
|
||||||
@@ -1068,7 +1109,7 @@ class Central(Connection.Listener):
|
|||||||
transport,
|
transport,
|
||||||
peripheral_address,
|
peripheral_address,
|
||||||
classic,
|
classic,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
@@ -1081,7 +1122,7 @@ class Central(Connection.Listener):
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.peripheral_address = peripheral_address
|
self.peripheral_address = peripheral_address
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.role_factory = role_factory
|
self.scenario_factory = scenario_factory
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
self.authenticate = authenticate
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt or authenticate
|
self.encrypt = encrypt or authenticate
|
||||||
@@ -1134,7 +1175,7 @@ class Central(Connection.Listener):
|
|||||||
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
|
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
|
||||||
)
|
)
|
||||||
mode = self.mode_factory(self.device)
|
mode = self.mode_factory(self.device)
|
||||||
role = self.role_factory(mode)
|
scenario = self.scenario_factory(mode)
|
||||||
self.device.classic_enabled = self.classic
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
# Set up a pairing config factory with minimal requirements.
|
# Set up a pairing config factory with minimal requirements.
|
||||||
@@ -1215,7 +1256,7 @@ class Central(Connection.Listener):
|
|||||||
|
|
||||||
await mode.on_connection(self.connection)
|
await mode.on_connection(self.connection)
|
||||||
|
|
||||||
await role.run()
|
await scenario.run()
|
||||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||||
await self.connection.disconnect()
|
await self.connection.disconnect()
|
||||||
|
|
||||||
@@ -1246,7 +1287,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transport,
|
transport,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
classic,
|
classic,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
@@ -1254,11 +1295,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
):
|
):
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.role_factory = role_factory
|
self.scenario_factory = scenario_factory
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
self.extended_data_length = extended_data_length
|
self.extended_data_length = extended_data_length
|
||||||
self.role_switch = role_switch
|
self.role_switch = role_switch
|
||||||
self.role = None
|
self.scenario = None
|
||||||
self.mode = None
|
self.mode = None
|
||||||
self.device = None
|
self.device = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
@@ -1278,7 +1319,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
)
|
)
|
||||||
self.device.listener = self
|
self.device.listener = self
|
||||||
self.mode = self.mode_factory(self.device)
|
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
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
# Set up a pairing config factory with minimal requirements.
|
# Set up a pairing config factory with minimal requirements.
|
||||||
@@ -1315,7 +1356,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
await self.mode.on_connection(self.connection)
|
await self.mode.on_connection(self.connection)
|
||||||
await self.role.run()
|
await self.scenario.run()
|
||||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||||
|
|
||||||
def on_connection(self, connection):
|
def on_connection(self, connection):
|
||||||
@@ -1344,7 +1385,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
def on_disconnection(self, reason):
|
def on_disconnection(self, reason):
|
||||||
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||||
self.connection = None
|
self.connection = None
|
||||||
self.role.reset()
|
self.scenario.reset()
|
||||||
|
|
||||||
if self.classic:
|
if self.classic:
|
||||||
AsyncRunner.spawn(self.device.set_discoverable(True))
|
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):
|
def create_scenario_factory(ctx, default_scenario):
|
||||||
role = ctx.obj['role']
|
scenario = ctx.obj['scenario']
|
||||||
if role is None:
|
if scenario is None:
|
||||||
role = default_role
|
scenarion = default_scenario
|
||||||
|
|
||||||
def create_role(packet_io):
|
def create_scenario(packet_io):
|
||||||
if role == 'sender':
|
if scenario == 'send':
|
||||||
return Sender(
|
return Sender(
|
||||||
packet_io,
|
packet_io,
|
||||||
start_delay=ctx.obj['start_delay'],
|
start_delay=ctx.obj['start_delay'],
|
||||||
@@ -1443,10 +1484,10 @@ def create_role_factory(ctx, default_role):
|
|||||||
packet_count=ctx.obj['packet_count'],
|
packet_count=ctx.obj['packet_count'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if role == 'receiver':
|
if scenario == 'receive':
|
||||||
return Receiver(packet_io, ctx.obj['linger'])
|
return Receiver(packet_io, ctx.obj['linger'])
|
||||||
|
|
||||||
if role == 'ping':
|
if scenario == 'ping':
|
||||||
return Ping(
|
return Ping(
|
||||||
packet_io,
|
packet_io,
|
||||||
start_delay=ctx.obj['start_delay'],
|
start_delay=ctx.obj['start_delay'],
|
||||||
@@ -1457,12 +1498,12 @@ def create_role_factory(ctx, default_role):
|
|||||||
packet_count=ctx.obj['packet_count'],
|
packet_count=ctx.obj['packet_count'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if role == 'pong':
|
if scenario == 'pong':
|
||||||
return Pong(packet_io, ctx.obj['linger'])
|
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.group()
|
||||||
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
@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(
|
@click.option(
|
||||||
'--mode',
|
'--mode',
|
||||||
type=click.Choice(
|
type=click.Choice(
|
||||||
@@ -1503,7 +1544,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
'--rfcomm-channel',
|
'--rfcomm-channel',
|
||||||
type=int,
|
type=int,
|
||||||
default=DEFAULT_RFCOMM_CHANNEL,
|
default=DEFAULT_RFCOMM_CHANNEL,
|
||||||
help='RFComm channel to use',
|
help='RFComm channel to use (specify 0 for channel discovery via SDP)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--rfcomm-uuid',
|
'--rfcomm-uuid',
|
||||||
@@ -1565,7 +1606,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='SIZE',
|
metavar='SIZE',
|
||||||
type=click.IntRange(8, 8192),
|
type=click.IntRange(8, 8192),
|
||||||
default=500,
|
default=500,
|
||||||
help='Packet size (client or ping role)',
|
help='Packet size (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--packet-count',
|
'--packet-count',
|
||||||
@@ -1573,7 +1614,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='COUNT',
|
metavar='COUNT',
|
||||||
type=int,
|
type=int,
|
||||||
default=10,
|
default=10,
|
||||||
help='Packet count (client or ping role)',
|
help='Packet count (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--start-delay',
|
'--start-delay',
|
||||||
@@ -1581,7 +1622,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='SECONDS',
|
metavar='SECONDS',
|
||||||
type=int,
|
type=int,
|
||||||
default=1,
|
default=1,
|
||||||
help='Start delay (client or ping role)',
|
help='Start delay (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--repeat',
|
'--repeat',
|
||||||
@@ -1589,7 +1630,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
type=int,
|
type=int,
|
||||||
default=0,
|
default=0,
|
||||||
help=(
|
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) '
|
'(0, which is the fault, to run just once) '
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1613,13 +1654,13 @@ def create_role_factory(ctx, default_role):
|
|||||||
@click.option(
|
@click.option(
|
||||||
'--linger',
|
'--linger',
|
||||||
is_flag=True,
|
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
|
@click.pass_context
|
||||||
def bench(
|
def bench(
|
||||||
ctx,
|
ctx,
|
||||||
device_config,
|
device_config,
|
||||||
role,
|
scenario,
|
||||||
mode,
|
mode,
|
||||||
att_mtu,
|
att_mtu,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
@@ -1645,7 +1686,7 @@ def bench(
|
|||||||
):
|
):
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj['device_config'] = device_config
|
ctx.obj['device_config'] = device_config
|
||||||
ctx.obj['role'] = role
|
ctx.obj['scenario'] = scenario
|
||||||
ctx.obj['mode'] = mode
|
ctx.obj['mode'] = mode
|
||||||
ctx.obj['att_mtu'] = att_mtu
|
ctx.obj['att_mtu'] = att_mtu
|
||||||
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
||||||
@@ -1699,7 +1740,7 @@ def central(
|
|||||||
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
||||||
):
|
):
|
||||||
"""Run as a central (initiates the connection)"""
|
"""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')
|
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||||
classic = ctx.obj['classic']
|
classic = ctx.obj['classic']
|
||||||
|
|
||||||
@@ -1708,7 +1749,7 @@ def central(
|
|||||||
transport,
|
transport,
|
||||||
peripheral_address,
|
peripheral_address,
|
||||||
classic,
|
classic,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
@@ -1726,13 +1767,13 @@ def central(
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def peripheral(ctx, transport):
|
def peripheral(ctx, transport):
|
||||||
"""Run as a peripheral (waits for a connection)"""
|
"""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')
|
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
||||||
|
|
||||||
async def run_peripheral():
|
async def run_peripheral():
|
||||||
await Peripheral(
|
await Peripheral(
|
||||||
transport,
|
transport,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
ctx.obj['classic'],
|
ctx.obj['classic'],
|
||||||
ctx.obj['extended_data_length'],
|
ctx.obj['extended_data_length'],
|
||||||
@@ -1743,7 +1784,11 @@ def peripheral(ctx, transport):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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()
|
bench()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bumble.colors import color
|
|||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
|
CodecID,
|
||||||
LeFeature,
|
LeFeature,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_VERSION_NAMES,
|
HCI_VERSION_NAMES,
|
||||||
@@ -50,6 +51,8 @@ from bumble.hci import (
|
|||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
|
HCI_Read_Local_Supported_Codecs_Command,
|
||||||
|
HCI_Read_Local_Supported_Codecs_V2_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
@@ -168,6 +171,60 @@ async def get_acl_flow_control_info(host: Host) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def get_codecs_info(host: Host) -> None:
|
||||||
|
print()
|
||||||
|
|
||||||
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Local_Supported_Codecs_V2_Command(), check_result=True
|
||||||
|
)
|
||||||
|
print(color('Codecs:', 'yellow'))
|
||||||
|
|
||||||
|
for codec_id, transport in zip(
|
||||||
|
response.return_parameters.standard_codec_ids,
|
||||||
|
response.return_parameters.standard_codec_transports,
|
||||||
|
):
|
||||||
|
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
||||||
|
transport
|
||||||
|
).name
|
||||||
|
codec_name = CodecID(codec_id).name
|
||||||
|
print(f' {codec_name} - {transport_name}')
|
||||||
|
|
||||||
|
for codec_id, transport in zip(
|
||||||
|
response.return_parameters.vendor_specific_codec_ids,
|
||||||
|
response.return_parameters.vendor_specific_codec_transports,
|
||||||
|
):
|
||||||
|
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
||||||
|
transport
|
||||||
|
).name
|
||||||
|
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
||||||
|
print(f' {company} / {codec_id & 0xFFFF} - {transport_name}')
|
||||||
|
|
||||||
|
if not response.return_parameters.standard_codec_ids:
|
||||||
|
print(' No standard codecs')
|
||||||
|
if not response.return_parameters.vendor_specific_codec_ids:
|
||||||
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Local_Supported_Codecs_Command(), check_result=True
|
||||||
|
)
|
||||||
|
print(color('Codecs (BR/EDR):', 'yellow'))
|
||||||
|
for codec_id in response.return_parameters.standard_codec_ids:
|
||||||
|
codec_name = CodecID(codec_id).name
|
||||||
|
print(f' {codec_name}')
|
||||||
|
|
||||||
|
for codec_id in response.return_parameters.vendor_specific_codec_ids:
|
||||||
|
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
||||||
|
print(f' {company} / {codec_id & 0xFFFF}')
|
||||||
|
|
||||||
|
if not response.return_parameters.standard_codec_ids:
|
||||||
|
print(' No standard codecs')
|
||||||
|
if not response.return_parameters.vendor_specific_codec_ids:
|
||||||
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_main(latency_probes, transport):
|
async def async_main(latency_probes, transport):
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
@@ -220,6 +277,9 @@ async def async_main(latency_probes, transport):
|
|||||||
# Print the ACL flow control info
|
# Print the ACL flow control info
|
||||||
await get_acl_flow_control_info(host)
|
await get_acl_flow_control_info(host)
|
||||||
|
|
||||||
|
# Get codec info
|
||||||
|
await get_codecs_info(host)
|
||||||
|
|
||||||
# Print the list of commands supported by the controller
|
# Print the list of commands supported by the controller
|
||||||
print()
|
print()
|
||||||
print(color('Supported Commands:', 'yellow'))
|
print(color('Supported Commands:', 'yellow'))
|
||||||
|
|||||||
37
apps/pair.py
37
apps/pair.py
@@ -46,6 +46,12 @@ from bumble.att import (
|
|||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_ERROR,
|
ATT_INSUFFICIENT_ENCRYPTION_ERROR,
|
||||||
)
|
)
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
POST_PAIRING_DELAY = 1
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -235,8 +241,10 @@ def on_connection(connection, request):
|
|||||||
|
|
||||||
# Listen for pairing events
|
# Listen for pairing events
|
||||||
connection.on('pairing_start', on_pairing_start)
|
connection.on('pairing_start', on_pairing_start)
|
||||||
connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys))
|
connection.on('pairing', lambda keys: on_pairing(connection, keys))
|
||||||
connection.on('pairing_failure', on_pairing_failure)
|
connection.on(
|
||||||
|
'pairing_failure', lambda reason: on_pairing_failure(connection, reason)
|
||||||
|
)
|
||||||
|
|
||||||
# Listen for encryption changes
|
# Listen for encryption changes
|
||||||
connection.on(
|
connection.on(
|
||||||
@@ -270,19 +278,24 @@ def on_pairing_start():
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_pairing(address, keys):
|
@AsyncRunner.run_in_task()
|
||||||
|
async def on_pairing(connection, keys):
|
||||||
print(color('***-----------------------------------', 'cyan'))
|
print(color('***-----------------------------------', 'cyan'))
|
||||||
print(color(f'*** Paired! (peer identity={address})', 'cyan'))
|
print(color(f'*** Paired! (peer identity={connection.peer_address})', 'cyan'))
|
||||||
keys.print(prefix=color('*** ', 'cyan'))
|
keys.print(prefix=color('*** ', 'cyan'))
|
||||||
print(color('***-----------------------------------', 'cyan'))
|
print(color('***-----------------------------------', 'cyan'))
|
||||||
|
await asyncio.sleep(POST_PAIRING_DELAY)
|
||||||
|
await connection.disconnect()
|
||||||
Waiter.instance.terminate()
|
Waiter.instance.terminate()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_pairing_failure(reason):
|
@AsyncRunner.run_in_task()
|
||||||
|
async def on_pairing_failure(connection, reason):
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
|
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
|
await connection.disconnect()
|
||||||
Waiter.instance.terminate()
|
Waiter.instance.terminate()
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +306,7 @@ async def pair(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
identity_address,
|
||||||
linger,
|
linger,
|
||||||
io,
|
io,
|
||||||
oob,
|
oob,
|
||||||
@@ -382,11 +396,18 @@ async def pair(
|
|||||||
oob_contexts = None
|
oob_contexts = None
|
||||||
|
|
||||||
# Set up a pairing config factory
|
# Set up a pairing config factory
|
||||||
|
if identity_address == 'public':
|
||||||
|
identity_address_type = PairingConfig.AddressType.PUBLIC
|
||||||
|
elif identity_address == 'random':
|
||||||
|
identity_address_type = PairingConfig.AddressType.RANDOM
|
||||||
|
else:
|
||||||
|
identity_address_type = None
|
||||||
device.pairing_config_factory = lambda connection: PairingConfig(
|
device.pairing_config_factory = lambda connection: PairingConfig(
|
||||||
sc=sc,
|
sc=sc,
|
||||||
mitm=mitm,
|
mitm=mitm,
|
||||||
bonding=bond,
|
bonding=bond,
|
||||||
oob=oob_contexts,
|
oob=oob_contexts,
|
||||||
|
identity_address_type=identity_address_type,
|
||||||
delegate=Delegate(mode, connection, io, prompt),
|
delegate=Delegate(mode, connection, io, prompt),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,6 +478,10 @@ class LogHandler(logging.Handler):
|
|||||||
help='Enable CTKD',
|
help='Enable CTKD',
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
'--identity-address',
|
||||||
|
type=click.Choice(['random', 'public']),
|
||||||
|
)
|
||||||
@click.option('--linger', default=False, is_flag=True, help='Linger after pairing')
|
@click.option('--linger', default=False, is_flag=True, help='Linger after pairing')
|
||||||
@click.option(
|
@click.option(
|
||||||
'--io',
|
'--io',
|
||||||
@@ -493,6 +518,7 @@ def main(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
identity_address,
|
||||||
linger,
|
linger,
|
||||||
io,
|
io,
|
||||||
oob,
|
oob,
|
||||||
@@ -518,6 +544,7 @@ def main(
|
|||||||
mitm,
|
mitm,
|
||||||
bond,
|
bond,
|
||||||
ctkd,
|
ctkd,
|
||||||
|
identity_address,
|
||||||
linger,
|
linger,
|
||||||
io,
|
io,
|
||||||
oob,
|
oob,
|
||||||
|
|||||||
608
apps/player/player.py
Normal file
608
apps/player/player.py
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
# 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
|
||||||
@@ -237,6 +237,7 @@ class ClientBridge:
|
|||||||
address: str,
|
address: str,
|
||||||
tcp_host: str,
|
tcp_host: str,
|
||||||
tcp_port: int,
|
tcp_port: int,
|
||||||
|
authenticate: bool,
|
||||||
encrypt: bool,
|
encrypt: bool,
|
||||||
):
|
):
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
@@ -245,6 +246,7 @@ class ClientBridge:
|
|||||||
self.address = address
|
self.address = address
|
||||||
self.tcp_host = tcp_host
|
self.tcp_host = tcp_host
|
||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt
|
self.encrypt = encrypt
|
||||||
self.device: Optional[Device] = None
|
self.device: Optional[Device] = None
|
||||||
self.connection: Optional[Connection] = None
|
self.connection: Optional[Connection] = None
|
||||||
@@ -274,6 +276,11 @@ class ClientBridge:
|
|||||||
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
||||||
self.connection.on("disconnection", self.on_disconnection)
|
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:
|
if self.encrypt:
|
||||||
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
||||||
await self.connection.encrypt()
|
await self.connection.encrypt()
|
||||||
@@ -491,8 +498,9 @@ def server(context, tcp_host, tcp_port):
|
|||||||
@click.argument("bluetooth-address")
|
@click.argument("bluetooth-address")
|
||||||
@click.option("--tcp-host", help="TCP host", default="_")
|
@click.option("--tcp-host", help="TCP host", default="_")
|
||||||
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
@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")
|
@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(
|
bridge = ClientBridge(
|
||||||
context.obj["channel"],
|
context.obj["channel"],
|
||||||
context.obj["uuid"],
|
context.obj["uuid"],
|
||||||
@@ -500,6 +508,7 @@ def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
|||||||
bluetooth_address,
|
bluetooth_address,
|
||||||
tcp_host,
|
tcp_host,
|
||||||
tcp_port,
|
tcp_port,
|
||||||
|
authenticate,
|
||||||
encrypt,
|
encrypt,
|
||||||
)
|
)
|
||||||
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|||||||
@@ -44,25 +44,18 @@ from bumble.avdtp import (
|
|||||||
AVDTP_AUDIO_MEDIA_TYPE,
|
AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
Listener,
|
Listener,
|
||||||
MediaCodecCapabilities,
|
MediaCodecCapabilities,
|
||||||
MediaPacket,
|
|
||||||
Protocol,
|
Protocol,
|
||||||
)
|
)
|
||||||
from bumble.a2dp import (
|
from bumble.a2dp import (
|
||||||
MPEG_2_AAC_LC_OBJECT_TYPE,
|
|
||||||
make_audio_sink_service_sdp_records,
|
make_audio_sink_service_sdp_records,
|
||||||
A2DP_SBC_CODEC_TYPE,
|
A2DP_SBC_CODEC_TYPE,
|
||||||
A2DP_MPEG_2_4_AAC_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,
|
SbcMediaCodecInformation,
|
||||||
AacMediaCodecInformation,
|
AacMediaCodecInformation,
|
||||||
)
|
)
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
from bumble.codecs import AacAudioRtpPacket
|
from bumble.codecs import AacAudioRtpPacket
|
||||||
|
from bumble.rtp import MediaPacket
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -93,7 +86,7 @@ class AudioExtractor:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AacAudioExtractor:
|
class AacAudioExtractor:
|
||||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||||
return AacAudioRtpPacket(packet.payload).to_adts()
|
return AacAudioRtpPacket.from_bytes(packet.payload).to_adts()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -451,10 +444,12 @@ class Speaker:
|
|||||||
return MediaCodecCapabilities(
|
return MediaCodecCapabilities(
|
||||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
media_codec_information=AacMediaCodecInformation.from_lists(
|
media_codec_information=AacMediaCodecInformation(
|
||||||
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||||
sampling_frequencies=[48000, 44100],
|
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||||
channels=[1, 2],
|
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||||
|
channels=AacMediaCodecInformation.Channels.MONO
|
||||||
|
| AacMediaCodecInformation.Channels.STEREO,
|
||||||
vbr=1,
|
vbr=1,
|
||||||
bitrate=256000,
|
bitrate=256000,
|
||||||
),
|
),
|
||||||
@@ -464,20 +459,23 @@ class Speaker:
|
|||||||
return MediaCodecCapabilities(
|
return MediaCodecCapabilities(
|
||||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
media_codec_information=SbcMediaCodecInformation(
|
||||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||||
channel_modes=[
|
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||||
SBC_MONO_CHANNEL_MODE,
|
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||||
SBC_DUAL_CHANNEL_MODE,
|
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||||
SBC_STEREO_CHANNEL_MODE,
|
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||||
],
|
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||||
block_lengths=[4, 8, 12, 16],
|
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||||
subbands=[4, 8],
|
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||||
allocation_methods=[
|
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||||
SBC_SNR_ALLOCATION_METHOD,
|
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||||
],
|
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||||
|
| SbcMediaCodecInformation.Subbands.S_8,
|
||||||
|
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||||
|
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||||
minimum_bitpool_value=2,
|
minimum_bitpool_value=2,
|
||||||
maximum_bitpool_value=53,
|
maximum_bitpool_value=53,
|
||||||
),
|
),
|
||||||
|
|||||||
708
bumble/a2dp.py
708
bumble/a2dp.py
@@ -17,12 +17,16 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import struct
|
|
||||||
import logging
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import List, Callable, Awaitable
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from typing_extensions import ClassVar, Self
|
||||||
|
|
||||||
|
|
||||||
|
from .codecs import AacAudioRtpPacket
|
||||||
from .company_ids import COMPANY_IDENTIFIERS
|
from .company_ids import COMPANY_IDENTIFIERS
|
||||||
from .sdp import (
|
from .sdp import (
|
||||||
DataElement,
|
DataElement,
|
||||||
@@ -42,6 +46,7 @@ from .core import (
|
|||||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
|
from .rtp import MediaPacket
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -103,6 +108,8 @@ SBC_ALLOCATION_METHOD_NAMES = {
|
|||||||
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
||||||
8000,
|
8000,
|
||||||
11025,
|
11025,
|
||||||
@@ -130,6 +137,9 @@ MPEG_2_4_OBJECT_TYPE_NAMES = {
|
|||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
@@ -257,38 +267,61 @@ class SbcMediaCodecInformation:
|
|||||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
sampling_frequency: int
|
sampling_frequency: SamplingFrequency
|
||||||
channel_mode: int
|
channel_mode: ChannelMode
|
||||||
block_length: int
|
block_length: BlockLength
|
||||||
subbands: int
|
subbands: Subbands
|
||||||
allocation_method: int
|
allocation_method: AllocationMethod
|
||||||
minimum_bitpool_value: int
|
minimum_bitpool_value: int
|
||||||
maximum_bitpool_value: int
|
maximum_bitpool_value: int
|
||||||
|
|
||||||
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
class SamplingFrequency(enum.IntFlag):
|
||||||
CHANNEL_MODE_BITS = {
|
SF_16000 = 1 << 3
|
||||||
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
SF_32000 = 1 << 2
|
||||||
SBC_DUAL_CHANNEL_MODE: 1 << 2,
|
SF_44100 = 1 << 1
|
||||||
SBC_STEREO_CHANNEL_MODE: 1 << 1,
|
SF_48000 = 1 << 0
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_bytes(data: bytes) -> SbcMediaCodecInformation:
|
def from_int(cls, sampling_frequency: int) -> Self:
|
||||||
sampling_frequency = (data[0] >> 4) & 0x0F
|
sampling_frequencies = [
|
||||||
channel_mode = (data[0] >> 0) & 0x0F
|
16000,
|
||||||
block_length = (data[1] >> 4) & 0x0F
|
32000,
|
||||||
subbands = (data[1] >> 2) & 0x03
|
44100,
|
||||||
allocation_method = (data[1] >> 0) & 0x03
|
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)
|
||||||
minimum_bitpool_value = (data[2] >> 0) & 0xFF
|
minimum_bitpool_value = (data[2] >> 0) & 0xFF
|
||||||
maximum_bitpool_value = (data[3] >> 0) & 0xFF
|
maximum_bitpool_value = (data[3] >> 0) & 0xFF
|
||||||
return SbcMediaCodecInformation(
|
return cls(
|
||||||
sampling_frequency,
|
sampling_frequency,
|
||||||
channel_mode,
|
channel_mode,
|
||||||
block_length,
|
block_length,
|
||||||
@@ -298,52 +331,6 @@ class SbcMediaCodecInformation:
|
|||||||
maximum_bitpool_value,
|
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:
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
[
|
[
|
||||||
@@ -356,23 +343,6 @@ 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
|
@dataclasses.dataclass
|
||||||
@@ -381,83 +351,66 @@ class AacMediaCodecInformation:
|
|||||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
object_type: int
|
object_type: ObjectType
|
||||||
sampling_frequency: int
|
sampling_frequency: SamplingFrequency
|
||||||
channels: int
|
channels: Channels
|
||||||
rfa: int
|
|
||||||
vbr: int
|
vbr: int
|
||||||
bitrate: int
|
bitrate: int
|
||||||
|
|
||||||
OBJECT_TYPE_BITS = {
|
class ObjectType(enum.IntFlag):
|
||||||
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
MPEG_2_AAC_LC = 1 << 7
|
||||||
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
MPEG_4_AAC_LC = 1 << 6
|
||||||
MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5,
|
MPEG_4_AAC_LTP = 1 << 5
|
||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4,
|
MPEG_4_AAC_SCALABLE = 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}
|
|
||||||
|
|
||||||
@staticmethod
|
class SamplingFrequency(enum.IntFlag):
|
||||||
def from_bytes(data: bytes) -> AacMediaCodecInformation:
|
SF_8000 = 1 << 11
|
||||||
object_type = data[0]
|
SF_11025 = 1 << 10
|
||||||
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
SF_12000 = 1 << 9
|
||||||
channels = (data[2] >> 2) & 0x03
|
SF_16000 = 1 << 8
|
||||||
rfa = 0
|
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)
|
||||||
vbr = (data[3] >> 7) & 0x01
|
vbr = (data[3] >> 7) & 0x01
|
||||||
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
|
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
|
||||||
return AacMediaCodecInformation(
|
return AacMediaCodecInformation(
|
||||||
object_type, sampling_frequency, channels, rfa, vbr, bitrate
|
object_type, sampling_frequency, channels, 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:
|
def __bytes__(self) -> bytes:
|
||||||
@@ -472,30 +425,6 @@ 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
|
@dataclasses.dataclass
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -514,7 +443,7 @@ class VendorSpecificMediaCodecInformation:
|
|||||||
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
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:
|
def __str__(self) -> str:
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
@@ -528,13 +457,69 @@ 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
|
@dataclasses.dataclass
|
||||||
class SbcFrame:
|
class SbcFrame:
|
||||||
sampling_frequency: int
|
sampling_frequency: int
|
||||||
block_count: int
|
block_count: int
|
||||||
channel_mode: int
|
channel_mode: int
|
||||||
|
allocation_method: int
|
||||||
subband_count: int
|
subband_count: int
|
||||||
|
bitpool: int
|
||||||
payload: bytes
|
payload: bytes
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -553,8 +538,10 @@ class SbcFrame:
|
|||||||
return (
|
return (
|
||||||
f'SBC(sf={self.sampling_frequency},'
|
f'SBC(sf={self.sampling_frequency},'
|
||||||
f'cm={self.channel_mode},'
|
f'cm={self.channel_mode},'
|
||||||
|
f'am={self.allocation_method},'
|
||||||
f'br={self.bitrate},'
|
f'br={self.bitrate},'
|
||||||
f'sc={self.sample_count},'
|
f'sc={self.sample_count},'
|
||||||
|
f'bp={self.bitpool},'
|
||||||
f'size={len(self.payload)})'
|
f'size={len(self.payload)})'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -583,6 +570,7 @@ class SbcParser:
|
|||||||
blocks = 4 * (1 + ((header[1] >> 4) & 3))
|
blocks = 4 * (1 + ((header[1] >> 4) & 3))
|
||||||
channel_mode = (header[1] >> 2) & 3
|
channel_mode = (header[1] >> 2) & 3
|
||||||
channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2
|
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
|
subbands = 8 if ((header[1]) & 1) else 4
|
||||||
bitpool = header[2]
|
bitpool = header[2]
|
||||||
|
|
||||||
@@ -602,7 +590,13 @@ class SbcParser:
|
|||||||
|
|
||||||
# Emit the next frame
|
# Emit the next frame
|
||||||
yield SbcFrame(
|
yield SbcFrame(
|
||||||
sampling_frequency, blocks, channel_mode, subbands, payload
|
sampling_frequency,
|
||||||
|
blocks,
|
||||||
|
channel_mode,
|
||||||
|
allocation_method,
|
||||||
|
subbands,
|
||||||
|
bitpool,
|
||||||
|
payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return generate_frames()
|
return generate_frames()
|
||||||
@@ -610,21 +604,15 @@ class SbcParser:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SbcPacketSource:
|
class SbcPacketSource:
|
||||||
def __init__(
|
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||||
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
|
|
||||||
) -> None:
|
|
||||||
self.read = read
|
self.read = read
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
self.codec_capabilities = codec_capabilities
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def packets(self):
|
def packets(self):
|
||||||
async def generate_packets():
|
async def generate_packets():
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from .avdtp import MediaPacket # Import here to avoid a circular reference
|
|
||||||
|
|
||||||
sequence_number = 0
|
sequence_number = 0
|
||||||
timestamp = 0
|
sample_count = 0
|
||||||
frames = []
|
frames = []
|
||||||
frames_size = 0
|
frames_size = 0
|
||||||
max_rtp_payload = self.mtu - 12 - 1
|
max_rtp_payload = self.mtu - 12 - 1
|
||||||
@@ -632,29 +620,29 @@ class SbcPacketSource:
|
|||||||
# NOTE: this doesn't support frame fragments
|
# NOTE: this doesn't support frame fragments
|
||||||
sbc_parser = SbcParser(self.read)
|
sbc_parser = SbcParser(self.read)
|
||||||
async for frame in sbc_parser.frames:
|
async for frame in sbc_parser.frames:
|
||||||
print(frame)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
frames_size + len(frame.payload) > max_rtp_payload
|
frames_size + len(frame.payload) > max_rtp_payload
|
||||||
or len(frames) == 16
|
or len(frames) == SBC_MAX_FRAMES_IN_RTP_PAYLOAD
|
||||||
):
|
):
|
||||||
# Need to flush what has been accumulated so far
|
# Need to flush what has been accumulated so far
|
||||||
|
logger.debug(f"yielding {len(frames)} frames")
|
||||||
|
|
||||||
# Emit a packet
|
# Emit a packet
|
||||||
sbc_payload = bytes([len(frames)]) + b''.join(
|
sbc_payload = bytes([len(frames) & 0x0F]) + b''.join(
|
||||||
[frame.payload for frame in frames]
|
[frame.payload for frame in frames]
|
||||||
)
|
)
|
||||||
|
timestamp_seconds = sample_count / frame.sampling_frequency
|
||||||
|
timestamp = int(1000 * timestamp_seconds)
|
||||||
packet = MediaPacket(
|
packet = MediaPacket(
|
||||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload
|
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload
|
||||||
)
|
)
|
||||||
packet.timestamp_seconds = timestamp / frame.sampling_frequency
|
packet.timestamp_seconds = timestamp_seconds
|
||||||
yield packet
|
yield packet
|
||||||
|
|
||||||
# Prepare for next packets
|
# Prepare for next packets
|
||||||
sequence_number += 1
|
sequence_number += 1
|
||||||
sequence_number &= 0xFFFF
|
sequence_number &= 0xFFFF
|
||||||
timestamp += sum((frame.sample_count for frame in frames))
|
sample_count += sum((frame.sample_count for frame in frames))
|
||||||
timestamp &= 0xFFFFFFFF
|
|
||||||
frames = [frame]
|
frames = [frame]
|
||||||
frames_size = len(frame.payload)
|
frames_size = len(frame.payload)
|
||||||
else:
|
else:
|
||||||
@@ -663,3 +651,315 @@ class SbcPacketSource:
|
|||||||
frames_size += len(frame.payload)
|
frames_size += len(frame.payload)
|
||||||
|
|
||||||
return generate_packets()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
@@ -41,6 +42,7 @@ from typing import (
|
|||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
from bumble.core import UUID, name_or_number, ProtocolError
|
from bumble.core import UUID, name_or_number, ProtocolError
|
||||||
from bumble.hci import HCI_Object, key_with_value
|
from bumble.hci import HCI_Object, key_with_value
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -145,43 +147,57 @@ ATT_RESPONSES = [
|
|||||||
ATT_EXECUTE_WRITE_RESPONSE
|
ATT_EXECUTE_WRITE_RESPONSE
|
||||||
]
|
]
|
||||||
|
|
||||||
ATT_INVALID_HANDLE_ERROR = 0x01
|
class ErrorCode(utils.OpenIntEnum):
|
||||||
ATT_READ_NOT_PERMITTED_ERROR = 0x02
|
'''
|
||||||
ATT_WRITE_NOT_PERMITTED_ERROR = 0x03
|
See
|
||||||
ATT_INVALID_PDU_ERROR = 0x04
|
|
||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR = 0x05
|
|
||||||
ATT_REQUEST_NOT_SUPPORTED_ERROR = 0x06
|
|
||||||
ATT_INVALID_OFFSET_ERROR = 0x07
|
|
||||||
ATT_INSUFFICIENT_AUTHORIZATION_ERROR = 0x08
|
|
||||||
ATT_PREPARE_QUEUE_FULL_ERROR = 0x09
|
|
||||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR = 0x0A
|
|
||||||
ATT_ATTRIBUTE_NOT_LONG_ERROR = 0x0B
|
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR = 0x0C
|
|
||||||
ATT_INVALID_ATTRIBUTE_LENGTH_ERROR = 0x0D
|
|
||||||
ATT_UNLIKELY_ERROR_ERROR = 0x0E
|
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_ERROR = 0x0F
|
|
||||||
ATT_UNSUPPORTED_GROUP_TYPE_ERROR = 0x10
|
|
||||||
ATT_INSUFFICIENT_RESOURCES_ERROR = 0x11
|
|
||||||
|
|
||||||
ATT_ERROR_NAMES = {
|
* Bluetooth spec @ Vol 3, Part F - 3.4.1.1 Error Response
|
||||||
ATT_INVALID_HANDLE_ERROR: 'ATT_INVALID_HANDLE_ERROR',
|
* Core Specification Supplement: Common Profile And Service Error Codes
|
||||||
ATT_READ_NOT_PERMITTED_ERROR: 'ATT_READ_NOT_PERMITTED_ERROR',
|
'''
|
||||||
ATT_WRITE_NOT_PERMITTED_ERROR: 'ATT_WRITE_NOT_PERMITTED_ERROR',
|
INVALID_HANDLE = 0x01
|
||||||
ATT_INVALID_PDU_ERROR: 'ATT_INVALID_PDU_ERROR',
|
READ_NOT_PERMITTED = 0x02
|
||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR: 'ATT_INSUFFICIENT_AUTHENTICATION_ERROR',
|
WRITE_NOT_PERMITTED = 0x03
|
||||||
ATT_REQUEST_NOT_SUPPORTED_ERROR: 'ATT_REQUEST_NOT_SUPPORTED_ERROR',
|
INVALID_PDU = 0x04
|
||||||
ATT_INVALID_OFFSET_ERROR: 'ATT_INVALID_OFFSET_ERROR',
|
INSUFFICIENT_AUTHENTICATION = 0x05
|
||||||
ATT_INSUFFICIENT_AUTHORIZATION_ERROR: 'ATT_INSUFFICIENT_AUTHORIZATION_ERROR',
|
REQUEST_NOT_SUPPORTED = 0x06
|
||||||
ATT_PREPARE_QUEUE_FULL_ERROR: 'ATT_PREPARE_QUEUE_FULL_ERROR',
|
INVALID_OFFSET = 0x07
|
||||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR: 'ATT_ATTRIBUTE_NOT_FOUND_ERROR',
|
INSUFFICIENT_AUTHORIZATION = 0x08
|
||||||
ATT_ATTRIBUTE_NOT_LONG_ERROR: 'ATT_ATTRIBUTE_NOT_LONG_ERROR',
|
PREPARE_QUEUE_FULL = 0x09
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR: 'ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR',
|
ATTRIBUTE_NOT_FOUND = 0x0A
|
||||||
ATT_INVALID_ATTRIBUTE_LENGTH_ERROR: 'ATT_INVALID_ATTRIBUTE_LENGTH_ERROR',
|
ATTRIBUTE_NOT_LONG = 0x0B
|
||||||
ATT_UNLIKELY_ERROR_ERROR: 'ATT_UNLIKELY_ERROR_ERROR',
|
INSUFFICIENT_ENCRYPTION_KEY_SIZE = 0x0C
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_ERROR: 'ATT_INSUFFICIENT_ENCRYPTION_ERROR',
|
INVALID_ATTRIBUTE_LENGTH = 0x0D
|
||||||
ATT_UNSUPPORTED_GROUP_TYPE_ERROR: 'ATT_UNSUPPORTED_GROUP_TYPE_ERROR',
|
UNLIKELY_ERROR = 0x0E
|
||||||
ATT_INSUFFICIENT_RESOURCES_ERROR: 'ATT_INSUFFICIENT_RESOURCES_ERROR'
|
INSUFFICIENT_ENCRYPTION = 0x0F
|
||||||
}
|
UNSUPPORTED_GROUP_TYPE = 0x10
|
||||||
|
INSUFFICIENT_RESOURCES = 0x11
|
||||||
|
DATABASE_OUT_OF_SYNC = 0x12
|
||||||
|
VALUE_NOT_ALLOWED = 0x13
|
||||||
|
# 0x80 – 0x9F: Application Error
|
||||||
|
# 0xE0 – 0xFF: Common Profile and Service Error Codes
|
||||||
|
WRITE_REQUEST_REJECTED = 0xFC
|
||||||
|
CCCD_IMPROPERLY_CONFIGURED = 0xFD
|
||||||
|
PROCEDURE_ALREADY_IN_PROGRESS = 0xFE
|
||||||
|
OUT_OF_RANGE = 0xFF
|
||||||
|
|
||||||
|
# Backward Compatible Constants
|
||||||
|
ATT_INVALID_HANDLE_ERROR = ErrorCode.INVALID_HANDLE
|
||||||
|
ATT_READ_NOT_PERMITTED_ERROR = ErrorCode.READ_NOT_PERMITTED
|
||||||
|
ATT_WRITE_NOT_PERMITTED_ERROR = ErrorCode.WRITE_NOT_PERMITTED
|
||||||
|
ATT_INVALID_PDU_ERROR = ErrorCode.INVALID_PDU
|
||||||
|
ATT_INSUFFICIENT_AUTHENTICATION_ERROR = ErrorCode.INSUFFICIENT_AUTHENTICATION
|
||||||
|
ATT_REQUEST_NOT_SUPPORTED_ERROR = ErrorCode.REQUEST_NOT_SUPPORTED
|
||||||
|
ATT_INVALID_OFFSET_ERROR = ErrorCode.INVALID_OFFSET
|
||||||
|
ATT_INSUFFICIENT_AUTHORIZATION_ERROR = ErrorCode.INSUFFICIENT_AUTHORIZATION
|
||||||
|
ATT_PREPARE_QUEUE_FULL_ERROR = ErrorCode.PREPARE_QUEUE_FULL
|
||||||
|
ATT_ATTRIBUTE_NOT_FOUND_ERROR = ErrorCode.ATTRIBUTE_NOT_FOUND
|
||||||
|
ATT_ATTRIBUTE_NOT_LONG_ERROR = ErrorCode.ATTRIBUTE_NOT_LONG
|
||||||
|
ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR = ErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE
|
||||||
|
ATT_INVALID_ATTRIBUTE_LENGTH_ERROR = ErrorCode.INVALID_ATTRIBUTE_LENGTH
|
||||||
|
ATT_UNLIKELY_ERROR_ERROR = ErrorCode.UNLIKELY_ERROR
|
||||||
|
ATT_INSUFFICIENT_ENCRYPTION_ERROR = ErrorCode.INSUFFICIENT_ENCRYPTION
|
||||||
|
ATT_UNSUPPORTED_GROUP_TYPE_ERROR = ErrorCode.UNSUPPORTED_GROUP_TYPE
|
||||||
|
ATT_INSUFFICIENT_RESOURCES_ERROR = ErrorCode.INSUFFICIENT_RESOURCES
|
||||||
|
|
||||||
ATT_DEFAULT_MTU = 23
|
ATT_DEFAULT_MTU = 23
|
||||||
|
|
||||||
@@ -245,9 +261,9 @@ class ATT_PDU:
|
|||||||
def pdu_name(op_code):
|
def pdu_name(op_code):
|
||||||
return name_or_number(ATT_PDU_NAMES, op_code, 2)
|
return name_or_number(ATT_PDU_NAMES, op_code, 2)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def error_name(error_code):
|
def error_name(cls, error_code: int) -> str:
|
||||||
return name_or_number(ATT_ERROR_NAMES, error_code, 2)
|
return ErrorCode(error_code).name
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def subclass(fields):
|
def subclass(fields):
|
||||||
@@ -694,7 +710,7 @@ class ATT_Prepare_Write_Response(ATT_PDU):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@ATT_PDU.subclass([])
|
@ATT_PDU.subclass([("flags", 1)])
|
||||||
class ATT_Execute_Write_Request(ATT_PDU):
|
class ATT_Execute_Write_Request(ATT_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Execute Write Request
|
See Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Execute Write Request
|
||||||
@@ -795,7 +811,7 @@ class Attribute(EventEmitter):
|
|||||||
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||||
enum_list_str = ",".join(enum_list)
|
enum_list_str = ",".join(enum_list)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str }\nGot: {permissions_str}"
|
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str}\nGot: {permissions_str}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Permission flags(legacy-use only)
|
# Permission flags(legacy-use only)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class Frame:
|
|||||||
# Not supported
|
# Not supported
|
||||||
raise NotImplementedError("extended subunit types not supported")
|
raise NotImplementedError("extended subunit types not supported")
|
||||||
|
|
||||||
if subunit_id < 5:
|
if subunit_id < 5 or subunit_id == 7:
|
||||||
opcode_offset = 2
|
opcode_offset = 2
|
||||||
elif subunit_id == 5:
|
elif subunit_id == 5:
|
||||||
# Extended to the next byte
|
# Extended to the next byte
|
||||||
@@ -132,9 +132,10 @@ class Frame:
|
|||||||
else:
|
else:
|
||||||
subunit_id = 5 + extension
|
subunit_id = 5 + extension
|
||||||
opcode_offset = 3
|
opcode_offset = 3
|
||||||
|
|
||||||
elif subunit_id == 6:
|
elif subunit_id == 6:
|
||||||
raise core.InvalidPacketError("reserved subunit ID")
|
raise core.InvalidPacketError("reserved subunit ID")
|
||||||
|
else:
|
||||||
|
raise core.InvalidPacketError("invalid subunit ID")
|
||||||
|
|
||||||
opcode = Frame.OperationCode(data[opcode_offset])
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
operands = data[opcode_offset + 1 :]
|
operands = data[opcode_offset + 1 :]
|
||||||
|
|||||||
155
bumble/avdtp.py
155
bumble/avdtp.py
@@ -17,12 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
import warnings
|
||||||
from pyee import EventEmitter
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
@@ -39,6 +37,8 @@ from typing import (
|
|||||||
cast,
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .core import (
|
from .core import (
|
||||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
@@ -51,13 +51,16 @@ from .a2dp import (
|
|||||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
A2DP_NON_A2DP_CODEC_TYPE,
|
A2DP_NON_A2DP_CODEC_TYPE,
|
||||||
A2DP_SBC_CODEC_TYPE,
|
A2DP_SBC_CODEC_TYPE,
|
||||||
|
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES,
|
||||||
AacMediaCodecInformation,
|
AacMediaCodecInformation,
|
||||||
SbcMediaCodecInformation,
|
SbcMediaCodecInformation,
|
||||||
VendorSpecificMediaCodecInformation,
|
VendorSpecificMediaCodecInformation,
|
||||||
)
|
)
|
||||||
|
from .rtp import MediaPacket
|
||||||
from . import sdp, device, l2cap
|
from . import sdp, device, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -278,95 +281,6 @@ class RealtimeClock:
|
|||||||
await asyncio.sleep(duration)
|
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:
|
class MediaPacketPump:
|
||||||
pump_task: Optional[asyncio.Task]
|
pump_task: Optional[asyncio.Task]
|
||||||
@@ -377,6 +291,7 @@ class MediaPacketPump:
|
|||||||
self.packets = packets
|
self.packets = packets
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
self.completed = asyncio.Event()
|
||||||
|
|
||||||
async def start(self, rtp_channel: l2cap.ClassicChannel) -> None:
|
async def start(self, rtp_channel: l2cap.ClassicChannel) -> None:
|
||||||
async def pump_packets():
|
async def pump_packets():
|
||||||
@@ -406,6 +321,8 @@ class MediaPacketPump:
|
|||||||
)
|
)
|
||||||
except asyncio.exceptions.CancelledError:
|
except asyncio.exceptions.CancelledError:
|
||||||
logger.debug('pump canceled')
|
logger.debug('pump canceled')
|
||||||
|
finally:
|
||||||
|
self.completed.set()
|
||||||
|
|
||||||
# Pump packets
|
# Pump packets
|
||||||
self.pump_task = asyncio.create_task(pump_packets())
|
self.pump_task = asyncio.create_task(pump_packets())
|
||||||
@@ -417,6 +334,9 @@ class MediaPacketPump:
|
|||||||
await self.pump_task
|
await self.pump_task
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
|
async def wait_for_completion(self) -> None:
|
||||||
|
await self.completed.wait()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class MessageAssembler:
|
class MessageAssembler:
|
||||||
@@ -580,10 +500,10 @@ class ServiceCapabilities:
|
|||||||
self.service_category = service_category
|
self.service_category = service_category
|
||||||
self.service_capabilities_bytes = service_capabilities_bytes
|
self.service_capabilities_bytes = service_capabilities_bytes
|
||||||
|
|
||||||
def to_string(self, details: List[str] = []) -> str:
|
def to_string(self, details: Optional[List[str]] = None) -> str:
|
||||||
attributes = ','.join(
|
attributes = ','.join(
|
||||||
[name_or_number(AVDTP_SERVICE_CATEGORY_NAMES, self.service_category)]
|
[name_or_number(AVDTP_SERVICE_CATEGORY_NAMES, self.service_category)]
|
||||||
+ details
|
+ (details or [])
|
||||||
)
|
)
|
||||||
return f'ServiceCapabilities({attributes})'
|
return f'ServiceCapabilities({attributes})'
|
||||||
|
|
||||||
@@ -615,11 +535,25 @@ class MediaCodecCapabilities(ServiceCapabilities):
|
|||||||
self.media_codec_information
|
self.media_codec_information
|
||||||
)
|
)
|
||||||
elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE:
|
elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE:
|
||||||
self.media_codec_information = (
|
vendor_media_codec_information = (
|
||||||
VendorSpecificMediaCodecInformation.from_bytes(
|
VendorSpecificMediaCodecInformation.from_bytes(
|
||||||
self.media_codec_information
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1316,10 +1250,20 @@ class Protocol(EventEmitter):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def add_source(
|
def add_source(
|
||||||
self, codec_capabilities: MediaCodecCapabilities, packet_pump: MediaPacketPump
|
self,
|
||||||
|
codec_capabilities: MediaCodecCapabilities,
|
||||||
|
packet_pump: MediaPacketPump,
|
||||||
|
delay_reporting: bool = False,
|
||||||
) -> LocalSource:
|
) -> LocalSource:
|
||||||
seid = len(self.local_endpoints) + 1
|
seid = len(self.local_endpoints) + 1
|
||||||
source = LocalSource(self, seid, codec_capabilities, packet_pump)
|
service_capabilities = (
|
||||||
|
[ServiceCapabilities(AVDTP_DELAY_REPORTING_SERVICE_CATEGORY)]
|
||||||
|
if delay_reporting
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
source = LocalSource(
|
||||||
|
self, seid, codec_capabilities, service_capabilities, packet_pump
|
||||||
|
)
|
||||||
self.local_endpoints.append(source)
|
self.local_endpoints.append(source)
|
||||||
|
|
||||||
return source
|
return source
|
||||||
@@ -1372,7 +1316,7 @@ class Protocol(EventEmitter):
|
|||||||
return self.remote_endpoints.values()
|
return self.remote_endpoints.values()
|
||||||
|
|
||||||
def find_remote_sink_by_codec(
|
def find_remote_sink_by_codec(
|
||||||
self, media_type: int, codec_type: int
|
self, media_type: int, codec_type: int, vendor_id: int = 0, codec_id: int = 0
|
||||||
) -> Optional[DiscoveredStreamEndPoint]:
|
) -> Optional[DiscoveredStreamEndPoint]:
|
||||||
for endpoint in self.remote_endpoints.values():
|
for endpoint in self.remote_endpoints.values():
|
||||||
if (
|
if (
|
||||||
@@ -1397,7 +1341,19 @@ class Protocol(EventEmitter):
|
|||||||
codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE
|
codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE
|
||||||
and codec_capabilities.media_codec_type == codec_type
|
and codec_capabilities.media_codec_type == codec_type
|
||||||
):
|
):
|
||||||
has_codec = True
|
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
|
||||||
if has_media_transport and has_codec:
|
if has_media_transport and has_codec:
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
@@ -2180,12 +2136,13 @@ class LocalSource(LocalStreamEndPoint):
|
|||||||
protocol: Protocol,
|
protocol: Protocol,
|
||||||
seid: int,
|
seid: int,
|
||||||
codec_capabilities: MediaCodecCapabilities,
|
codec_capabilities: MediaCodecCapabilities,
|
||||||
|
other_capabilitiles: Iterable[ServiceCapabilities],
|
||||||
packet_pump: MediaPacketPump,
|
packet_pump: MediaPacketPump,
|
||||||
) -> None:
|
) -> None:
|
||||||
capabilities = [
|
capabilities = [
|
||||||
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
||||||
codec_capabilities,
|
codec_capabilities,
|
||||||
]
|
] + list(other_capabilitiles)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
protocol,
|
protocol,
|
||||||
seid,
|
seid,
|
||||||
|
|||||||
@@ -1491,10 +1491,14 @@ class Protocol(pyee.EventEmitter):
|
|||||||
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
|
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only the PANEL subunit type with subunit ID 0 is supported in this profile.
|
# Only addressing the unit, or the PANEL subunit with subunit ID 0 is supported
|
||||||
if (
|
# in this profile.
|
||||||
command.subunit_type != avc.Frame.SubunitType.PANEL
|
if not (
|
||||||
or command.subunit_id != 0
|
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
|
||||||
):
|
):
|
||||||
logger.debug("subunit not supported")
|
logger.debug("subunit not supported")
|
||||||
self.send_not_implemented_response(transaction_label, command)
|
self.send_not_implemented_response(transaction_label, command)
|
||||||
@@ -1528,8 +1532,8 @@ class Protocol(pyee.EventEmitter):
|
|||||||
# TODO: delegate
|
# TODO: delegate
|
||||||
response = avc.PassThroughResponseFrame(
|
response = avc.PassThroughResponseFrame(
|
||||||
avc.ResponseFrame.ResponseCode.ACCEPTED,
|
avc.ResponseFrame.ResponseCode.ACCEPTED,
|
||||||
avc.Frame.SubunitType.PANEL,
|
command.subunit_type,
|
||||||
0,
|
command.subunit_id,
|
||||||
command.state_flag,
|
command.state_flag,
|
||||||
command.operation_id,
|
command.operation_id,
|
||||||
command.operation_data,
|
command.operation_data,
|
||||||
@@ -1846,6 +1850,15 @@ class Protocol(pyee.EventEmitter):
|
|||||||
RejectedResponse(pdu_id, status_code),
|
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(
|
def _on_get_capabilities_command(
|
||||||
self, transaction_label: int, command: GetCapabilitiesCommand
|
self, transaction_label: int, command: GetCapabilitiesCommand
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1891,29 +1904,35 @@ class Protocol(pyee.EventEmitter):
|
|||||||
async def register_notification():
|
async def register_notification():
|
||||||
# Check if the event is supported.
|
# Check if the event is supported.
|
||||||
supported_events = await self.delegate.get_supported_events()
|
supported_events = await self.delegate.get_supported_events()
|
||||||
if command.event_id in supported_events:
|
if command.event_id not in supported_events:
|
||||||
if command.event_id == EventId.VOLUME_CHANGED:
|
logger.debug("event not supported")
|
||||||
volume = await self.delegate.get_absolute_volume()
|
self.send_not_implemented_avrcp_response(
|
||||||
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
transaction_label, self.PduId.REGISTER_NOTIFICATION
|
||||||
self.send_avrcp_response(
|
)
|
||||||
transaction_label,
|
return
|
||||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
self._register_notification_listener(transaction_label, command)
|
|
||||||
return
|
|
||||||
|
|
||||||
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
|
if command.event_id == EventId.VOLUME_CHANGED:
|
||||||
# TODO: testing only, use delegate
|
volume = await self.delegate.get_absolute_volume()
|
||||||
response = RegisterNotificationResponse(
|
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
||||||
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
|
self.send_avrcp_response(
|
||||||
)
|
transaction_label,
|
||||||
self.send_avrcp_response(
|
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||||
transaction_label,
|
response,
|
||||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
)
|
||||||
response,
|
self._register_notification_listener(transaction_label, command)
|
||||||
)
|
return
|
||||||
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())
|
self._delegate_command(transaction_label, command, register_notification())
|
||||||
|
|||||||
290
bumble/codecs.py
290
bumble/codecs.py
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
|
|
||||||
@@ -101,12 +102,40 @@ class BitReader:
|
|||||||
break
|
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:
|
class AacAudioRtpPacket:
|
||||||
"""AAC payload encapsulated in an RTP packet payload"""
|
"""AAC payload encapsulated in an RTP packet payload"""
|
||||||
|
|
||||||
|
audio_mux_element: AudioMuxElement
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def latm_value(reader: BitReader) -> int:
|
def read_latm_value(reader: BitReader) -> int:
|
||||||
bytes_for_value = reader.read(2)
|
bytes_for_value = reader.read(2)
|
||||||
value = 0
|
value = 0
|
||||||
for _ in range(bytes_for_value + 1):
|
for _ in range(bytes_for_value + 1):
|
||||||
@@ -114,24 +143,33 @@ class AacAudioRtpPacket:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def program_config_element(reader: BitReader):
|
def read_audio_object_type(reader: BitReader):
|
||||||
raise core.InvalidPacketError('program_config_element not supported')
|
# 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
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GASpecificConfig:
|
class GASpecificConfig:
|
||||||
def __init__(
|
audio_object_type: int
|
||||||
self, reader: BitReader, channel_configuration: int, audio_object_type: int
|
# NOTE: other fields not supported
|
||||||
) -> None:
|
|
||||||
|
@classmethod
|
||||||
|
def from_bits(
|
||||||
|
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||||
|
) -> Self:
|
||||||
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
||||||
frame_length_flag = reader.read(1)
|
frame_length_flag = reader.read(1)
|
||||||
depends_on_core_coder = reader.read(1)
|
depends_on_core_coder = reader.read(1)
|
||||||
if depends_on_core_coder:
|
if depends_on_core_coder:
|
||||||
self.core_coder_delay = reader.read(14)
|
core_coder_delay = reader.read(14)
|
||||||
extension_flag = reader.read(1)
|
extension_flag = reader.read(1)
|
||||||
if not channel_configuration:
|
if not channel_configuration:
|
||||||
AacAudioRtpPacket.program_config_element(reader)
|
raise core.InvalidPacketError('program_config_element not supported')
|
||||||
if audio_object_type in (6, 20):
|
if audio_object_type in (6, 20):
|
||||||
self.layer_nr = reader.read(3)
|
layer_nr = reader.read(3)
|
||||||
if extension_flag:
|
if extension_flag:
|
||||||
if audio_object_type == 22:
|
if audio_object_type == 22:
|
||||||
num_of_sub_frame = reader.read(5)
|
num_of_sub_frame = reader.read(5)
|
||||||
@@ -144,14 +182,13 @@ class AacAudioRtpPacket:
|
|||||||
if extension_flag_3 == 1:
|
if extension_flag_3 == 1:
|
||||||
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||||
|
|
||||||
@staticmethod
|
return cls(audio_object_type)
|
||||||
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)
|
|
||||||
|
|
||||||
return audio_object_type
|
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
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioSpecificConfig:
|
class AudioSpecificConfig:
|
||||||
@@ -159,6 +196,7 @@ class AacAudioRtpPacket:
|
|||||||
sampling_frequency_index: int
|
sampling_frequency_index: int
|
||||||
sampling_frequency: int
|
sampling_frequency: int
|
||||||
channel_configuration: int
|
channel_configuration: int
|
||||||
|
ga_specific_config: AacAudioRtpPacket.GASpecificConfig
|
||||||
sbr_present_flag: int
|
sbr_present_flag: int
|
||||||
ps_present_flag: int
|
ps_present_flag: int
|
||||||
extension_audio_object_type: int
|
extension_audio_object_type: int
|
||||||
@@ -182,44 +220,73 @@ class AacAudioRtpPacket:
|
|||||||
7350,
|
7350,
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, reader: BitReader) -> None:
|
@classmethod
|
||||||
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
|
def for_simple_aac(
|
||||||
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
cls,
|
||||||
self.sampling_frequency_index = reader.read(4)
|
audio_object_type: int,
|
||||||
if self.sampling_frequency_index == 0xF:
|
sampling_frequency: int,
|
||||||
self.sampling_frequency = reader.read(24)
|
channel_configuration: int,
|
||||||
else:
|
) -> Self:
|
||||||
self.sampling_frequency = self.SAMPLING_FREQUENCIES[
|
if sampling_frequency not in cls.SAMPLING_FREQUENCIES:
|
||||||
self.sampling_frequency_index
|
raise ValueError(f'invalid sampling frequency {sampling_frequency}')
|
||||||
]
|
|
||||||
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:
|
|
||||||
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
|
|
||||||
self.extension_sampling_frequency_index
|
|
||||||
]
|
|
||||||
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 self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(audio_object_type)
|
||||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
|
|
||||||
reader, self.channel_configuration, self.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:
|
||||||
|
# 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)
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
extension_sampling_frequency = cls.SAMPLING_FREQUENCIES[
|
||||||
|
extension_sampling_frequency_index
|
||||||
|
]
|
||||||
|
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
|
||||||
|
if audio_object_type == 22:
|
||||||
|
extension_channel_configuration = reader.read(4)
|
||||||
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise core.InvalidPacketError(
|
raise core.InvalidPacketError(
|
||||||
f'audioObjectType {self.audio_object_type} not supported'
|
f'audioObjectType {audio_object_type} not supported'
|
||||||
)
|
)
|
||||||
|
|
||||||
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
|
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
|
||||||
@@ -248,13 +315,44 @@ class AacAudioRtpPacket:
|
|||||||
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
||||||
# self.extension_channel_configuration = reader.read(4)
|
# 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
|
@dataclass
|
||||||
class StreamMuxConfig:
|
class StreamMuxConfig:
|
||||||
other_data_present: int
|
other_data_present: int
|
||||||
other_data_len_bits: int
|
other_data_len_bits: int
|
||||||
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
|
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
|
||||||
|
|
||||||
def __init__(self, reader: BitReader) -> None:
|
@classmethod
|
||||||
|
def from_bits(cls, reader: BitReader) -> Self:
|
||||||
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
|
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
|
||||||
audio_mux_version = reader.read(1)
|
audio_mux_version = reader.read(1)
|
||||||
if audio_mux_version == 1:
|
if audio_mux_version == 1:
|
||||||
@@ -264,7 +362,7 @@ class AacAudioRtpPacket:
|
|||||||
if audio_mux_version_a != 0:
|
if audio_mux_version_a != 0:
|
||||||
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||||
if audio_mux_version == 1:
|
if audio_mux_version == 1:
|
||||||
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader)
|
||||||
stream_cnt = 0
|
stream_cnt = 0
|
||||||
all_streams_same_time_framing = reader.read(1)
|
all_streams_same_time_framing = reader.read(1)
|
||||||
num_sub_frames = reader.read(6)
|
num_sub_frames = reader.read(6)
|
||||||
@@ -275,13 +373,13 @@ class AacAudioRtpPacket:
|
|||||||
if num_layer != 0:
|
if num_layer != 0:
|
||||||
raise core.InvalidPacketError('num_layer != 0 not supported')
|
raise core.InvalidPacketError('num_layer != 0 not supported')
|
||||||
if audio_mux_version == 0:
|
if audio_mux_version == 0:
|
||||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||||
reader
|
reader
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
asc_len = AacAudioRtpPacket.latm_value(reader)
|
asc_len = AacAudioRtpPacket.read_latm_value(reader)
|
||||||
marker = reader.bit_position
|
marker = reader.bit_position
|
||||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||||
reader
|
reader
|
||||||
)
|
)
|
||||||
audio_specific_config_len = reader.bit_position - marker
|
audio_specific_config_len = reader.bit_position - marker
|
||||||
@@ -299,36 +397,49 @@ class AacAudioRtpPacket:
|
|||||||
f'frame_length_type {frame_length_type} not supported'
|
f'frame_length_type {frame_length_type} not supported'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.other_data_present = reader.read(1)
|
other_data_present = reader.read(1)
|
||||||
if self.other_data_present:
|
other_data_len_bits = 0
|
||||||
|
if other_data_present:
|
||||||
if audio_mux_version == 1:
|
if audio_mux_version == 1:
|
||||||
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
|
other_data_len_bits = AacAudioRtpPacket.read_latm_value(reader)
|
||||||
else:
|
else:
|
||||||
self.other_data_len_bits = 0
|
|
||||||
while True:
|
while True:
|
||||||
self.other_data_len_bits *= 256
|
other_data_len_bits *= 256
|
||||||
other_data_len_esc = reader.read(1)
|
other_data_len_esc = reader.read(1)
|
||||||
self.other_data_len_bits += reader.read(8)
|
other_data_len_bits += reader.read(8)
|
||||||
if other_data_len_esc == 0:
|
if other_data_len_esc == 0:
|
||||||
break
|
break
|
||||||
crc_check_present = reader.read(1)
|
crc_check_present = reader.read(1)
|
||||||
if crc_check_present:
|
if crc_check_present:
|
||||||
crc_checksum = reader.read(8)
|
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
|
@dataclass
|
||||||
class AudioMuxElement:
|
class AudioMuxElement:
|
||||||
payload: bytes
|
|
||||||
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
||||||
|
payload: bytes
|
||||||
|
|
||||||
def __init__(self, reader: BitReader, mux_config_present: int):
|
@classmethod
|
||||||
if mux_config_present == 0:
|
def from_bits(cls, reader: BitReader) -> Self:
|
||||||
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
|
|
||||||
|
|
||||||
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||||
|
# (only supports mux_config_present=1)
|
||||||
use_same_stream_mux = reader.read(1)
|
use_same_stream_mux = reader.read(1)
|
||||||
if use_same_stream_mux:
|
if use_same_stream_mux:
|
||||||
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
||||||
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
stream_mux_config = AacAudioRtpPacket.StreamMuxConfig.from_bits(reader)
|
||||||
|
|
||||||
# We only support:
|
# We only support:
|
||||||
# allStreamsSameTimeFraming == 1
|
# allStreamsSameTimeFraming == 1
|
||||||
@@ -344,19 +455,46 @@ class AacAudioRtpPacket:
|
|||||||
if tmp != 255:
|
if tmp != 255:
|
||||||
break
|
break
|
||||||
|
|
||||||
self.payload = reader.read_bytes(mux_slot_length_bytes)
|
payload = reader.read_bytes(mux_slot_length_bytes)
|
||||||
|
|
||||||
if self.stream_mux_config.other_data_present:
|
if stream_mux_config.other_data_present:
|
||||||
reader.skip(self.stream_mux_config.other_data_len_bits)
|
reader.skip(stream_mux_config.other_data_len_bits)
|
||||||
|
|
||||||
# ByteAlign
|
# ByteAlign
|
||||||
while reader.bit_position % 8:
|
while reader.bit_position % 8:
|
||||||
reader.read(1)
|
reader.read(1)
|
||||||
|
|
||||||
def __init__(self, data: bytes) -> None:
|
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:
|
||||||
# Parse the bit stream
|
# Parse the bit stream
|
||||||
reader = BitReader(data)
|
reader = BitReader(data)
|
||||||
self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
|
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)
|
||||||
|
|
||||||
def to_adts(self):
|
def to_adts(self):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
@@ -383,3 +521,11 @@ class AacAudioRtpPacket:
|
|||||||
)
|
)
|
||||||
+ self.audio_mux_element.payload
|
+ 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)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -149,7 +151,7 @@ QMF_COEFFS = [3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11]
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Classes
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class G722Decoder(object):
|
class G722Decoder:
|
||||||
"""G.722 decoder with bitrate 64kbit/s.
|
"""G.722 decoder with bitrate 64kbit/s.
|
||||||
|
|
||||||
For the Blocks in the sub-band decoders, please refer to the G.722
|
For the Blocks in the sub-band decoders, please refer to the G.722
|
||||||
@@ -157,7 +159,7 @@ class G722Decoder(object):
|
|||||||
https://www.itu.int/rec/T-REC-G.722-201209-I
|
https://www.itu.int/rec/T-REC-G.722-201209-I
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self._x = [0] * 24
|
self._x = [0] * 24
|
||||||
self._band = [Band(), Band()]
|
self._band = [Band(), Band()]
|
||||||
# The initial value in BLOCK 3L
|
# The initial value in BLOCK 3L
|
||||||
@@ -165,12 +167,12 @@ class G722Decoder(object):
|
|||||||
# The initial value in BLOCK 3H
|
# The initial value in BLOCK 3H
|
||||||
self._band[1].det = 8
|
self._band[1].det = 8
|
||||||
|
|
||||||
def decode_frame(self, encoded_data) -> bytearray:
|
def decode_frame(self, encoded_data: Union[bytes, bytearray]) -> bytearray:
|
||||||
result_array = bytearray(len(encoded_data) * 4)
|
result_array = bytearray(len(encoded_data) * 4)
|
||||||
self.g722_decode(result_array, encoded_data)
|
self.g722_decode(result_array, encoded_data)
|
||||||
return result_array
|
return result_array
|
||||||
|
|
||||||
def g722_decode(self, result_array, encoded_data) -> int:
|
def g722_decode(self, result_array, encoded_data: Union[bytes, bytearray]) -> int:
|
||||||
"""Decode the data frame using g722 decoder."""
|
"""Decode the data frame using g722 decoder."""
|
||||||
result_length = 0
|
result_length = 0
|
||||||
|
|
||||||
@@ -198,14 +200,16 @@ class G722Decoder(object):
|
|||||||
|
|
||||||
return result_length
|
return result_length
|
||||||
|
|
||||||
def update_decoded_result(self, xout, byte_length, byte_array) -> int:
|
def update_decoded_result(
|
||||||
|
self, xout: int, byte_length: int, byte_array: bytearray
|
||||||
|
) -> int:
|
||||||
result = (int)(xout >> 11)
|
result = (int)(xout >> 11)
|
||||||
bytes_result = result.to_bytes(2, 'little', signed=True)
|
bytes_result = result.to_bytes(2, 'little', signed=True)
|
||||||
byte_array[byte_length] = bytes_result[0]
|
byte_array[byte_length] = bytes_result[0]
|
||||||
byte_array[byte_length + 1] = bytes_result[1]
|
byte_array[byte_length + 1] = bytes_result[1]
|
||||||
return byte_length + 2
|
return byte_length + 2
|
||||||
|
|
||||||
def lower_sub_band_decoder(self, lower_bits) -> int:
|
def lower_sub_band_decoder(self, lower_bits: int) -> int:
|
||||||
"""Lower sub-band decoder for last six bits."""
|
"""Lower sub-band decoder for last six bits."""
|
||||||
|
|
||||||
# Block 5L
|
# Block 5L
|
||||||
@@ -258,7 +262,7 @@ class G722Decoder(object):
|
|||||||
|
|
||||||
return rlow
|
return rlow
|
||||||
|
|
||||||
def higher_sub_band_decoder(self, higher_bits) -> int:
|
def higher_sub_band_decoder(self, higher_bits: int) -> int:
|
||||||
"""Higher sub-band decoder for first two bits."""
|
"""Higher sub-band decoder for first two bits."""
|
||||||
|
|
||||||
# Block 2H
|
# Block 2H
|
||||||
@@ -306,14 +310,14 @@ class G722Decoder(object):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Band(object):
|
class Band:
|
||||||
"""Structure for G722 decode proccessing."""
|
"""Structure for G722 decode processing."""
|
||||||
|
|
||||||
s: int = 0
|
s: int = 0
|
||||||
nb: int = 0
|
nb: int = 0
|
||||||
det: int = 0
|
det: int = 0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self._sp = 0
|
self._sp = 0
|
||||||
self._sz = 0
|
self._sz = 0
|
||||||
self._r = [0] * 3
|
self._r = [0] * 3
|
||||||
|
|||||||
@@ -1571,14 +1571,22 @@ class Connection(CompositeEventEmitter):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
if self.transport == BT_LE_TRANSPORT:
|
||||||
f'Connection(handle=0x{self.handle:04X}, '
|
return (
|
||||||
f'role={self.role_name}, '
|
f'Connection(transport=LE, handle=0x{self.handle:04X}, '
|
||||||
f'self_address={self.self_address}, '
|
f'role={self.role_name}, '
|
||||||
f'self_resolvable_address={self.self_resolvable_address}, '
|
f'self_address={self.self_address}, '
|
||||||
f'peer_address={self.peer_address}, '
|
f'self_resolvable_address={self.self_resolvable_address}, '
|
||||||
f'peer_resolvable_address={self.peer_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})'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1766,9 +1774,9 @@ device_host_event_handlers: List[str] = []
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Device(CompositeEventEmitter):
|
class Device(CompositeEventEmitter):
|
||||||
# Incomplete list of fields.
|
# Incomplete list of fields.
|
||||||
random_address: Address # Random address that may change with RPA
|
random_address: Address # Random private address that may change periodically
|
||||||
public_address: Address # Public address (obtained from the controller)
|
public_address: Address # Public address that is globally unique (from controller)
|
||||||
static_address: Address # Random address that can be set but does not change
|
static_address: Address # Random static address that does not change once set
|
||||||
classic_enabled: bool
|
classic_enabled: bool
|
||||||
name: str
|
name: str
|
||||||
class_of_device: int
|
class_of_device: int
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ class Driver(common.Driver):
|
|||||||
fw_name: str = ""
|
fw_name: str = ""
|
||||||
config_name: str = ""
|
config_name: str = ""
|
||||||
|
|
||||||
|
POST_RESET_DELAY: float = 0.2
|
||||||
|
|
||||||
DRIVER_INFOS = [
|
DRIVER_INFOS = [
|
||||||
# 8723A
|
# 8723A
|
||||||
DriverInfo(
|
DriverInfo(
|
||||||
@@ -495,12 +497,24 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def driver_info_for_host(cls, host):
|
async def driver_info_for_host(cls, host):
|
||||||
await host.send_command(HCI_Reset_Command(), check_result=True)
|
try:
|
||||||
host.ready = True # Needed to let the host know the controller is ready.
|
await host.send_command(
|
||||||
|
HCI_Reset_Command(),
|
||||||
|
check_result=True,
|
||||||
|
response_timeout=cls.POST_RESET_DELAY,
|
||||||
|
)
|
||||||
|
host.ready = True # Needed to let the host know the controller is ready.
|
||||||
|
except asyncio.exceptions.TimeoutError:
|
||||||
|
logger.warning("timeout waiting for hci reset, retrying")
|
||||||
|
await host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
|
host.ready = True
|
||||||
|
|
||||||
|
command = HCI_Read_Local_Version_Information_Command()
|
||||||
|
response = await host.send_command(command, check_result=True)
|
||||||
|
if response.command_opcode != command.op_code:
|
||||||
|
logger.error("failed to probe local version information")
|
||||||
|
return None
|
||||||
|
|
||||||
response = await host.send_command(
|
|
||||||
HCI_Read_Local_Version_Information_Command(), check_result=True
|
|
||||||
)
|
|
||||||
local_version = response.return_parameters
|
local_version = response.return_parameters
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -238,22 +238,22 @@ GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x
|
|||||||
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control Id')
|
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control Id')
|
||||||
|
|
||||||
# Telephone Bearer Service (TBS)
|
# Telephone Bearer Service (TBS)
|
||||||
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB4, 'Bearer Provider Name')
|
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB3, 'Bearer Provider Name')
|
||||||
GATT_BEARER_UCI_CHARACTERISTIC = UUID.from_16_bits(0x2BB5, 'Bearer UCI')
|
GATT_BEARER_UCI_CHARACTERISTIC = UUID.from_16_bits(0x2BB4, 'Bearer UCI')
|
||||||
GATT_BEARER_TECHNOLOGY_CHARACTERISTIC = UUID.from_16_bits(0x2BB6, 'Bearer Technology')
|
GATT_BEARER_TECHNOLOGY_CHARACTERISTIC = UUID.from_16_bits(0x2BB5, 'Bearer Technology')
|
||||||
GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2BB7, 'Bearer URI Schemes Supported List')
|
GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2BB6, 'Bearer URI Schemes Supported List')
|
||||||
GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength')
|
GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC = UUID.from_16_bits(0x2BB7, 'Bearer Signal Strength')
|
||||||
GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer Signal Strength Reporting Interval')
|
GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength Reporting Interval')
|
||||||
GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Bearer List Current Calls')
|
GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer List Current Calls')
|
||||||
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBB, 'Content Control ID')
|
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control ID')
|
||||||
GATT_STATUS_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2BBC, 'Status Flags')
|
GATT_STATUS_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2BBB, 'Status Flags')
|
||||||
GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC = UUID.from_16_bits(0x2BBD, 'Incoming Call Target Bearer URI')
|
GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC = UUID.from_16_bits(0x2BBC, 'Incoming Call Target Bearer URI')
|
||||||
GATT_CALL_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BBE, 'Call State')
|
GATT_CALL_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BBD, 'Call State')
|
||||||
GATT_CALL_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BBF, 'Call Control Point')
|
GATT_CALL_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BBE, 'Call Control Point')
|
||||||
GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC = UUID.from_16_bits(0x2BC0, 'Call Control Point Optional Opcodes')
|
GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC = UUID.from_16_bits(0x2BBF, 'Call Control Point Optional Opcodes')
|
||||||
GATT_TERMINATION_REASON_CHARACTERISTIC = UUID.from_16_bits(0x2BC1, 'Termination Reason')
|
GATT_TERMINATION_REASON_CHARACTERISTIC = UUID.from_16_bits(0x2BC0, 'Termination Reason')
|
||||||
GATT_INCOMING_CALL_CHARACTERISTIC = UUID.from_16_bits(0x2BC2, 'Incoming Call')
|
GATT_INCOMING_CALL_CHARACTERISTIC = UUID.from_16_bits(0x2BC1, 'Incoming Call')
|
||||||
GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Call Friendly Name')
|
GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BC2, 'Call Friendly Name')
|
||||||
|
|
||||||
# Microphone Control Service (MICS)
|
# Microphone Control Service (MICS)
|
||||||
GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
|
GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
|
||||||
@@ -275,6 +275,11 @@ GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Sou
|
|||||||
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
|
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
|
||||||
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
|
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
|
||||||
|
|
||||||
|
# Hearing Access Service
|
||||||
|
GATT_HEARING_AID_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2BDA, 'Hearing Aid Features')
|
||||||
|
GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BDB, 'Hearing Aid Preset Control Point')
|
||||||
|
GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC = UUID.from_16_bits(0x2BDC, 'Active Preset Index')
|
||||||
|
|
||||||
# ASHA Service
|
# ASHA Service
|
||||||
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
|
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
|
||||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
|
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
|
||||||
@@ -340,7 +345,7 @@ class Service(Attribute):
|
|||||||
uuid: Union[str, UUID],
|
uuid: Union[str, UUID],
|
||||||
characteristics: List[Characteristic],
|
characteristics: List[Characteristic],
|
||||||
primary=True,
|
primary=True,
|
||||||
included_services: List[Service] = [],
|
included_services: Iterable[Service] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
# Convert the uuid to a UUID object if it isn't already
|
# Convert the uuid to a UUID object if it isn't already
|
||||||
if isinstance(uuid, str):
|
if isinstance(uuid, str):
|
||||||
@@ -356,7 +361,7 @@ class Service(Attribute):
|
|||||||
uuid.to_pdu_bytes(),
|
uuid.to_pdu_bytes(),
|
||||||
)
|
)
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.included_services = included_services[:]
|
self.included_services = list(included_services)
|
||||||
self.characteristics = characteristics[:]
|
self.characteristics = characteristics[:]
|
||||||
self.primary = primary
|
self.primary = primary
|
||||||
|
|
||||||
@@ -390,7 +395,7 @@ class TemplateService(Service):
|
|||||||
self,
|
self,
|
||||||
characteristics: List[Characteristic],
|
characteristics: List[Characteristic],
|
||||||
primary: bool = True,
|
primary: bool = True,
|
||||||
included_services: List[Service] = [],
|
included_services: Iterable[Service] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(self.UUID, characteristics, primary, included_services)
|
super().__init__(self.UUID, characteristics, primary, included_services)
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ from .att import (
|
|||||||
ATT_Error,
|
ATT_Error,
|
||||||
)
|
)
|
||||||
from . import core
|
from . import core
|
||||||
from .core import UUID, InvalidStateError, ProtocolError
|
from .core import UUID, InvalidStateError
|
||||||
from .gatt import (
|
from .gatt import (
|
||||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||||
@@ -345,12 +345,7 @@ class Client:
|
|||||||
self.mtu_exchange_done = True
|
self.mtu_exchange_done = True
|
||||||
response = await self.send_request(ATT_Exchange_MTU_Request(client_rx_mtu=mtu))
|
response = await self.send_request(ATT_Exchange_MTU_Request(client_rx_mtu=mtu))
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == ATT_ERROR_RESPONSE:
|
||||||
raise ProtocolError(
|
raise ATT_Error(error_code=response.error_code, message=response)
|
||||||
response.error_code,
|
|
||||||
'att',
|
|
||||||
ATT_PDU.error_name(response.error_code),
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compute the final MTU
|
# Compute the final MTU
|
||||||
self.connection.att_mtu = min(mtu, response.server_rx_mtu)
|
self.connection.att_mtu = min(mtu, response.server_rx_mtu)
|
||||||
@@ -936,12 +931,7 @@ class Client:
|
|||||||
if response is None:
|
if response is None:
|
||||||
raise TimeoutError('read timeout')
|
raise TimeoutError('read timeout')
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == ATT_ERROR_RESPONSE:
|
||||||
raise ProtocolError(
|
raise ATT_Error(error_code=response.error_code, message=response)
|
||||||
response.error_code,
|
|
||||||
'att',
|
|
||||||
ATT_PDU.error_name(response.error_code),
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the value is the max size for the MTU, try to read more unless the caller
|
# If the value is the max size for the MTU, try to read more unless the caller
|
||||||
# specifically asked not to do that
|
# specifically asked not to do that
|
||||||
@@ -963,12 +953,7 @@ class Client:
|
|||||||
ATT_INVALID_OFFSET_ERROR,
|
ATT_INVALID_OFFSET_ERROR,
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
raise ProtocolError(
|
raise ATT_Error(error_code=response.error_code, message=response)
|
||||||
response.error_code,
|
|
||||||
'att',
|
|
||||||
ATT_PDU.error_name(response.error_code),
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
|
|
||||||
part = response.part_attribute_value
|
part = response.part_attribute_value
|
||||||
attribute_value += part
|
attribute_value += part
|
||||||
@@ -1061,12 +1046,7 @@ class Client:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == ATT_ERROR_RESPONSE:
|
||||||
raise ProtocolError(
|
raise ATT_Error(error_code=response.error_code, message=response)
|
||||||
response.error_code,
|
|
||||||
'att',
|
|
||||||
ATT_PDU.error_name(response.error_code),
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
ATT_Write_Command(
|
ATT_Write_Command(
|
||||||
|
|||||||
@@ -915,7 +915,7 @@ class Server(EventEmitter):
|
|||||||
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
|
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Check that the attribute exists
|
# Check that the attribute exists
|
||||||
attribute = self.get_attribute(request.attribute_handle)
|
attribute = self.get_attribute(request.attribute_handle)
|
||||||
if attribute is None:
|
if attribute is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
@@ -942,11 +942,19 @@ class Server(EventEmitter):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Accept the value
|
try:
|
||||||
await attribute.write_value(connection, request.attribute_value)
|
# Accept the value
|
||||||
|
await attribute.write_value(connection, request.attribute_value)
|
||||||
# Done
|
except ATT_Error as error:
|
||||||
self.send_response(connection, ATT_Write_Response())
|
response = ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
|
error_code=error.error_code,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Done
|
||||||
|
response = ATT_Write_Response()
|
||||||
|
self.send_response(connection, response)
|
||||||
|
|
||||||
@AsyncRunner.run_in_task()
|
@AsyncRunner.run_in_task()
|
||||||
async def on_att_write_command(self, connection, request):
|
async def on_att_write_command(self, connection, request):
|
||||||
|
|||||||
305
bumble/hci.py
305
bumble/hci.py
@@ -267,6 +267,19 @@ HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_V2_EVENT = 0X26
|
|||||||
HCI_LE_PERIODIC_ADVERTISING_SUBEVENT_DATA_REQUEST_EVENT = 0X27
|
HCI_LE_PERIODIC_ADVERTISING_SUBEVENT_DATA_REQUEST_EVENT = 0X27
|
||||||
HCI_LE_PERIODIC_ADVERTISING_RESPONSE_REPORT_EVENT = 0X28
|
HCI_LE_PERIODIC_ADVERTISING_RESPONSE_REPORT_EVENT = 0X28
|
||||||
HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT = 0X29
|
HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT = 0X29
|
||||||
|
HCI_LE_READ_ALL_REMOTE_FEATURES_COMPLETE_EVENT = 0x2A
|
||||||
|
HCI_LE_CIS_ESTABLISHED_V2_EVENT = 0x2B
|
||||||
|
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT = 0x2C
|
||||||
|
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMPLETE_EVENT = 0x2D
|
||||||
|
HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT = 0x2E
|
||||||
|
HCI_LE_CS_CONFIG_COMPLETE_EVENT = 0x2F
|
||||||
|
HCI_LE_CS_PROCEDURE_ENABLE_EVENT = 0x30
|
||||||
|
HCI_LE_CS_SUBEVENT_RESULT_EVENT = 0x31
|
||||||
|
HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT = 0x32
|
||||||
|
HCI_LE_CS_TEST_END_COMPLETE_EVENT = 0x33
|
||||||
|
HCI_LE_MONITORED_ADVERTISERS_REPORT_EVENT = 0x34
|
||||||
|
HCI_LE_FRAME_SPACE_UPDATE_EVENT = 0x35
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# HCI Command
|
# HCI Command
|
||||||
@@ -573,11 +586,36 @@ HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_c
|
|||||||
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
|
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
|
||||||
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
|
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
|
||||||
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x007F)
|
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x007F)
|
||||||
|
HCI_LE_SET_DECISION_DATA_COMMAND = hci_command_op_code(0x08, 0x0080)
|
||||||
|
HCI_LE_SET_DECISION_INSTRUCTIONS_COMMAND = hci_command_op_code(0x08, 0x0081)
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND = hci_command_op_code(0x08, 0x0082)
|
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND = hci_command_op_code(0x08, 0x0082)
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND = hci_command_op_code(0x08, 0x0083)
|
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND = hci_command_op_code(0x08, 0x0083)
|
||||||
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND = hci_command_op_code(0x08, 0x0084)
|
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND = hci_command_op_code(0x08, 0x0084)
|
||||||
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND = hci_command_op_code(0x08, 0x0085)
|
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND = hci_command_op_code(0x08, 0x0085)
|
||||||
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x0086)
|
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x0086)
|
||||||
|
HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND = hci_command_op_code(0x08, 0x0087)
|
||||||
|
HCI_LE_READ_ALL_REMOTE_FEATURES_COMMAND = hci_command_op_code(0x08, 0x0088)
|
||||||
|
HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x0089)
|
||||||
|
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x008A)
|
||||||
|
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES = hci_command_op_code(0x08, 0x008B)
|
||||||
|
HCI_LE_CS_SECURITY_ENABLE_COMMAND = hci_command_op_code(0x08, 0x008C)
|
||||||
|
HCI_LE_CS_SET_DEFAULT_SETTINGS_COMMAND = hci_command_op_code(0x08, 0x008D)
|
||||||
|
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMMAND = hci_command_op_code(0x08, 0x008E)
|
||||||
|
HCI_LE_CS_WRITE_CACHED_REMOTE_FAE_TABLE_COMMAND = hci_command_op_code(0x08, 0x008F)
|
||||||
|
HCI_LE_CS_CREATE_CONFIG_COMMAND = hci_command_op_code(0x08, 0x0090)
|
||||||
|
HCI_LE_CS_REMOVE_CONFIG_COMMAND = hci_command_op_code(0x08, 0x0091)
|
||||||
|
HCI_LE_CS_SET_CHANNEL_CLASSIFICATION_COMMAND = hci_command_op_code(0x08, 0x0092)
|
||||||
|
HCI_LE_CS_SET_PROCEDURE_PARAMETERS_COMMAND = hci_command_op_code(0x08, 0x0093)
|
||||||
|
HCI_LE_CS_PROCEDURE_ENABLE_COMMAND = hci_command_op_code(0x08, 0x0094)
|
||||||
|
HCI_LE_CS_TEST_COMMAND = hci_command_op_code(0x08, 0x0095)
|
||||||
|
HCI_LE_CS_TEST_END_COMMAND = hci_command_op_code(0x08, 0x0096)
|
||||||
|
HCI_LE_SET_HOST_FEATURE_V2_COMMAND = hci_command_op_code(0x08, 0x0097)
|
||||||
|
HCI_LE_ADD_DEVICE_TO_MONITORED_ADVERTISERS_LIST_COMMAND = hci_command_op_code(0x08, 0x0098)
|
||||||
|
HCI_LE_REMOVE_DEVICE_FROM_MONITORED_ADVERTISERS_LIST_COMMAND = hci_command_op_code(0x08, 0x0099)
|
||||||
|
HCI_LE_CLEAR_MONITORED_ADVERTISERS_LIST_COMMAND = hci_command_op_code(0x08, 0x009A)
|
||||||
|
HCI_LE_READ_MONITORED_ADVERTISERS_LIST_SIZE_COMMAND = hci_command_op_code(0x08, 0x009B)
|
||||||
|
HCI_LE_ENABLE_MONITORING_ADVERTISERS_COMMAND = hci_command_op_code(0x08, 0x009C)
|
||||||
|
HCI_LE_FRAME_SPACE_UPDATE_COMMAND = hci_command_op_code(0x08, 0x009D)
|
||||||
|
|
||||||
|
|
||||||
# HCI Error Codes
|
# HCI Error Codes
|
||||||
@@ -1150,8 +1188,16 @@ class LeFeature(OpenIntEnum):
|
|||||||
CHANNEL_CLASSIFICATION = 39
|
CHANNEL_CLASSIFICATION = 39
|
||||||
ADVERTISING_CODING_SELECTION = 40
|
ADVERTISING_CODING_SELECTION = 40
|
||||||
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 41
|
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 41
|
||||||
|
DECISION_BASED_ADVERTISING_FILTERING = 42
|
||||||
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 43
|
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 43
|
||||||
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 44
|
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 44
|
||||||
|
UNSEGMENTED_FRAMED_MODE = 45
|
||||||
|
CHANNEL_SOUNDING = 46
|
||||||
|
CHANNEL_SOUNDING_HOST_SUPPORT = 47
|
||||||
|
CHANNEL_SOUNDING_TONE_QUALITY_INDICATION = 48
|
||||||
|
LL_EXTENDED_FEATURE_SET = 63
|
||||||
|
MONITORING_ADVERTISERS = 64
|
||||||
|
FRAME_SPACE_UPDATE = 65
|
||||||
|
|
||||||
class LeFeatureMask(enum.IntFlag):
|
class LeFeatureMask(enum.IntFlag):
|
||||||
LE_ENCRYPTION = 1 << LeFeature.LE_ENCRYPTION
|
LE_ENCRYPTION = 1 << LeFeature.LE_ENCRYPTION
|
||||||
@@ -1196,8 +1242,16 @@ class LeFeatureMask(enum.IntFlag):
|
|||||||
CHANNEL_CLASSIFICATION = 1 << LeFeature.CHANNEL_CLASSIFICATION
|
CHANNEL_CLASSIFICATION = 1 << LeFeature.CHANNEL_CLASSIFICATION
|
||||||
ADVERTISING_CODING_SELECTION = 1 << LeFeature.ADVERTISING_CODING_SELECTION
|
ADVERTISING_CODING_SELECTION = 1 << LeFeature.ADVERTISING_CODING_SELECTION
|
||||||
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 1 << LeFeature.ADVERTISING_CODING_SELECTION_HOST_SUPPORT
|
ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 1 << LeFeature.ADVERTISING_CODING_SELECTION_HOST_SUPPORT
|
||||||
|
DECISION_BASED_ADVERTISING_FILTERING = 1 << LeFeature.DECISION_BASED_ADVERTISING_FILTERING
|
||||||
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
|
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
|
||||||
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
|
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
|
||||||
|
UNSEGMENTED_FRAMED_MODE = 1 << LeFeature.UNSEGMENTED_FRAMED_MODE
|
||||||
|
CHANNEL_SOUNDING = 1 << LeFeature.CHANNEL_SOUNDING
|
||||||
|
CHANNEL_SOUNDING_HOST_SUPPORT = 1 << LeFeature.CHANNEL_SOUNDING_HOST_SUPPORT
|
||||||
|
CHANNEL_SOUNDING_TONE_QUALITY_INDICATION = 1 << LeFeature.CHANNEL_SOUNDING_TONE_QUALITY_INDICATION
|
||||||
|
LL_EXTENDED_FEATURE_SET = 1 << LeFeature.LL_EXTENDED_FEATURE_SET
|
||||||
|
MONITORING_ADVERTISERS = 1 << LeFeature.MONITORING_ADVERTISERS
|
||||||
|
FRAME_SPACE_UPDATE = 1 << LeFeature.FRAME_SPACE_UPDATE
|
||||||
|
|
||||||
class LmpFeature(enum.IntEnum):
|
class LmpFeature(enum.IntEnum):
|
||||||
# Page 0 (Legacy LMP features)
|
# Page 0 (Legacy LMP features)
|
||||||
@@ -1565,12 +1619,16 @@ class HCI_Object:
|
|||||||
# This is an array field, starting with a 1-byte item count.
|
# This is an array field, starting with a 1-byte item count.
|
||||||
item_count = data[offset]
|
item_count = data[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
|
# Set fields first, because item_count might be 0.
|
||||||
|
for sub_field_name, _ in field:
|
||||||
|
result[sub_field_name] = []
|
||||||
|
|
||||||
for _ in range(item_count):
|
for _ in range(item_count):
|
||||||
for sub_field_name, sub_field_type in field:
|
for sub_field_name, sub_field_type in field:
|
||||||
value, size = HCI_Object.parse_field(
|
value, size = HCI_Object.parse_field(
|
||||||
data, offset, sub_field_type
|
data, offset, sub_field_type
|
||||||
)
|
)
|
||||||
result.setdefault(sub_field_name, []).append(value)
|
result[sub_field_name].append(value)
|
||||||
offset += size
|
offset += size
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -2982,6 +3040,27 @@ class HCI_Write_Inquiry_Scan_Activity_Command(HCI_Command):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('authentication_enable', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_Read_Authentication_Enable_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.3.23 Read Authentication Enable Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command([('authentication_enable', 1)])
|
||||||
|
class HCI_Write_Authentication_Enable_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.3.24 Write Authentication Enable Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
return_parameters_fields=[
|
return_parameters_fields=[
|
||||||
@@ -3022,7 +3101,12 @@ class HCI_Write_Voice_Setting_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('synchronous_flow_control_enable', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_Read_Synchronous_Flow_Control_Enable_Command(HCI_Command):
|
class HCI_Read_Synchronous_Flow_Control_Enable_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.3.36 Read Synchronous Flow Control Enable Command
|
See Bluetooth spec @ 7.3.36 Read Synchronous Flow Control Enable Command
|
||||||
@@ -3191,7 +3275,13 @@ class HCI_Set_Event_Mask_Page_2_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('le_supported_host', 1),
|
||||||
|
('unused', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_Read_LE_Host_Support_Command(HCI_Command):
|
class HCI_Read_LE_Host_Support_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.3.78 Read LE Host Support Command
|
See Bluetooth spec @ 7.3.78 Read LE Host Support Command
|
||||||
@@ -3324,13 +3414,39 @@ class HCI_Read_BD_ADDR_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
("status", STATUS_SPEC),
|
||||||
|
[("standard_codec_ids", 1)],
|
||||||
|
[("vendor_specific_codec_ids", 4)],
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_Read_Local_Supported_Codecs_Command(HCI_Command):
|
class HCI_Read_Local_Supported_Codecs_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.4.8 Read Local Supported Codecs Command
|
See Bluetooth spec @ 7.4.8 Read Local Supported Codecs Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
("status", STATUS_SPEC),
|
||||||
|
[("standard_codec_ids", 1), ("standard_codec_transports", 1)],
|
||||||
|
[("vendor_specific_codec_ids", 4), ("vendor_specific_codec_transports", 1)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_Read_Local_Supported_Codecs_V2_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.4.8 Read Local Supported Codecs Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Transport(enum.IntFlag):
|
||||||
|
BR_EDR_ACL = 1 << 0
|
||||||
|
BR_EDR_SCO = 1 << 1
|
||||||
|
LE_CIS = 1 << 2
|
||||||
|
LE_BIS = 1 << 3
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[('handle', 2)],
|
fields=[('handle', 2)],
|
||||||
@@ -3488,7 +3604,12 @@ class HCI_LE_Set_Advertising_Parameters_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('tx_power_level', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_LE_Read_Advertising_Physical_Channel_Tx_Power_Command(HCI_Command):
|
class HCI_LE_Read_Advertising_Physical_Channel_Tx_Power_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.8.6 LE Read Advertising Physical Channel Tx Power Command
|
See Bluetooth spec @ 7.8.6 LE Read Advertising Physical Channel Tx Power Command
|
||||||
@@ -3612,7 +3733,12 @@ class HCI_LE_Create_Connection_Cancel_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('filter_accept_list_size', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_LE_Read_Filter_Accept_List_Size_Command(HCI_Command):
|
class HCI_LE_Read_Filter_Accept_List_Size_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.8.14 LE Read Filter Accept List Size Command
|
See Bluetooth spec @ 7.8.14 LE Read Filter Accept List Size Command
|
||||||
@@ -3723,7 +3849,12 @@ class HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command()
|
@HCI_Command.command(
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('le_states', 8),
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_LE_Read_Supported_States_Command(HCI_Command):
|
class HCI_LE_Read_Supported_States_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.8.27 LE Read Supported States Command
|
See Bluetooth spec @ 7.8.27 LE Read Supported States Command
|
||||||
@@ -4698,6 +4829,102 @@ class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
|
|||||||
reason: int
|
reason: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('big_handle', 1),
|
||||||
|
('advertising_handle', 1),
|
||||||
|
('num_bis', 1),
|
||||||
|
('sdu_interval', 3),
|
||||||
|
('max_sdu', 2),
|
||||||
|
('max_transport_latency', 2),
|
||||||
|
('rtn', 1),
|
||||||
|
('phy', 1),
|
||||||
|
('packing', 1),
|
||||||
|
('framing', 1),
|
||||||
|
('encryption', 1),
|
||||||
|
('broadcast_code', 16),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Create_BIG_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.103 LE Create BIG command
|
||||||
|
'''
|
||||||
|
|
||||||
|
big_handle: int
|
||||||
|
advertising_handle: int
|
||||||
|
num_bis: int
|
||||||
|
sdu_interval: int
|
||||||
|
max_sdu: int
|
||||||
|
max_transport_latency: int
|
||||||
|
rtn: int
|
||||||
|
phy: int
|
||||||
|
packing: int
|
||||||
|
framing: int
|
||||||
|
encryption: int
|
||||||
|
broadcast_code: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('big_handle', 1),
|
||||||
|
('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_Terminate_BIG_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.105 LE Terminate BIG command
|
||||||
|
'''
|
||||||
|
|
||||||
|
big_handle: int
|
||||||
|
reason: int
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('big_handle', 1),
|
||||||
|
('sync_handle', 2),
|
||||||
|
('encryption', 1),
|
||||||
|
('broadcast_code', 16),
|
||||||
|
('mse', 1),
|
||||||
|
('big_sync_timeout', 2),
|
||||||
|
[('bis', 1)],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_BIG_Create_Sync_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.106 LE BIG Create Sync command
|
||||||
|
'''
|
||||||
|
|
||||||
|
big_handle: int
|
||||||
|
sync_handle: int
|
||||||
|
encryption: int
|
||||||
|
broadcast_code: int
|
||||||
|
mse: int
|
||||||
|
big_sync_timeout: int
|
||||||
|
bis: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
('big_handle', 1),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('big_handle', 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_LE_BIG_Terminate_Sync_Command(HCI_Command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.8.107. LE BIG Terminate Sync command
|
||||||
|
'''
|
||||||
|
|
||||||
|
big_handle: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
fields=[
|
fields=[
|
||||||
@@ -5533,6 +5760,27 @@ class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_LE_Meta_Event.event(
|
||||||
|
[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('service_data', 2),
|
||||||
|
('sync_handle', 2),
|
||||||
|
('advertising_sid', 1),
|
||||||
|
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
|
||||||
|
('advertiser_address', Address.parse_address_preceded_by_type),
|
||||||
|
('advertiser_phy', 1),
|
||||||
|
('periodic_advertising_interval', 2),
|
||||||
|
('advertiser_clock_accuracy', 1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_LE_Periodic_Advertising_Sync_Transfer_Received_Event(HCI_LE_Meta_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.65.24 LE Periodic Advertising Sync Transfer Received Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_LE_Meta_Event.event(
|
@HCI_LE_Meta_Event.event(
|
||||||
[
|
[
|
||||||
@@ -5817,6 +6065,32 @@ 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(
|
@HCI_Event.event(
|
||||||
[
|
[
|
||||||
@@ -6225,6 +6499,23 @@ class HCI_Synchronous_Connection_Changed_Event(HCI_Event):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Event.event(
|
||||||
|
[
|
||||||
|
('status', STATUS_SPEC),
|
||||||
|
('connection_handle', 2),
|
||||||
|
('max_tx_latency', 2),
|
||||||
|
('max_rx_latency', 2),
|
||||||
|
('min_remote_timeout', 2),
|
||||||
|
('min_local_timeout', 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class HCI_Sniff_Subrating_Event(HCI_Event):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ 7.7.37 Sniff Subrating Event
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Event.event(
|
@HCI_Event.event(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -795,29 +795,32 @@ class HfProtocol(pyee.EventEmitter):
|
|||||||
# Append to the read buffer.
|
# Append to the read buffer.
|
||||||
self.read_buffer.extend(data)
|
self.read_buffer.extend(data)
|
||||||
|
|
||||||
# Locate header and trailer.
|
while self.read_buffer:
|
||||||
header = self.read_buffer.find(b'\r\n')
|
# Locate header and trailer.
|
||||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
header = self.read_buffer.find(b'\r\n')
|
||||||
if header == -1 or trailer == -1:
|
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||||
return
|
if header == -1 or trailer == -1:
|
||||||
|
return
|
||||||
|
|
||||||
# Isolate the AT response code and parameters.
|
# Isolate the AT response code and parameters.
|
||||||
raw_response = self.read_buffer[header + 2 : trailer]
|
raw_response = self.read_buffer[header + 2 : trailer]
|
||||||
response = AtResponse.parse_from(raw_response)
|
response = AtResponse.parse_from(raw_response)
|
||||||
logger.debug(f"<<< {raw_response.decode()}")
|
logger.debug(f"<<< {raw_response.decode()}")
|
||||||
|
|
||||||
# Consume the response bytes.
|
# Consume the response bytes.
|
||||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||||
|
|
||||||
# Forward the received code to the correct queue.
|
# Forward the received code to the correct queue.
|
||||||
if self.command_lock.locked() and (
|
if self.command_lock.locked() and (
|
||||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||||
):
|
):
|
||||||
self.response_queue.put_nowait(response)
|
self.response_queue.put_nowait(response)
|
||||||
elif response.code in UNSOLICITED_CODES:
|
elif response.code in UNSOLICITED_CODES:
|
||||||
self.unsolicited_queue.put_nowait(response)
|
self.unsolicited_queue.put_nowait(response)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
logger.warning(
|
||||||
|
f"dropping unexpected response with code '{response.code}'"
|
||||||
|
)
|
||||||
|
|
||||||
async def execute_command(
|
async def execute_command(
|
||||||
self,
|
self,
|
||||||
@@ -1244,31 +1247,32 @@ class AgProtocol(pyee.EventEmitter):
|
|||||||
# Append to the read buffer.
|
# Append to the read buffer.
|
||||||
self.read_buffer.extend(data)
|
self.read_buffer.extend(data)
|
||||||
|
|
||||||
# Locate the trailer.
|
while self.read_buffer:
|
||||||
trailer = self.read_buffer.find(b'\r')
|
# Locate the trailer.
|
||||||
if trailer == -1:
|
trailer = self.read_buffer.find(b'\r')
|
||||||
return
|
if trailer == -1:
|
||||||
|
return
|
||||||
|
|
||||||
# Isolate the AT response code and parameters.
|
# Isolate the AT response code and parameters.
|
||||||
raw_command = self.read_buffer[:trailer]
|
raw_command = self.read_buffer[:trailer]
|
||||||
command = AtCommand.parse_from(raw_command)
|
command = AtCommand.parse_from(raw_command)
|
||||||
logger.debug(f"<<< {raw_command.decode()}")
|
logger.debug(f"<<< {raw_command.decode()}")
|
||||||
|
|
||||||
# Consume the response bytes.
|
# Consume the response bytes.
|
||||||
self.read_buffer = self.read_buffer[trailer + 1 :]
|
self.read_buffer = self.read_buffer[trailer + 1 :]
|
||||||
|
|
||||||
if command.sub_code == AtCommand.SubCode.TEST:
|
if command.sub_code == AtCommand.SubCode.TEST:
|
||||||
handler_name = f'_on_{command.code.lower()}_test'
|
handler_name = f'_on_{command.code.lower()}_test'
|
||||||
elif command.sub_code == AtCommand.SubCode.READ:
|
elif command.sub_code == AtCommand.SubCode.READ:
|
||||||
handler_name = f'_on_{command.code.lower()}_read'
|
handler_name = f'_on_{command.code.lower()}_read'
|
||||||
else:
|
else:
|
||||||
handler_name = f'_on_{command.code.lower()}'
|
handler_name = f'_on_{command.code.lower()}'
|
||||||
|
|
||||||
if handler := getattr(self, handler_name, None):
|
if handler := getattr(self, handler_name, None):
|
||||||
handler(*command.parameters)
|
handler(*command.parameters)
|
||||||
else:
|
else:
|
||||||
logger.warning('Handler %s not found', handler_name)
|
logger.warning('Handler %s not found', handler_name)
|
||||||
self.send_response('ERROR')
|
self.send_response('ERROR')
|
||||||
|
|
||||||
def send_response(self, response: str) -> None:
|
def send_response(self, response: str) -> None:
|
||||||
"""Sends an AT response."""
|
"""Sends an AT response."""
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.cis_links = {} # CIS links, by connection handle
|
self.cis_links = {} # CIS links, by connection handle
|
||||||
self.sco_links = {} # SCO links, by connection handle
|
self.sco_links = {} # SCO links, by connection handle
|
||||||
self.pending_command = None
|
self.pending_command = None
|
||||||
self.pending_response = None
|
self.pending_response: Optional[asyncio.Future[Any]] = None
|
||||||
self.number_of_supported_advertising_sets = 0
|
self.number_of_supported_advertising_sets = 0
|
||||||
self.maximum_advertising_data_length = 31
|
self.maximum_advertising_data_length = 31
|
||||||
self.local_version = None
|
self.local_version = None
|
||||||
@@ -514,7 +514,9 @@ class Host(AbortableEventEmitter):
|
|||||||
if self.hci_sink:
|
if self.hci_sink:
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(self, command, check_result=False):
|
async def send_command(
|
||||||
|
self, command, check_result=False, response_timeout: Optional[int] = None
|
||||||
|
):
|
||||||
# Wait until we can send (only one pending command at a time)
|
# Wait until we can send (only one pending command at a time)
|
||||||
async with self.command_semaphore:
|
async with self.command_semaphore:
|
||||||
assert self.pending_command is None
|
assert self.pending_command is None
|
||||||
@@ -526,12 +528,13 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_hci_packet(command)
|
self.send_hci_packet(command)
|
||||||
response = await self.pending_response
|
await asyncio.wait_for(self.pending_response, timeout=response_timeout)
|
||||||
|
response = self.pending_response.result()
|
||||||
|
|
||||||
# Check the return parameters if required
|
# Check the return parameters if required
|
||||||
if check_result:
|
if check_result:
|
||||||
if isinstance(response, hci.HCI_Command_Status_Event):
|
if isinstance(response, hci.HCI_Command_Status_Event):
|
||||||
status = response.status
|
status = response.status # type: ignore[attr-defined]
|
||||||
elif isinstance(response.return_parameters, int):
|
elif isinstance(response.return_parameters, int):
|
||||||
status = response.return_parameters
|
status = response.return_parameters
|
||||||
elif isinstance(response.return_parameters, bytes):
|
elif isinstance(response.return_parameters, bytes):
|
||||||
@@ -625,14 +628,21 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||||
def on_packet(self, packet: bytes) -> None:
|
def on_packet(self, packet: bytes) -> None:
|
||||||
hci_packet = hci.HCI_Packet.from_bytes(packet)
|
try:
|
||||||
|
hci_packet = hci.HCI_Packet.from_bytes(packet)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(f'!!! error parsing packet from bytes: {error}')
|
||||||
|
return
|
||||||
|
|
||||||
if self.ready or (
|
if self.ready or (
|
||||||
isinstance(hci_packet, hci.HCI_Command_Complete_Event)
|
isinstance(hci_packet, hci.HCI_Command_Complete_Event)
|
||||||
and hci_packet.command_opcode == hci.HCI_RESET_COMMAND
|
and hci_packet.command_opcode == hci.HCI_RESET_COMMAND
|
||||||
):
|
):
|
||||||
self.on_hci_packet(hci_packet)
|
self.on_hci_packet(hci_packet)
|
||||||
else:
|
else:
|
||||||
logger.debug('reset not done, ignoring packet from controller')
|
logger.debug(
|
||||||
|
f'reset not done, ignoring packet from controller: {hci_packet}'
|
||||||
|
)
|
||||||
|
|
||||||
def on_transport_lost(self):
|
def on_transport_lost(self):
|
||||||
# Called by the source when the transport has been lost.
|
# Called by the source when the transport has been lost.
|
||||||
@@ -1096,6 +1106,18 @@ class Host(AbortableEventEmitter):
|
|||||||
event.status,
|
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):
|
def on_hci_link_supervision_timeout_changed_event(self, event):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1911,6 +1911,7 @@ class ChannelManager:
|
|||||||
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
||||||
else:
|
else:
|
||||||
result = L2CAP_Information_Response.NOT_SUPPORTED
|
result = L2CAP_Information_Response.NOT_SUPPORTED
|
||||||
|
data = b''
|
||||||
|
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ class LocalLink:
|
|||||||
elif transport == BT_BR_EDR_TRANSPORT:
|
elif transport == BT_BR_EDR_TRANSPORT:
|
||||||
destination_controller = self.find_classic_controller(destination_address)
|
destination_controller = self.find_classic_controller(destination_address)
|
||||||
source_address = sender_controller.public_address
|
source_address = sender_controller.public_address
|
||||||
|
else:
|
||||||
|
raise ValueError("unsupported transport type")
|
||||||
|
|
||||||
if destination_controller is not None:
|
if destination_controller is not None:
|
||||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
destination_controller.on_link_acl_data(source_address, transport, data)
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import grpc.aio
|
|||||||
from .config import Config
|
from .config import Config
|
||||||
from .device import PandoraDevice
|
from .device import PandoraDevice
|
||||||
from .host import HostService
|
from .host import HostService
|
||||||
|
from .l2cap import L2CAPService
|
||||||
from .security import SecurityService, SecurityStorageService
|
from .security import SecurityService, SecurityStorageService
|
||||||
from pandora.host_grpc_aio import add_HostServicer_to_server
|
from pandora.host_grpc_aio import add_HostServicer_to_server
|
||||||
|
from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
|
||||||
from pandora.security_grpc_aio import (
|
from pandora.security_grpc_aio import (
|
||||||
add_SecurityServicer_to_server,
|
add_SecurityServicer_to_server,
|
||||||
add_SecurityStorageServicer_to_server,
|
add_SecurityStorageServicer_to_server,
|
||||||
@@ -77,6 +79,7 @@ async def serve(
|
|||||||
add_SecurityStorageServicer_to_server(
|
add_SecurityStorageServicer_to_server(
|
||||||
SecurityStorageService(bumble.device, config), server
|
SecurityStorageService(bumble.device, config), server
|
||||||
)
|
)
|
||||||
|
add_L2CAPServicer_to_server(L2CAPService(bumble.device, config), server)
|
||||||
|
|
||||||
# call hooks if any.
|
# call hooks if any.
|
||||||
for hook in _SERVICERS_HOOKS:
|
for hook in _SERVICERS_HOOKS:
|
||||||
|
|||||||
310
bumble/pandora/l2cap.py
Normal file
310
bumble/pandora/l2cap.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# 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.
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import grpc
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from asyncio import Queue as AsyncQueue, Future
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
from .config import Config
|
||||||
|
from bumble.core import OutOfResourcesError, InvalidArgumentError
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.l2cap import (
|
||||||
|
ClassicChannel,
|
||||||
|
ClassicChannelServer,
|
||||||
|
ClassicChannelSpec,
|
||||||
|
LeCreditBasedChannel,
|
||||||
|
LeCreditBasedChannelServer,
|
||||||
|
LeCreditBasedChannelSpec,
|
||||||
|
)
|
||||||
|
from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
|
||||||
|
from pandora.l2cap_grpc_aio import L2CAPServicer # pytype: disable=pyi-error
|
||||||
|
from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
|
||||||
|
COMMAND_NOT_UNDERSTOOD,
|
||||||
|
INVALID_CID_IN_REQUEST,
|
||||||
|
Channel as PandoraChannel,
|
||||||
|
ConnectRequest,
|
||||||
|
ConnectResponse,
|
||||||
|
CreditBasedChannelRequest,
|
||||||
|
DisconnectRequest,
|
||||||
|
DisconnectResponse,
|
||||||
|
ReceiveRequest,
|
||||||
|
ReceiveResponse,
|
||||||
|
SendRequest,
|
||||||
|
SendResponse,
|
||||||
|
WaitConnectionRequest,
|
||||||
|
WaitConnectionResponse,
|
||||||
|
WaitDisconnectionRequest,
|
||||||
|
WaitDisconnectionResponse,
|
||||||
|
)
|
||||||
|
from typing import AsyncGenerator, Dict, Optional, Union
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelContext:
|
||||||
|
close_future: Future
|
||||||
|
sdu_queue: AsyncQueue
|
||||||
|
|
||||||
|
|
||||||
|
class L2CAPService(L2CAPServicer):
|
||||||
|
def __init__(self, device: Device, config: Config) -> None:
|
||||||
|
self.log = utils.BumbleServerLoggerAdapter(
|
||||||
|
logging.getLogger(), {'service_name': 'L2CAP', 'device': device}
|
||||||
|
)
|
||||||
|
self.device = device
|
||||||
|
self.config = config
|
||||||
|
self.channels: Dict[bytes, ChannelContext] = {}
|
||||||
|
|
||||||
|
def register_event(self, l2cap_channel: L2capChannel) -> ChannelContext:
|
||||||
|
close_future = asyncio.get_running_loop().create_future()
|
||||||
|
sdu_queue: AsyncQueue = AsyncQueue()
|
||||||
|
|
||||||
|
def on_channel_sdu(sdu):
|
||||||
|
sdu_queue.put_nowait(sdu)
|
||||||
|
|
||||||
|
def on_close():
|
||||||
|
close_future.set_result(None)
|
||||||
|
|
||||||
|
l2cap_channel.sink = on_channel_sdu
|
||||||
|
l2cap_channel.on('close', on_close)
|
||||||
|
|
||||||
|
return ChannelContext(close_future, sdu_queue)
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def WaitConnection(
|
||||||
|
self, request: WaitConnectionRequest, context: grpc.ServicerContext
|
||||||
|
) -> WaitConnectionResponse:
|
||||||
|
self.log.debug('WaitConnection')
|
||||||
|
if not request.connection:
|
||||||
|
raise ValueError('A valid connection field must be set')
|
||||||
|
|
||||||
|
# find connection on device based on connection cookie value
|
||||||
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
|
|
||||||
|
if not connection:
|
||||||
|
raise ValueError('The connection specified is invalid.')
|
||||||
|
|
||||||
|
oneof = request.WhichOneof('type')
|
||||||
|
self.log.debug(f'WaitConnection channel request type: {oneof}.')
|
||||||
|
channel_type = getattr(request, oneof)
|
||||||
|
spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
|
||||||
|
l2cap_server: Optional[
|
||||||
|
Union[ClassicChannelServer, LeCreditBasedChannelServer]
|
||||||
|
] = None
|
||||||
|
if isinstance(channel_type, CreditBasedChannelRequest):
|
||||||
|
spec = LeCreditBasedChannelSpec(
|
||||||
|
psm=channel_type.spsm,
|
||||||
|
max_credits=channel_type.initial_credit,
|
||||||
|
mtu=channel_type.mtu,
|
||||||
|
mps=channel_type.mps,
|
||||||
|
)
|
||||||
|
if channel_type.spsm in self.device.l2cap_channel_manager.le_coc_servers:
|
||||||
|
l2cap_server = self.device.l2cap_channel_manager.le_coc_servers[
|
||||||
|
channel_type.spsm
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
spec = ClassicChannelSpec(
|
||||||
|
psm=channel_type.psm,
|
||||||
|
mtu=channel_type.mtu,
|
||||||
|
)
|
||||||
|
if channel_type.psm in self.device.l2cap_channel_manager.servers:
|
||||||
|
l2cap_server = self.device.l2cap_channel_manager.servers[
|
||||||
|
channel_type.psm
|
||||||
|
]
|
||||||
|
|
||||||
|
self.log.info(f'Listening for L2CAP connection on PSM {spec.psm}')
|
||||||
|
channel_future: Future[PandoraChannel] = (
|
||||||
|
asyncio.get_running_loop().create_future()
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_l2cap_channel(l2cap_channel: L2capChannel):
|
||||||
|
try:
|
||||||
|
channel_context = self.register_event(l2cap_channel)
|
||||||
|
pandora_channel: PandoraChannel = self.craft_pandora_channel(
|
||||||
|
connection_handle, l2cap_channel
|
||||||
|
)
|
||||||
|
self.channels[pandora_channel.cookie.value] = channel_context
|
||||||
|
channel_future.set_result(pandora_channel)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f'Failed to set channel future: {e}')
|
||||||
|
|
||||||
|
if l2cap_server is None:
|
||||||
|
l2cap_server = self.device.create_l2cap_server(
|
||||||
|
spec=spec, handler=on_l2cap_channel
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
l2cap_server.on('connection', on_l2cap_channel)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.log.debug('Waiting for a channel connection.')
|
||||||
|
pandora_channel: PandoraChannel = await channel_future
|
||||||
|
|
||||||
|
return WaitConnectionResponse(channel=pandora_channel)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f'Exception: {e}')
|
||||||
|
|
||||||
|
return WaitConnectionResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def WaitDisconnection(
|
||||||
|
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
|
||||||
|
) -> WaitDisconnectionResponse:
|
||||||
|
try:
|
||||||
|
self.log.debug('WaitDisconnection')
|
||||||
|
|
||||||
|
await self.lookup_context(request.channel).close_future
|
||||||
|
self.log.debug("return WaitDisconnectionResponse")
|
||||||
|
return WaitDisconnectionResponse(success=empty_pb2.Empty())
|
||||||
|
except KeyError as e:
|
||||||
|
self.log.warning(f'WaitDisconnection: Unable to find the channel: {e}')
|
||||||
|
return WaitDisconnectionResponse(error=INVALID_CID_IN_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(f'WaitDisonnection failed: {e}')
|
||||||
|
return WaitDisconnectionResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def Receive(
|
||||||
|
self, request: ReceiveRequest, context: grpc.ServicerContext
|
||||||
|
) -> AsyncGenerator[ReceiveResponse, None]:
|
||||||
|
self.log.debug('Receive')
|
||||||
|
oneof = request.WhichOneof('source')
|
||||||
|
self.log.debug(f'Source: {oneof}.')
|
||||||
|
pandora_channel = getattr(request, oneof)
|
||||||
|
|
||||||
|
sdu_queue = self.lookup_context(pandora_channel).sdu_queue
|
||||||
|
|
||||||
|
while sdu := await sdu_queue.get():
|
||||||
|
self.log.debug(f'Receive: Received {len(sdu)} bytes -> {sdu.decode()}')
|
||||||
|
response = ReceiveResponse(data=sdu)
|
||||||
|
yield response
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def Connect(
|
||||||
|
self, request: ConnectRequest, context: grpc.ServicerContext
|
||||||
|
) -> ConnectResponse:
|
||||||
|
self.log.debug('Connect')
|
||||||
|
|
||||||
|
if not request.connection:
|
||||||
|
raise ValueError('A valid connection field must be set')
|
||||||
|
|
||||||
|
# find connection on device based on connection cookie value
|
||||||
|
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
|
||||||
|
connection = self.device.lookup_connection(connection_handle)
|
||||||
|
|
||||||
|
if not connection:
|
||||||
|
raise ValueError('The connection specified is invalid.')
|
||||||
|
|
||||||
|
oneof = request.WhichOneof('type')
|
||||||
|
self.log.debug(f'Channel request type: {oneof}.')
|
||||||
|
channel_type = getattr(request, oneof)
|
||||||
|
spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
|
||||||
|
if isinstance(channel_type, CreditBasedChannelRequest):
|
||||||
|
spec = LeCreditBasedChannelSpec(
|
||||||
|
psm=channel_type.spsm,
|
||||||
|
max_credits=channel_type.initial_credit,
|
||||||
|
mtu=channel_type.mtu,
|
||||||
|
mps=channel_type.mps,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
spec = ClassicChannelSpec(
|
||||||
|
psm=channel_type.psm,
|
||||||
|
mtu=channel_type.mtu,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.log.info(f'Opening L2CAP channel on PSM = {spec.psm}')
|
||||||
|
l2cap_channel = await connection.create_l2cap_channel(spec=spec)
|
||||||
|
channel_context = self.register_event(l2cap_channel)
|
||||||
|
pandora_channel = self.craft_pandora_channel(
|
||||||
|
connection_handle, l2cap_channel
|
||||||
|
)
|
||||||
|
self.channels[pandora_channel.cookie.value] = channel_context
|
||||||
|
|
||||||
|
return ConnectResponse(channel=pandora_channel)
|
||||||
|
|
||||||
|
except OutOfResourcesError as e:
|
||||||
|
self.log.error(e)
|
||||||
|
return ConnectResponse(error=INVALID_CID_IN_REQUEST)
|
||||||
|
except InvalidArgumentError as e:
|
||||||
|
self.log.error(e)
|
||||||
|
return ConnectResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def Disconnect(
|
||||||
|
self, request: DisconnectRequest, context: grpc.ServicerContext
|
||||||
|
) -> DisconnectResponse:
|
||||||
|
try:
|
||||||
|
self.log.debug('Disconnect')
|
||||||
|
l2cap_channel = self.lookup_channel(request.channel)
|
||||||
|
if not l2cap_channel:
|
||||||
|
self.log.warning('Disconnect: Unable to find the channel')
|
||||||
|
return DisconnectResponse(error=INVALID_CID_IN_REQUEST)
|
||||||
|
|
||||||
|
await l2cap_channel.disconnect()
|
||||||
|
return DisconnectResponse(success=empty_pb2.Empty())
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(f'Disonnect failed: {e}')
|
||||||
|
return DisconnectResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
|
||||||
|
@utils.rpc
|
||||||
|
async def Send(
|
||||||
|
self, request: SendRequest, context: grpc.ServicerContext
|
||||||
|
) -> SendResponse:
|
||||||
|
self.log.debug('Send')
|
||||||
|
try:
|
||||||
|
oneof = request.WhichOneof('sink')
|
||||||
|
self.log.debug(f'Sink: {oneof}.')
|
||||||
|
pandora_channel = getattr(request, oneof)
|
||||||
|
|
||||||
|
l2cap_channel = self.lookup_channel(pandora_channel)
|
||||||
|
if not l2cap_channel:
|
||||||
|
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
if isinstance(l2cap_channel, ClassicChannel):
|
||||||
|
l2cap_channel.send_pdu(request.data)
|
||||||
|
else:
|
||||||
|
l2cap_channel.write(request.data)
|
||||||
|
return SendResponse(success=empty_pb2.Empty())
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(f'Disonnect failed: {e}')
|
||||||
|
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
|
|
||||||
|
def craft_pandora_channel(
|
||||||
|
self,
|
||||||
|
connection_handle: int,
|
||||||
|
l2cap_channel: L2capChannel,
|
||||||
|
) -> PandoraChannel:
|
||||||
|
parameters = {
|
||||||
|
"connection_handle": connection_handle,
|
||||||
|
"source_cid": l2cap_channel.source_cid,
|
||||||
|
}
|
||||||
|
cookie = any_pb2.Any()
|
||||||
|
cookie.value = json.dumps(parameters).encode()
|
||||||
|
return PandoraChannel(cookie=cookie)
|
||||||
|
|
||||||
|
def lookup_channel(self, pandora_channel: PandoraChannel) -> L2capChannel:
|
||||||
|
(connection_handle, source_cid) = json.loads(
|
||||||
|
pandora_channel.cookie.value
|
||||||
|
).values()
|
||||||
|
|
||||||
|
return self.device.l2cap_channel_manager.channels[connection_handle][source_cid]
|
||||||
|
|
||||||
|
def lookup_context(self, pandora_channel: PandoraChannel) -> ChannelContext:
|
||||||
|
return self.channels[pandora_channel.cookie.value]
|
||||||
520
bumble/profiles/aics.py
Normal file
520
bumble/profiles/aics.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""LE Audio - Audio Input Control Service"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble.device import Connection
|
||||||
|
from bumble.att import ATT_Error
|
||||||
|
from bumble.gatt import (
|
||||||
|
Characteristic,
|
||||||
|
DelegatedCharacteristicAdapter,
|
||||||
|
TemplateService,
|
||||||
|
CharacteristicValue,
|
||||||
|
PackedCharacteristicAdapter,
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
||||||
|
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||||
|
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
|
)
|
||||||
|
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
CHANGE_COUNTER_MAX_VALUE = 0xFF
|
||||||
|
GAIN_SETTINGS_MIN_VALUE = 0
|
||||||
|
GAIN_SETTINGS_MAX_VALUE = 255
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(OpenIntEnum):
|
||||||
|
'''
|
||||||
|
Cf. 1.6 Application error codes
|
||||||
|
'''
|
||||||
|
|
||||||
|
INVALID_CHANGE_COUNTER = 0x80
|
||||||
|
OPCODE_NOT_SUPPORTED = 0x81
|
||||||
|
MUTE_DISABLED = 0x82
|
||||||
|
VALUE_OUT_OF_RANGE = 0x83
|
||||||
|
GAIN_MODE_CHANGE_NOT_ALLOWED = 0x84
|
||||||
|
|
||||||
|
|
||||||
|
class Mute(OpenIntEnum):
|
||||||
|
'''
|
||||||
|
Cf. 2.2.1.2 Mute Field
|
||||||
|
'''
|
||||||
|
|
||||||
|
NOT_MUTED = 0x00
|
||||||
|
MUTED = 0x01
|
||||||
|
DISABLED = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
class GainMode(OpenIntEnum):
|
||||||
|
'''
|
||||||
|
Cf. 2.2.1.3 Gain Mode
|
||||||
|
'''
|
||||||
|
|
||||||
|
MANUAL_ONLY = 0x00
|
||||||
|
AUTOMATIC_ONLY = 0x01
|
||||||
|
MANUAL = 0x02
|
||||||
|
AUTOMATIC = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
class AudioInputStatus(OpenIntEnum):
|
||||||
|
'''
|
||||||
|
Cf. 3.4 Audio Input Status
|
||||||
|
'''
|
||||||
|
|
||||||
|
INATIVE = 0x00
|
||||||
|
ACTIVE = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
class AudioInputControlPointOpCode(OpenIntEnum):
|
||||||
|
'''
|
||||||
|
Cf. 3.5.1 Audio Input Control Point procedure requirements
|
||||||
|
'''
|
||||||
|
|
||||||
|
SET_GAIN_SETTING = 0x00
|
||||||
|
UNMUTE = 0x02
|
||||||
|
MUTE = 0x03
|
||||||
|
SET_MANUAL_GAIN_MODE = 0x04
|
||||||
|
SET_AUTOMATIC_GAIN_MODE = 0x05
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class AudioInputState:
|
||||||
|
'''
|
||||||
|
Cf. 2.2.1 Audio Input State
|
||||||
|
'''
|
||||||
|
|
||||||
|
gain_settings: int = 0
|
||||||
|
mute: Mute = Mute.NOT_MUTED
|
||||||
|
gain_mode: GainMode = GainMode.MANUAL
|
||||||
|
change_counter: int = 0
|
||||||
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
[self.gain_settings, self.mute, self.gain_mode, self.change_counter]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
gain_settings, mute, gain_mode, change_counter = struct.unpack("BBBB", data)
|
||||||
|
return cls(gain_settings, mute, gain_mode, change_counter)
|
||||||
|
|
||||||
|
def update_gain_settings_unit(self, gain_settings_unit: int) -> None:
|
||||||
|
self.gain_settings_unit = gain_settings_unit
|
||||||
|
|
||||||
|
def increment_gain_settings(self, gain_settings_unit: int) -> None:
|
||||||
|
self.gain_settings += gain_settings_unit
|
||||||
|
self.increment_change_counter()
|
||||||
|
|
||||||
|
def decrement_gain_settings(self) -> None:
|
||||||
|
self.gain_settings -= self.gain_settings_unit
|
||||||
|
self.increment_change_counter()
|
||||||
|
|
||||||
|
def increment_change_counter(self):
|
||||||
|
self.change_counter = (self.change_counter + 1) % (CHANGE_COUNTER_MAX_VALUE + 1)
|
||||||
|
|
||||||
|
async def notify_subscribers_via_connection(self, connection: Connection) -> None:
|
||||||
|
assert self.attribute_value is not None
|
||||||
|
await connection.device.notify_subscribers(
|
||||||
|
attribute=self.attribute_value, value=bytes(self)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return bytes(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GainSettingsProperties:
|
||||||
|
'''
|
||||||
|
Cf. 3.2 Gain Settings Properties
|
||||||
|
'''
|
||||||
|
|
||||||
|
gain_settings_unit: int = 1
|
||||||
|
gain_settings_minimum: int = GAIN_SETTINGS_MIN_VALUE
|
||||||
|
gain_settings_maximum: int = GAIN_SETTINGS_MAX_VALUE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
(gain_settings_unit, gain_settings_minimum, gain_settings_maximum) = (
|
||||||
|
struct.unpack('BBB', data)
|
||||||
|
)
|
||||||
|
GainSettingsProperties(
|
||||||
|
gain_settings_unit, gain_settings_minimum, gain_settings_maximum
|
||||||
|
)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
[
|
||||||
|
self.gain_settings_unit,
|
||||||
|
self.gain_settings_minimum,
|
||||||
|
self.gain_settings_maximum,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return bytes(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioInputControlPoint:
|
||||||
|
'''
|
||||||
|
Cf. 3.5.2 Audio Input Control Point
|
||||||
|
'''
|
||||||
|
|
||||||
|
audio_input_state: AudioInputState
|
||||||
|
gain_settings_properties: GainSettingsProperties
|
||||||
|
|
||||||
|
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
opcode = AudioInputControlPointOpCode(value[0])
|
||||||
|
|
||||||
|
if opcode == AudioInputControlPointOpCode.SET_GAIN_SETTING:
|
||||||
|
gain_settings_operand = value[2]
|
||||||
|
await self._set_gain_settings(connection, gain_settings_operand)
|
||||||
|
elif opcode == AudioInputControlPointOpCode.UNMUTE:
|
||||||
|
await self._unmute(connection)
|
||||||
|
elif opcode == AudioInputControlPointOpCode.MUTE:
|
||||||
|
change_counter_operand = value[1]
|
||||||
|
await self._mute(connection, change_counter_operand)
|
||||||
|
elif opcode == AudioInputControlPointOpCode.SET_MANUAL_GAIN_MODE:
|
||||||
|
await self._set_manual_gain_mode(connection)
|
||||||
|
elif opcode == AudioInputControlPointOpCode.SET_AUTOMATIC_GAIN_MODE:
|
||||||
|
await self._set_automatic_gain_mode(connection)
|
||||||
|
else:
|
||||||
|
logger.error(f"OpCode value is incorrect: {opcode}")
|
||||||
|
raise ATT_Error(ErrorCode.OPCODE_NOT_SUPPORTED)
|
||||||
|
|
||||||
|
async def _set_gain_settings(
|
||||||
|
self, connection: Connection, gain_settings_operand: int
|
||||||
|
) -> None:
|
||||||
|
'''Cf. 3.5.2.1 Set Gain Settings Procedure'''
|
||||||
|
|
||||||
|
gain_mode = self.audio_input_state.gain_mode
|
||||||
|
|
||||||
|
logger.error(f"set_gain_setting: gain_mode: {gain_mode}")
|
||||||
|
if not (gain_mode == GainMode.MANUAL or gain_mode == GainMode.MANUAL_ONLY):
|
||||||
|
logger.warning(
|
||||||
|
"GainMode should be either MANUAL or MANUAL_ONLY Cf Spec Audio Input Control Service 3.5.2.1"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
gain_settings_operand < self.gain_settings_properties.gain_settings_minimum
|
||||||
|
or gain_settings_operand
|
||||||
|
> self.gain_settings_properties.gain_settings_maximum
|
||||||
|
):
|
||||||
|
logger.error("gain_seetings value out of range")
|
||||||
|
raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
|
||||||
|
|
||||||
|
if self.audio_input_state.gain_settings != gain_settings_operand:
|
||||||
|
self.audio_input_state.gain_settings = gain_settings_operand
|
||||||
|
await self.audio_input_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
async def _unmute(self, connection: Connection):
|
||||||
|
'''Cf. 3.5.2.2 Unmute procedure'''
|
||||||
|
|
||||||
|
logger.error(f'unmute: {self.audio_input_state.mute}')
|
||||||
|
mute = self.audio_input_state.mute
|
||||||
|
if mute == Mute.DISABLED:
|
||||||
|
logger.error("unmute: Cannot change Mute value, Mute state is DISABLED")
|
||||||
|
raise ATT_Error(ErrorCode.MUTE_DISABLED)
|
||||||
|
|
||||||
|
if mute == Mute.NOT_MUTED:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.audio_input_state.mute = Mute.NOT_MUTED
|
||||||
|
self.audio_input_state.increment_change_counter()
|
||||||
|
await self.audio_input_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
async def _mute(self, connection: Connection, change_counter_operand: int) -> None:
|
||||||
|
'''Cf. 3.5.5.2 Mute procedure'''
|
||||||
|
|
||||||
|
change_counter = self.audio_input_state.change_counter
|
||||||
|
mute = self.audio_input_state.mute
|
||||||
|
if mute == Mute.DISABLED:
|
||||||
|
logger.error("mute: Cannot change Mute value, Mute state is DISABLED")
|
||||||
|
raise ATT_Error(ErrorCode.MUTE_DISABLED)
|
||||||
|
|
||||||
|
if change_counter != change_counter_operand:
|
||||||
|
raise ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
|
||||||
|
|
||||||
|
if mute == Mute.MUTED:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.audio_input_state.mute = Mute.MUTED
|
||||||
|
self.audio_input_state.increment_change_counter()
|
||||||
|
await self.audio_input_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
async def _set_manual_gain_mode(self, connection: Connection) -> None:
|
||||||
|
'''Cf. 3.5.2.4 Set Manual Gain Mode procedure'''
|
||||||
|
|
||||||
|
gain_mode = self.audio_input_state.gain_mode
|
||||||
|
if gain_mode in (GainMode.AUTOMATIC_ONLY, GainMode.MANUAL_ONLY):
|
||||||
|
logger.error(f"Cannot change gain_mode, bad state: {gain_mode}")
|
||||||
|
raise ATT_Error(ErrorCode.GAIN_MODE_CHANGE_NOT_ALLOWED)
|
||||||
|
|
||||||
|
if gain_mode == GainMode.MANUAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.audio_input_state.gain_mode = GainMode.MANUAL
|
||||||
|
self.audio_input_state.increment_change_counter()
|
||||||
|
await self.audio_input_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
async def _set_automatic_gain_mode(self, connection: Connection) -> None:
|
||||||
|
'''Cf. 3.5.2.5 Set Automatic Gain Mode'''
|
||||||
|
|
||||||
|
gain_mode = self.audio_input_state.gain_mode
|
||||||
|
if gain_mode in (GainMode.AUTOMATIC_ONLY, GainMode.MANUAL_ONLY):
|
||||||
|
logger.error(f"Cannot change gain_mode, bad state: {gain_mode}")
|
||||||
|
raise ATT_Error(ErrorCode.GAIN_MODE_CHANGE_NOT_ALLOWED)
|
||||||
|
|
||||||
|
if gain_mode == GainMode.AUTOMATIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.audio_input_state.gain_mode = GainMode.AUTOMATIC
|
||||||
|
self.audio_input_state.increment_change_counter()
|
||||||
|
await self.audio_input_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioInputDescription:
|
||||||
|
'''
|
||||||
|
Cf. 3.6 Audio Input Description
|
||||||
|
'''
|
||||||
|
|
||||||
|
audio_input_description: str = "Bluetooth"
|
||||||
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
return cls(audio_input_description=data.decode('utf-8'))
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.audio_input_description.encode('utf-8')
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return self.audio_input_description.encode('utf-8')
|
||||||
|
|
||||||
|
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
assert connection
|
||||||
|
assert self.attribute_value
|
||||||
|
|
||||||
|
self.audio_input_description = value.decode('utf-8')
|
||||||
|
await connection.device.notify_subscribers(
|
||||||
|
attribute=self.attribute_value, value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AICSService(TemplateService):
|
||||||
|
UUID = GATT_AUDIO_INPUT_CONTROL_SERVICE
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
audio_input_state: Optional[AudioInputState] = None,
|
||||||
|
gain_settings_properties: Optional[GainSettingsProperties] = None,
|
||||||
|
audio_input_type: str = "local",
|
||||||
|
audio_input_status: Optional[AudioInputStatus] = None,
|
||||||
|
audio_input_description: Optional[AudioInputDescription] = None,
|
||||||
|
):
|
||||||
|
self.audio_input_state = (
|
||||||
|
AudioInputState() if audio_input_state is None else audio_input_state
|
||||||
|
)
|
||||||
|
self.gain_settings_properties = (
|
||||||
|
GainSettingsProperties()
|
||||||
|
if gain_settings_properties is None
|
||||||
|
else gain_settings_properties
|
||||||
|
)
|
||||||
|
self.audio_input_status = (
|
||||||
|
AudioInputStatus.ACTIVE
|
||||||
|
if audio_input_status is None
|
||||||
|
else audio_input_status
|
||||||
|
)
|
||||||
|
self.audio_input_description = (
|
||||||
|
AudioInputDescription()
|
||||||
|
if audio_input_description is None
|
||||||
|
else audio_input_description
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_control_point: AudioInputControlPoint = AudioInputControlPoint(
|
||||||
|
self.audio_input_state, self.gain_settings_properties
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_state_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ
|
||||||
|
| Characteristic.Properties.NOTIFY,
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(read=self.audio_input_state.on_read),
|
||||||
|
),
|
||||||
|
encode=lambda value: bytes(value),
|
||||||
|
)
|
||||||
|
self.audio_input_state.attribute_value = (
|
||||||
|
self.audio_input_state_characteristic.value
|
||||||
|
)
|
||||||
|
|
||||||
|
self.gain_settings_properties_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(read=self.gain_settings_properties.on_read),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_type_characteristic = Characteristic(
|
||||||
|
uuid=GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=audio_input_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_status_characteristic = Characteristic(
|
||||||
|
uuid=GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=bytes([self.audio_input_status]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_control_point_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.WRITE,
|
||||||
|
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(
|
||||||
|
write=self.audio_input_control_point.on_write
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_input_description_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ
|
||||||
|
| Characteristic.Properties.NOTIFY
|
||||||
|
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
|
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(
|
||||||
|
write=self.audio_input_description.on_write,
|
||||||
|
read=self.audio_input_description.on_read,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.audio_input_description.attribute_value = (
|
||||||
|
self.audio_input_control_point_characteristic.value
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
characteristics=[
|
||||||
|
self.audio_input_state_characteristic, # type: ignore
|
||||||
|
self.gain_settings_properties_characteristic, # type: ignore
|
||||||
|
self.audio_input_type_characteristic, # type: ignore
|
||||||
|
self.audio_input_status_characteristic, # type: ignore
|
||||||
|
self.audio_input_control_point_characteristic, # type: ignore
|
||||||
|
self.audio_input_description_characteristic, # type: ignore
|
||||||
|
],
|
||||||
|
primary=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AICSServiceProxy(ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = AICSService
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
|
||||||
|
self.audio_input_state = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0], decode=AudioInputState.from_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError(
|
||||||
|
"Gain Settings Attribute Characteristic not found"
|
||||||
|
)
|
||||||
|
self.gain_settings_properties = PackedCharacteristicAdapter(
|
||||||
|
characteristics[0],
|
||||||
|
'BBB',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError(
|
||||||
|
"Audio Input Status Characteristic not found"
|
||||||
|
)
|
||||||
|
self.audio_input_status = PackedCharacteristicAdapter(
|
||||||
|
characteristics[0],
|
||||||
|
'B',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError(
|
||||||
|
"Audio Input Control Point Characteristic not found"
|
||||||
|
)
|
||||||
|
self.audio_input_control_point = characteristics[0]
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError(
|
||||||
|
"Audio Input Description Characteristic not found"
|
||||||
|
)
|
||||||
|
self.audio_input_description = characteristics[0]
|
||||||
295
bumble/profiles/asha.py
Normal file
295
bumble/profiles/asha.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Copyright 2021-2022 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Callable, Union, Any
|
||||||
|
|
||||||
|
from bumble import l2cap
|
||||||
|
from bumble import utils
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import gatt_client
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class DeviceCapabilities(enum.IntFlag):
|
||||||
|
IS_RIGHT = 0x01
|
||||||
|
IS_DUAL = 0x02
|
||||||
|
CSIS_SUPPORTED = 0x04
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureMap(enum.IntFlag):
|
||||||
|
LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
class AudioType(utils.OpenIntEnum):
|
||||||
|
UNKNOWN = 0x00
|
||||||
|
RINGTONE = 0x01
|
||||||
|
PHONE_CALL = 0x02
|
||||||
|
MEDIA = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
class OpCode(utils.OpenIntEnum):
|
||||||
|
START = 1
|
||||||
|
STOP = 2
|
||||||
|
STATUS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Codec(utils.OpenIntEnum):
|
||||||
|
G_722_16KHZ = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SupportedCodecs(enum.IntFlag):
|
||||||
|
G_722_16KHZ = 1 << Codec.G_722_16KHZ
|
||||||
|
|
||||||
|
|
||||||
|
class PeripheralStatus(utils.OpenIntEnum):
|
||||||
|
"""Status update on the other peripheral."""
|
||||||
|
|
||||||
|
OTHER_PERIPHERAL_DISCONNECTED = 1
|
||||||
|
OTHER_PERIPHERAL_CONNECTED = 2
|
||||||
|
CONNECTION_PARAMETER_UPDATED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class AudioStatus(utils.OpenIntEnum):
|
||||||
|
"""Status report field for the audio control point."""
|
||||||
|
|
||||||
|
OK = 0
|
||||||
|
UNKNOWN_COMMAND = -1
|
||||||
|
ILLEGAL_PARAMETERS = -2
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AshaService(gatt.TemplateService):
|
||||||
|
UUID = gatt.GATT_ASHA_SERVICE
|
||||||
|
|
||||||
|
audio_sink: Optional[Callable[[bytes], Any]]
|
||||||
|
active_codec: Optional[Codec] = None
|
||||||
|
audio_type: Optional[AudioType] = None
|
||||||
|
volume: Optional[int] = None
|
||||||
|
other_state: Optional[int] = None
|
||||||
|
connection: Optional[Connection] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
capability: int,
|
||||||
|
hisyncid: Union[List[int], bytes],
|
||||||
|
device: Device,
|
||||||
|
psm: int = 0,
|
||||||
|
audio_sink: Optional[Callable[[bytes], Any]] = None,
|
||||||
|
feature_map: int = FeatureMap.LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED,
|
||||||
|
protocol_version: int = 0x01,
|
||||||
|
render_delay_milliseconds: int = 0,
|
||||||
|
supported_codecs: int = SupportedCodecs.G_722_16KHZ,
|
||||||
|
) -> None:
|
||||||
|
if len(hisyncid) != 8:
|
||||||
|
_logger.warning('HiSyncId should have a length of 8, got %d', len(hisyncid))
|
||||||
|
|
||||||
|
self.hisyncid = bytes(hisyncid)
|
||||||
|
self.capability = capability
|
||||||
|
self.device = device
|
||||||
|
self.audio_out_data = b''
|
||||||
|
self.psm = psm # a non-zero psm is mainly for testing purpose
|
||||||
|
self.audio_sink = audio_sink
|
||||||
|
self.protocol_version = protocol_version
|
||||||
|
|
||||||
|
self.read_only_properties_characteristic = gatt.Characteristic(
|
||||||
|
gatt.GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
||||||
|
gatt.Characteristic.Properties.READ,
|
||||||
|
gatt.Characteristic.READABLE,
|
||||||
|
struct.pack(
|
||||||
|
"<BB8sBH2sH",
|
||||||
|
protocol_version,
|
||||||
|
capability,
|
||||||
|
self.hisyncid,
|
||||||
|
feature_map,
|
||||||
|
render_delay_milliseconds,
|
||||||
|
b'\x00\x00',
|
||||||
|
supported_codecs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_control_point_characteristic = gatt.Characteristic(
|
||||||
|
gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
gatt.Characteristic.Properties.WRITE
|
||||||
|
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||||
|
gatt.Characteristic.WRITEABLE,
|
||||||
|
gatt.CharacteristicValue(write=self._on_audio_control_point_write),
|
||||||
|
)
|
||||||
|
self.audio_status_characteristic = gatt.Characteristic(
|
||||||
|
gatt.GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
||||||
|
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.NOTIFY,
|
||||||
|
gatt.Characteristic.READABLE,
|
||||||
|
bytes([AudioStatus.OK]),
|
||||||
|
)
|
||||||
|
self.volume_characteristic = gatt.Characteristic(
|
||||||
|
gatt.GATT_ASHA_VOLUME_CHARACTERISTIC,
|
||||||
|
gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||||
|
gatt.Characteristic.WRITEABLE,
|
||||||
|
gatt.CharacteristicValue(write=self._on_volume_write),
|
||||||
|
)
|
||||||
|
|
||||||
|
# let the server find a free PSM
|
||||||
|
self.psm = device.create_l2cap_server(
|
||||||
|
spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8),
|
||||||
|
handler=self._on_connection,
|
||||||
|
).psm
|
||||||
|
self.le_psm_out_characteristic = gatt.Characteristic(
|
||||||
|
gatt.GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||||
|
gatt.Characteristic.Properties.READ,
|
||||||
|
gatt.Characteristic.READABLE,
|
||||||
|
struct.pack('<H', self.psm),
|
||||||
|
)
|
||||||
|
|
||||||
|
characteristics = [
|
||||||
|
self.read_only_properties_characteristic,
|
||||||
|
self.audio_control_point_characteristic,
|
||||||
|
self.audio_status_characteristic,
|
||||||
|
self.volume_characteristic,
|
||||||
|
self.le_psm_out_characteristic,
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(characteristics)
|
||||||
|
|
||||||
|
def get_advertising_data(self) -> bytes:
|
||||||
|
# Advertisement only uses 4 least significant bytes of the HiSyncId.
|
||||||
|
return bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||||
|
bytes(gatt.GATT_ASHA_SERVICE)
|
||||||
|
+ bytes([self.protocol_version, self.capability])
|
||||||
|
+ self.hisyncid[:4],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handler for audio control commands
|
||||||
|
async def _on_audio_control_point_write(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
) -> None:
|
||||||
|
_logger.debug(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
|
||||||
|
opcode = value[0]
|
||||||
|
if opcode == OpCode.START:
|
||||||
|
# Start
|
||||||
|
self.active_codec = Codec(value[1])
|
||||||
|
self.audio_type = AudioType(value[2])
|
||||||
|
self.volume = value[3]
|
||||||
|
self.other_state = value[4]
|
||||||
|
_logger.debug(
|
||||||
|
f'### START: codec={self.active_codec.name}, '
|
||||||
|
f'audio_type={self.audio_type.name}, '
|
||||||
|
f'volume={self.volume}, '
|
||||||
|
f'other_state={self.other_state}'
|
||||||
|
)
|
||||||
|
self.emit('started')
|
||||||
|
elif opcode == OpCode.STOP:
|
||||||
|
_logger.debug('### STOP')
|
||||||
|
self.active_codec = None
|
||||||
|
self.audio_type = None
|
||||||
|
self.volume = None
|
||||||
|
self.other_state = None
|
||||||
|
self.emit('stopped')
|
||||||
|
elif opcode == OpCode.STATUS:
|
||||||
|
_logger.debug('### STATUS: %s', PeripheralStatus(value[1]).name)
|
||||||
|
|
||||||
|
if self.connection is None and connection:
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def on_disconnection(_reason) -> None:
|
||||||
|
self.connection = None
|
||||||
|
self.active_codec = None
|
||||||
|
self.audio_type = None
|
||||||
|
self.volume = None
|
||||||
|
self.other_state = None
|
||||||
|
self.emit('disconnected')
|
||||||
|
|
||||||
|
connection.once('disconnection', on_disconnection)
|
||||||
|
|
||||||
|
# OPCODE_STATUS does not need audio status point update
|
||||||
|
if opcode != OpCode.STATUS:
|
||||||
|
await self.device.notify_subscribers(
|
||||||
|
self.audio_status_characteristic, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handler for volume control
|
||||||
|
def _on_volume_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
_logger.debug(f'--- VOLUME Write:{value[0]}')
|
||||||
|
self.volume = value[0]
|
||||||
|
self.emit('volume_changed')
|
||||||
|
|
||||||
|
# Register an L2CAP CoC server
|
||||||
|
def _on_connection(self, channel: l2cap.LeCreditBasedChannel) -> None:
|
||||||
|
def on_data(data: bytes) -> None:
|
||||||
|
if self.audio_sink: # pylint: disable=not-callable
|
||||||
|
self.audio_sink(data)
|
||||||
|
|
||||||
|
channel.sink = on_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AshaServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = AshaService
|
||||||
|
read_only_properties_characteristic: gatt_client.CharacteristicProxy
|
||||||
|
audio_control_point_characteristic: gatt_client.CharacteristicProxy
|
||||||
|
audio_status_point_characteristic: gatt_client.CharacteristicProxy
|
||||||
|
volume_characteristic: gatt_client.CharacteristicProxy
|
||||||
|
psm_characteristic: gatt_client.CharacteristicProxy
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
for uuid, attribute_name in (
|
||||||
|
(
|
||||||
|
gatt.GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
||||||
|
'read_only_properties_characteristic',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
'audio_control_point_characteristic',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gatt.GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
||||||
|
'audio_status_point_characteristic',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gatt.GATT_ASHA_VOLUME_CHARACTERISTIC,
|
||||||
|
'volume_characteristic',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gatt.GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||||
|
'psm_characteristic',
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if not (
|
||||||
|
characteristics := self.service_proxy.get_characteristics_by_uuid(uuid)
|
||||||
|
):
|
||||||
|
raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic")
|
||||||
|
setattr(self, attribute_name, characteristics[0])
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Imports
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
import struct
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from bumble import l2cap
|
|
||||||
from ..core import AdvertisingData
|
|
||||||
from ..device import Device, Connection
|
|
||||||
from ..gatt import (
|
|
||||||
GATT_ASHA_SERVICE,
|
|
||||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
|
||||||
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
|
||||||
GATT_ASHA_VOLUME_CHARACTERISTIC,
|
|
||||||
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
)
|
|
||||||
from ..utils import AsyncRunner
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Logging
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
class AshaService(TemplateService):
|
|
||||||
UUID = GATT_ASHA_SERVICE
|
|
||||||
OPCODE_START = 1
|
|
||||||
OPCODE_STOP = 2
|
|
||||||
OPCODE_STATUS = 3
|
|
||||||
PROTOCOL_VERSION = 0x01
|
|
||||||
RESERVED_FOR_FUTURE_USE = [00, 00]
|
|
||||||
FEATURE_MAP = [0x01] # [LE CoC audio output streaming supported]
|
|
||||||
SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz]
|
|
||||||
RENDER_DELAY = [00, 00]
|
|
||||||
|
|
||||||
def __init__(self, capability: int, hisyncid: List[int], device: Device, psm=0):
|
|
||||||
self.hisyncid = hisyncid
|
|
||||||
self.capability = capability # Device Capabilities [Left, Monaural]
|
|
||||||
self.device = device
|
|
||||||
self.audio_out_data = b''
|
|
||||||
self.psm = psm # a non-zero psm is mainly for testing purpose
|
|
||||||
|
|
||||||
# Handler for volume control
|
|
||||||
def on_volume_write(connection, value):
|
|
||||||
logger.info(f'--- VOLUME Write:{value[0]}')
|
|
||||||
self.emit('volume', connection, value[0])
|
|
||||||
|
|
||||||
# Handler for audio control commands
|
|
||||||
def on_audio_control_point_write(connection: Optional[Connection], value):
|
|
||||||
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
|
|
||||||
opcode = value[0]
|
|
||||||
if opcode == AshaService.OPCODE_START:
|
|
||||||
# Start
|
|
||||||
audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]]
|
|
||||||
logger.info(
|
|
||||||
f'### START: codec={value[1]}, '
|
|
||||||
f'audio_type={audio_type}, '
|
|
||||||
f'volume={value[3]}, '
|
|
||||||
f'otherstate={value[4]}'
|
|
||||||
)
|
|
||||||
self.emit(
|
|
||||||
'start',
|
|
||||||
connection,
|
|
||||||
{
|
|
||||||
'codec': value[1],
|
|
||||||
'audiotype': value[2],
|
|
||||||
'volume': value[3],
|
|
||||||
'otherstate': value[4],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
elif opcode == AshaService.OPCODE_STOP:
|
|
||||||
logger.info('### STOP')
|
|
||||||
self.emit('stop', connection)
|
|
||||||
elif opcode == AshaService.OPCODE_STATUS:
|
|
||||||
logger.info(f'### STATUS: connected={value[1]}')
|
|
||||||
|
|
||||||
# OPCODE_STATUS does not need audio status point update
|
|
||||||
if opcode != AshaService.OPCODE_STATUS:
|
|
||||||
AsyncRunner.spawn(
|
|
||||||
device.notify_subscribers(
|
|
||||||
self.audio_status_characteristic, force=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.read_only_properties_characteristic = Characteristic(
|
|
||||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
bytes(
|
|
||||||
[
|
|
||||||
AshaService.PROTOCOL_VERSION, # Version
|
|
||||||
self.capability,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ bytes(self.hisyncid)
|
|
||||||
+ bytes(AshaService.FEATURE_MAP)
|
|
||||||
+ bytes(AshaService.RENDER_DELAY)
|
|
||||||
+ bytes(AshaService.RESERVED_FOR_FUTURE_USE)
|
|
||||||
+ bytes(AshaService.SUPPORTED_CODEC_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.audio_control_point_characteristic = Characteristic(
|
|
||||||
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.WRITE
|
|
||||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
|
||||||
Characteristic.WRITEABLE,
|
|
||||||
CharacteristicValue(write=on_audio_control_point_write),
|
|
||||||
)
|
|
||||||
self.audio_status_characteristic = Characteristic(
|
|
||||||
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
bytes([0]),
|
|
||||||
)
|
|
||||||
self.volume_characteristic = Characteristic(
|
|
||||||
GATT_ASHA_VOLUME_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
|
||||||
Characteristic.WRITEABLE,
|
|
||||||
CharacteristicValue(write=on_volume_write),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register an L2CAP CoC server
|
|
||||||
def on_coc(channel):
|
|
||||||
def on_data(data):
|
|
||||||
logging.debug(f'<<< data received:{data}')
|
|
||||||
|
|
||||||
self.emit('data', channel.connection, data)
|
|
||||||
self.audio_out_data += data
|
|
||||||
|
|
||||||
channel.sink = on_data
|
|
||||||
|
|
||||||
# let the server find a free PSM
|
|
||||||
self.psm = device.create_l2cap_server(
|
|
||||||
spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8),
|
|
||||||
handler=on_coc,
|
|
||||||
).psm
|
|
||||||
self.le_psm_out_characteristic = Characteristic(
|
|
||||||
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
struct.pack('<H', self.psm),
|
|
||||||
)
|
|
||||||
|
|
||||||
characteristics = [
|
|
||||||
self.read_only_properties_characteristic,
|
|
||||||
self.audio_control_point_characteristic,
|
|
||||||
self.audio_status_characteristic,
|
|
||||||
self.volume_characteristic,
|
|
||||||
self.le_psm_out_characteristic,
|
|
||||||
]
|
|
||||||
|
|
||||||
super().__init__(characteristics)
|
|
||||||
|
|
||||||
def get_advertising_data(self):
|
|
||||||
# Advertisement only uses 4 least significant bytes of the HiSyncId.
|
|
||||||
return bytes(
|
|
||||||
AdvertisingData(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
|
||||||
bytes(GATT_ASHA_SERVICE)
|
|
||||||
+ bytes(
|
|
||||||
[
|
|
||||||
AshaService.PROTOCOL_VERSION,
|
|
||||||
self.capability,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ bytes(self.hisyncid[:4]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -350,6 +350,7 @@ class CodecSpecificCapabilities:
|
|||||||
supported_max_codec_frames_per_sdu = value
|
supported_max_codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
# It is expected here that if some fields are missing, an error should be raised.
|
||||||
|
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||||
return CodecSpecificCapabilities(
|
return CodecSpecificCapabilities(
|
||||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||||
supported_frame_durations=supported_frame_durations,
|
supported_frame_durations=supported_frame_durations,
|
||||||
@@ -426,6 +427,7 @@ class CodecSpecificConfiguration:
|
|||||||
codec_frames_per_sdu = value
|
codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
# It is expected here that if some fields are missing, an error should be raised.
|
||||||
|
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||||
return CodecSpecificConfiguration(
|
return CodecSpecificConfiguration(
|
||||||
sampling_frequency=sampling_frequency,
|
sampling_frequency=sampling_frequency,
|
||||||
frame_duration=frame_duration,
|
frame_duration=frame_duration,
|
||||||
|
|||||||
674
bumble/profiles/hap.py
Normal file
674
bumble/profiles/hap.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
# 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 functools
|
||||||
|
from bumble import att, gatt, gatt_client
|
||||||
|
from bumble.core import InvalidArgumentError, InvalidStateError
|
||||||
|
from bumble.device import Device, Connection
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ErrorCode(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 2.4. Attribute Profile error codes.'''
|
||||||
|
|
||||||
|
INVALID_OPCODE = 0x80
|
||||||
|
WRITE_NAME_NOT_ALLOWED = 0x81
|
||||||
|
PRESET_SYNCHRONIZATION_NOT_SUPPORTED = 0x82
|
||||||
|
PRESET_OPERATION_NOT_POSSIBLE = 0x83
|
||||||
|
INVALID_PARAMETERS_LENGTH = 0x84
|
||||||
|
|
||||||
|
|
||||||
|
class HearingAidType(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
BINAURAL_HEARING_AID = 0b00
|
||||||
|
MONAURAL_HEARING_AID = 0b01
|
||||||
|
BANDED_HEARING_AID = 0b10
|
||||||
|
|
||||||
|
|
||||||
|
class PresetSynchronizationSupport(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED = 0b0
|
||||||
|
PRESET_SYNCHRONIZATION_IS_SUPPORTED = 0b1
|
||||||
|
|
||||||
|
|
||||||
|
class IndependentPresets(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
IDENTICAL_PRESET_RECORD = 0b0
|
||||||
|
DIFFERENT_PRESET_RECORD = 0b1
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicPresets(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
PRESET_RECORDS_DOES_NOT_CHANGE = 0b0
|
||||||
|
PRESET_RECORDS_MAY_CHANGE = 0b1
|
||||||
|
|
||||||
|
|
||||||
|
class WritablePresetsSupport(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
WRITABLE_PRESET_RECORDS_NOT_SUPPORTED = 0b0
|
||||||
|
WRITABLE_PRESET_RECORDS_SUPPORTED = 0b1
|
||||||
|
|
||||||
|
|
||||||
|
class HearingAidPresetControlPointOpcode(OpenIntEnum):
|
||||||
|
'''See Hearing Access Service 3.3.1 Hearing Aid Preset Control Point operation requirements.'''
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
READ_PRESETS_REQUEST = 0x01
|
||||||
|
READ_PRESET_RESPONSE = 0x02
|
||||||
|
PRESET_CHANGED = 0x03
|
||||||
|
WRITE_PRESET_NAME = 0x04
|
||||||
|
SET_ACTIVE_PRESET = 0x05
|
||||||
|
SET_NEXT_PRESET = 0x06
|
||||||
|
SET_PREVIOUS_PRESET = 0x07
|
||||||
|
SET_ACTIVE_PRESET_SYNCHRONIZED_LOCALLY = 0x08
|
||||||
|
SET_NEXT_PRESET_SYNCHRONIZED_LOCALLY = 0x09
|
||||||
|
SET_PREVIOUS_PRESET_SYNCHRONIZED_LOCALLY = 0x0A
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HearingAidFeatures:
|
||||||
|
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
|
||||||
|
|
||||||
|
hearing_aid_type: HearingAidType
|
||||||
|
preset_synchronization_support: PresetSynchronizationSupport
|
||||||
|
independent_presets: IndependentPresets
|
||||||
|
dynamic_presets: DynamicPresets
|
||||||
|
writable_presets_support: WritablePresetsSupport
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
[
|
||||||
|
(self.hearing_aid_type << 0)
|
||||||
|
| (self.preset_synchronization_support << 2)
|
||||||
|
| (self.independent_presets << 3)
|
||||||
|
| (self.dynamic_presets << 4)
|
||||||
|
| (self.writable_presets_support << 5)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def HearingAidFeatures_from_bytes(data: int) -> HearingAidFeatures:
|
||||||
|
return HearingAidFeatures(
|
||||||
|
HearingAidType(data & 0b11),
|
||||||
|
PresetSynchronizationSupport(data >> 2 & 0b1),
|
||||||
|
IndependentPresets(data >> 3 & 0b1),
|
||||||
|
DynamicPresets(data >> 4 & 0b1),
|
||||||
|
WritablePresetsSupport(data >> 5 & 0b1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PresetChangedOperation:
|
||||||
|
'''See Hearing Access Service 3.2.2.2. Preset Changed operation.'''
|
||||||
|
|
||||||
|
class ChangeId(OpenIntEnum):
|
||||||
|
# fmt: off
|
||||||
|
GENERIC_UPDATE = 0x00
|
||||||
|
PRESET_RECORD_DELETED = 0x01
|
||||||
|
PRESET_RECORD_AVAILABLE = 0x02
|
||||||
|
PRESET_RECORD_UNAVAILABLE = 0x03
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Generic:
|
||||||
|
prev_index: int
|
||||||
|
preset_record: PresetRecord
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes([self.prev_index]) + bytes(self.preset_record)
|
||||||
|
|
||||||
|
change_id: ChangeId
|
||||||
|
additional_parameters: Union[Generic, int]
|
||||||
|
|
||||||
|
def to_bytes(self, is_last: bool) -> bytes:
|
||||||
|
if isinstance(self.additional_parameters, PresetChangedOperation.Generic):
|
||||||
|
additional_parameters_bytes = bytes(self.additional_parameters)
|
||||||
|
else:
|
||||||
|
additional_parameters_bytes = bytes([self.additional_parameters])
|
||||||
|
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
HearingAidPresetControlPointOpcode.PRESET_CHANGED,
|
||||||
|
self.change_id,
|
||||||
|
is_last,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ additional_parameters_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PresetChangedOperationDeleted(PresetChangedOperation):
|
||||||
|
def __init__(self, index) -> None:
|
||||||
|
self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_DELETED
|
||||||
|
self.additional_parameters = index
|
||||||
|
|
||||||
|
|
||||||
|
class PresetChangedOperationAvailable(PresetChangedOperation):
|
||||||
|
def __init__(self, index) -> None:
|
||||||
|
self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_AVAILABLE
|
||||||
|
self.additional_parameters = index
|
||||||
|
|
||||||
|
|
||||||
|
class PresetChangedOperationUnavailable(PresetChangedOperation):
|
||||||
|
def __init__(self, index) -> None:
|
||||||
|
self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_UNAVAILABLE
|
||||||
|
self.additional_parameters = index
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PresetRecord:
|
||||||
|
'''See Hearing Access Service 2.8. Preset record.'''
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Property:
|
||||||
|
class Writable(OpenIntEnum):
|
||||||
|
CANNOT_BE_WRITTEN = 0b0
|
||||||
|
CAN_BE_WRITTEN = 0b1
|
||||||
|
|
||||||
|
class IsAvailable(OpenIntEnum):
|
||||||
|
IS_UNAVAILABLE = 0b0
|
||||||
|
IS_AVAILABLE = 0b1
|
||||||
|
|
||||||
|
writable: Writable = Writable.CAN_BE_WRITTEN
|
||||||
|
is_available: IsAvailable = IsAvailable.IS_AVAILABLE
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes([self.writable | (self.is_available << 1)])
|
||||||
|
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
properties: Property = field(default_factory=Property)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes([self.index]) + bytes(self.properties) + self.name.encode('utf-8')
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.properties.is_available
|
||||||
|
== PresetRecord.Property.IsAvailable.IS_AVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class HearingAccessService(gatt.TemplateService):
|
||||||
|
UUID = gatt.GATT_HEARING_ACCESS_SERVICE
|
||||||
|
|
||||||
|
hearing_aid_features_characteristic: gatt.Characteristic
|
||||||
|
hearing_aid_preset_control_point: gatt.Characteristic
|
||||||
|
active_preset_index_characteristic: gatt.Characteristic
|
||||||
|
active_preset_index: int
|
||||||
|
active_preset_index_per_device: Dict[Address, int]
|
||||||
|
|
||||||
|
device: Device
|
||||||
|
|
||||||
|
server_features: HearingAidFeatures
|
||||||
|
preset_records: Dict[int, PresetRecord] # key is the preset index
|
||||||
|
read_presets_request_in_progress: bool
|
||||||
|
|
||||||
|
preset_changed_operations_history_per_device: Dict[
|
||||||
|
Address, List[PresetChangedOperation]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keep an updated list of connected client to send notification to
|
||||||
|
currently_connected_clients: Set[Connection]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, device: Device, features: HearingAidFeatures, presets: List[PresetRecord]
|
||||||
|
) -> None:
|
||||||
|
self.active_preset_index_per_device = {}
|
||||||
|
self.read_presets_request_in_progress = False
|
||||||
|
self.preset_changed_operations_history_per_device = {}
|
||||||
|
self.currently_connected_clients = set()
|
||||||
|
|
||||||
|
self.device = device
|
||||||
|
self.server_features = features
|
||||||
|
if len(presets) < 1:
|
||||||
|
raise InvalidArgumentError(f'Invalid presets: {presets}')
|
||||||
|
|
||||||
|
self.preset_records = {}
|
||||||
|
for p in presets:
|
||||||
|
if len(p.name.encode()) < 1 or len(p.name.encode()) > 40:
|
||||||
|
raise InvalidArgumentError(f'Invalid name: {p.name}')
|
||||||
|
|
||||||
|
self.preset_records[p.index] = p
|
||||||
|
|
||||||
|
# associate the lowest index as the current active preset at startup
|
||||||
|
self.active_preset_index = sorted(self.preset_records.keys())[0]
|
||||||
|
|
||||||
|
@device.on('connection') # type: ignore
|
||||||
|
def on_connection(connection: Connection) -> None:
|
||||||
|
@connection.on('disconnection') # type: ignore
|
||||||
|
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)
|
||||||
|
|
||||||
|
if connection.peer_resolvable_address:
|
||||||
|
self.on_incoming_paired_connection(connection)
|
||||||
|
|
||||||
|
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=bytes(self.server_features),
|
||||||
|
)
|
||||||
|
self.hearing_aid_preset_control_point = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
gatt.Characteristic.Properties.WRITE
|
||||||
|
| gatt.Characteristic.Properties.INDICATE
|
||||||
|
),
|
||||||
|
permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
|
value=gatt.CharacteristicValue(
|
||||||
|
write=self._on_write_hearing_aid_preset_control_point
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.active_preset_index_characteristic = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
|
),
|
||||||
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=gatt.CharacteristicValue(read=self._on_read_active_preset_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
[
|
||||||
|
self.hearing_aid_features_characteristic,
|
||||||
|
self.hearing_aid_preset_control_point,
|
||||||
|
self.active_preset_index_characteristic,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return bytes([self.active_preset_index])
|
||||||
|
|
||||||
|
# TODO this need to be triggered when device is unbonded
|
||||||
|
def on_forget(self, addr: Address) -> None:
|
||||||
|
self.preset_changed_operations_history_per_device.pop(addr)
|
||||||
|
|
||||||
|
async def _on_write_hearing_aid_preset_control_point(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
):
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
opcode = HearingAidPresetControlPointOpcode(value[0])
|
||||||
|
handler = getattr(self, '_on_' + opcode.name.lower())
|
||||||
|
await handler(connection, value)
|
||||||
|
|
||||||
|
async def _on_read_presets_request(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
):
|
||||||
|
assert connection
|
||||||
|
if connection.att_mtu < 49: # 2.5. GATT sub-procedure requirements
|
||||||
|
logging.warning(f'HAS require MTU >= 49: {connection}')
|
||||||
|
|
||||||
|
if self.read_presets_request_in_progress:
|
||||||
|
raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
|
||||||
|
self.read_presets_request_in_progress = True
|
||||||
|
|
||||||
|
start_index = value[1]
|
||||||
|
if start_index == 0x00:
|
||||||
|
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
|
||||||
|
|
||||||
|
num_presets = value[2]
|
||||||
|
if num_presets == 0x00:
|
||||||
|
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
|
||||||
|
|
||||||
|
# Sending `num_presets` presets ordered by increasing index field, starting from start_index
|
||||||
|
presets = [
|
||||||
|
self.preset_records[key]
|
||||||
|
for key in sorted(self.preset_records.keys())
|
||||||
|
if self.preset_records[key].index >= start_index
|
||||||
|
]
|
||||||
|
del presets[num_presets:]
|
||||||
|
if len(presets) == 0:
|
||||||
|
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
|
||||||
|
|
||||||
|
AsyncRunner.spawn(self._read_preset_response(connection, presets))
|
||||||
|
|
||||||
|
async def _read_preset_response(
|
||||||
|
self, connection: Connection, presets: List[PresetRecord]
|
||||||
|
):
|
||||||
|
# If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Read Presets Request operation aborted and shall not either continue or restart the operation when the client reconnects.
|
||||||
|
try:
|
||||||
|
for i, preset in enumerate(presets):
|
||||||
|
await connection.device.indicate_subscriber(
|
||||||
|
connection,
|
||||||
|
self.hearing_aid_preset_control_point,
|
||||||
|
value=bytes(
|
||||||
|
[
|
||||||
|
HearingAidPresetControlPointOpcode.READ_PRESET_RESPONSE,
|
||||||
|
i == len(presets) - 1,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ bytes(preset),
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# indicate_subscriber can raise a TimeoutError, we need to gracefully terminate the operation
|
||||||
|
self.read_presets_request_in_progress = False
|
||||||
|
|
||||||
|
async def generic_update(self, op: PresetChangedOperation) -> None:
|
||||||
|
'''Server API to perform a generic update. It is the responsibility of the caller to modify the preset_records to match the PresetChangedOperation being sent'''
|
||||||
|
await self._notifyPresetOperations(op)
|
||||||
|
|
||||||
|
async def delete_preset(self, index: int) -> None:
|
||||||
|
'''Server API to delete a preset. It should not be the current active preset'''
|
||||||
|
|
||||||
|
if index == self.active_preset_index:
|
||||||
|
raise InvalidStateError('Cannot delete active preset')
|
||||||
|
|
||||||
|
del self.preset_records[index]
|
||||||
|
await self._notifyPresetOperations(PresetChangedOperationDeleted(index))
|
||||||
|
|
||||||
|
async def available_preset(self, index: int) -> None:
|
||||||
|
'''Server API to make a preset available'''
|
||||||
|
|
||||||
|
preset = self.preset_records[index]
|
||||||
|
preset.properties.is_available = PresetRecord.Property.IsAvailable.IS_AVAILABLE
|
||||||
|
await self._notifyPresetOperations(PresetChangedOperationAvailable(index))
|
||||||
|
|
||||||
|
async def unavailable_preset(self, index: int) -> None:
|
||||||
|
'''Server API to make a preset unavailable. It should not be the current active preset'''
|
||||||
|
|
||||||
|
if index == self.active_preset_index:
|
||||||
|
raise InvalidStateError('Cannot set active preset as unavailable')
|
||||||
|
|
||||||
|
preset = self.preset_records[index]
|
||||||
|
preset.properties.is_available = (
|
||||||
|
PresetRecord.Property.IsAvailable.IS_UNAVAILABLE
|
||||||
|
)
|
||||||
|
await self._notifyPresetOperations(PresetChangedOperationUnavailable(index))
|
||||||
|
|
||||||
|
async def _preset_changed_operation(self, connection: Connection) -> None:
|
||||||
|
'''Send all PresetChangedOperation saved for a given connection'''
|
||||||
|
op_list = self.preset_changed_operations_history_per_device.get(
|
||||||
|
connection.peer_address, []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification will be sent in index order
|
||||||
|
def get_op_index(op: PresetChangedOperation) -> int:
|
||||||
|
if isinstance(op.additional_parameters, PresetChangedOperation.Generic):
|
||||||
|
return op.additional_parameters.prev_index
|
||||||
|
return op.additional_parameters
|
||||||
|
|
||||||
|
op_list.sort(key=get_op_index)
|
||||||
|
# If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Preset Changed operation aborted and shall continue the operation when the client reconnects.
|
||||||
|
while len(op_list) > 0:
|
||||||
|
try:
|
||||||
|
await connection.device.indicate_subscriber(
|
||||||
|
connection,
|
||||||
|
self.hearing_aid_preset_control_point,
|
||||||
|
value=op_list[0].to_bytes(len(op_list) == 1),
|
||||||
|
)
|
||||||
|
# Remove item once sent, and keep the non sent item in the list
|
||||||
|
op_list.pop(0)
|
||||||
|
except TimeoutError:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _notifyPresetOperations(self, op: PresetChangedOperation) -> None:
|
||||||
|
for historyList in self.preset_changed_operations_history_per_device.values():
|
||||||
|
historyList.append(op)
|
||||||
|
|
||||||
|
for connection in self.currently_connected_clients:
|
||||||
|
await self._preset_changed_operation(connection)
|
||||||
|
|
||||||
|
async def _on_write_preset_name(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
):
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
if self.read_presets_request_in_progress:
|
||||||
|
raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
|
||||||
|
|
||||||
|
index = value[1]
|
||||||
|
preset = self.preset_records.get(index, None)
|
||||||
|
if (
|
||||||
|
not preset
|
||||||
|
or preset.properties.writable
|
||||||
|
== PresetRecord.Property.Writable.CANNOT_BE_WRITTEN
|
||||||
|
):
|
||||||
|
raise att.ATT_Error(ErrorCode.WRITE_NAME_NOT_ALLOWED)
|
||||||
|
|
||||||
|
name = value[2:].decode('utf-8')
|
||||||
|
if not name or len(name) > 40:
|
||||||
|
raise att.ATT_Error(ErrorCode.INVALID_PARAMETERS_LENGTH)
|
||||||
|
|
||||||
|
preset.name = name
|
||||||
|
|
||||||
|
await self.generic_update(
|
||||||
|
PresetChangedOperation(
|
||||||
|
PresetChangedOperation.ChangeId.GENERIC_UPDATE,
|
||||||
|
PresetChangedOperation.Generic(index, preset),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_active_preset_for_connection(self, connection: Connection) -> None:
|
||||||
|
if (
|
||||||
|
self.active_preset_index_per_device.get(connection.peer_address, 0x00)
|
||||||
|
== self.active_preset_index
|
||||||
|
):
|
||||||
|
# Nothing to do, peer is already updated
|
||||||
|
return
|
||||||
|
|
||||||
|
await connection.device.notify_subscriber(
|
||||||
|
connection,
|
||||||
|
attribute=self.active_preset_index_characteristic,
|
||||||
|
value=bytes([self.active_preset_index]),
|
||||||
|
)
|
||||||
|
self.active_preset_index_per_device[connection.peer_address] = (
|
||||||
|
self.active_preset_index
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_active_preset(self) -> None:
|
||||||
|
for connection in self.currently_connected_clients:
|
||||||
|
await self.notify_active_preset_for_connection(connection)
|
||||||
|
|
||||||
|
async def set_active_preset(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
) -> None:
|
||||||
|
assert connection
|
||||||
|
index = value[1]
|
||||||
|
preset = self.preset_records.get(index, None)
|
||||||
|
if (
|
||||||
|
not preset
|
||||||
|
or preset.properties.is_available
|
||||||
|
!= PresetRecord.Property.IsAvailable.IS_AVAILABLE
|
||||||
|
):
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
||||||
|
|
||||||
|
if index == self.active_preset_index:
|
||||||
|
# Already at correct value
|
||||||
|
return
|
||||||
|
|
||||||
|
self.active_preset_index = index
|
||||||
|
await self.notify_active_preset()
|
||||||
|
|
||||||
|
async def _on_set_active_preset(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
):
|
||||||
|
await self.set_active_preset(connection, value)
|
||||||
|
|
||||||
|
async def set_next_or_previous_preset(
|
||||||
|
self, connection: Optional[Connection], is_previous
|
||||||
|
):
|
||||||
|
'''Set the next or the previous preset as active'''
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
if self.active_preset_index == 0x00:
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
||||||
|
|
||||||
|
first_preset: Optional[PresetRecord] = None # To loop to first preset
|
||||||
|
next_preset: Optional[PresetRecord] = None
|
||||||
|
for index, record in sorted(self.preset_records.items(), reverse=is_previous):
|
||||||
|
if not record.is_available():
|
||||||
|
continue
|
||||||
|
if first_preset == None:
|
||||||
|
first_preset = record
|
||||||
|
if is_previous:
|
||||||
|
if index >= self.active_preset_index:
|
||||||
|
continue
|
||||||
|
elif index <= self.active_preset_index:
|
||||||
|
continue
|
||||||
|
next_preset = record
|
||||||
|
break
|
||||||
|
|
||||||
|
if not first_preset: # If no other preset are available
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
||||||
|
|
||||||
|
if next_preset:
|
||||||
|
self.active_preset_index = next_preset.index
|
||||||
|
else:
|
||||||
|
self.active_preset_index = first_preset.index
|
||||||
|
await self.notify_active_preset()
|
||||||
|
|
||||||
|
async def _on_set_next_preset(
|
||||||
|
self, connection: Optional[Connection], __value__: bytes
|
||||||
|
) -> None:
|
||||||
|
await self.set_next_or_previous_preset(connection, False)
|
||||||
|
|
||||||
|
async def _on_set_previous_preset(
|
||||||
|
self, connection: Optional[Connection], __value__: bytes
|
||||||
|
) -> None:
|
||||||
|
await self.set_next_or_previous_preset(connection, True)
|
||||||
|
|
||||||
|
async def _on_set_active_preset_synchronized_locally(
|
||||||
|
self, connection: Optional[Connection], value: bytes
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
self.server_features.preset_synchronization_support
|
||||||
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||||
|
):
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||||
|
await self.set_active_preset(connection, value)
|
||||||
|
# TODO (low priority) inform other server of the change
|
||||||
|
|
||||||
|
async def _on_set_next_preset_synchronized_locally(
|
||||||
|
self, connection: Optional[Connection], __value__: bytes
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
self.server_features.preset_synchronization_support
|
||||||
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||||
|
):
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||||
|
await self.set_next_or_previous_preset(connection, False)
|
||||||
|
# TODO (low priority) inform other server of the change
|
||||||
|
|
||||||
|
async def _on_set_previous_preset_synchronized_locally(
|
||||||
|
self, connection: Optional[Connection], __value__: bytes
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
self.server_features.preset_synchronization_support
|
||||||
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
|
||||||
|
):
|
||||||
|
raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
|
||||||
|
await self.set_next_or_previous_preset(connection, True)
|
||||||
|
# TODO (low priority) inform other server of the change
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = HearingAccessService
|
||||||
|
|
||||||
|
hearing_aid_preset_control_point: gatt_client.CharacteristicProxy
|
||||||
|
preset_control_point_indications: asyncio.Queue
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
self.server_features = gatt.PackedCharacteristicAdapter(
|
||||||
|
service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC
|
||||||
|
)[0],
|
||||||
|
'B',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.hearing_aid_preset_control_point = (
|
||||||
|
service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC
|
||||||
|
)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.active_preset_index = gatt.PackedCharacteristicAdapter(
|
||||||
|
service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC
|
||||||
|
)[0],
|
||||||
|
'B',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def setup_subscription(self):
|
||||||
|
self.preset_control_point_indications = asyncio.Queue()
|
||||||
|
self.active_preset_index_notification = asyncio.Queue()
|
||||||
|
|
||||||
|
def on_active_preset_index_notification(data: bytes):
|
||||||
|
self.active_preset_index_notification.put_nowait(data)
|
||||||
|
|
||||||
|
def on_preset_control_point_indication(data: bytes):
|
||||||
|
self.preset_control_point_indications.put_nowait(data)
|
||||||
|
|
||||||
|
await self.hearing_aid_preset_control_point.subscribe(
|
||||||
|
functools.partial(on_preset_control_point_indication), prefer_notify=False
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.active_preset_index.subscribe(
|
||||||
|
functools.partial(on_active_preset_index_notification)
|
||||||
|
)
|
||||||
@@ -24,7 +24,7 @@ from bumble import device
|
|||||||
from bumble import gatt
|
from bumble import gatt
|
||||||
from bumble import gatt_client
|
from bumble import gatt_client
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -88,6 +88,7 @@ class VolumeControlService(gatt.TemplateService):
|
|||||||
muted: int = 0,
|
muted: int = 0,
|
||||||
change_counter: int = 0,
|
change_counter: int = 0,
|
||||||
volume_flags: int = 0,
|
volume_flags: int = 0,
|
||||||
|
included_services: Sequence[gatt.Service] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
self.step_size = step_size
|
self.step_size = step_size
|
||||||
self.volume_setting = volume_setting
|
self.volume_setting = volume_setting
|
||||||
@@ -117,11 +118,12 @@ class VolumeControlService(gatt.TemplateService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
[
|
characteristics=[
|
||||||
self.volume_state,
|
self.volume_state,
|
||||||
self.volume_control_point,
|
self.volume_control_point,
|
||||||
self.volume_flags,
|
self.volume_flags,
|
||||||
]
|
],
|
||||||
|
included_services=list(included_services),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
110
bumble/rtp.py
Normal file
110
bumble/rtp.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 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)})'
|
||||||
|
)
|
||||||
@@ -434,6 +434,8 @@ class DataElement:
|
|||||||
if size != 1:
|
if size != 1:
|
||||||
raise InvalidArgumentError('boolean must be 1 byte')
|
raise InvalidArgumentError('boolean must be 1 byte')
|
||||||
size_index = 0
|
size_index = 0
|
||||||
|
else:
|
||||||
|
raise RuntimeError("internal error - self.type not supported")
|
||||||
|
|
||||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||||
return self.bytes
|
return self.bytes
|
||||||
|
|||||||
@@ -764,7 +764,9 @@ class Session:
|
|||||||
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
||||||
|
|
||||||
# OOB
|
# OOB
|
||||||
self.oob_data_flag = 0 if pairing_config.oob is None else 1
|
self.oob_data_flag = (
|
||||||
|
1 if pairing_config.oob and pairing_config.oob.peer_data else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Set up addresses
|
# Set up addresses
|
||||||
self_address = connection.self_resolvable_address or connection.self_address
|
self_address = connection.self_resolvable_address or connection.self_address
|
||||||
@@ -1014,8 +1016,10 @@ class Session:
|
|||||||
self.send_command(response)
|
self.send_command(response)
|
||||||
|
|
||||||
def send_pairing_confirm_command(self) -> None:
|
def send_pairing_confirm_command(self) -> None:
|
||||||
self.r = crypto.r()
|
|
||||||
logger.debug(f'generated random: {self.r.hex()}')
|
if self.pairing_method != PairingMethod.OOB:
|
||||||
|
self.r = crypto.r()
|
||||||
|
logger.debug(f'generated random: {self.r.hex()}')
|
||||||
|
|
||||||
if self.sc:
|
if self.sc:
|
||||||
|
|
||||||
@@ -1735,7 +1739,6 @@ class Session:
|
|||||||
if self.pairing_method in (
|
if self.pairing_method in (
|
||||||
PairingMethod.JUST_WORKS,
|
PairingMethod.JUST_WORKS,
|
||||||
PairingMethod.NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
PairingMethod.OOB,
|
|
||||||
):
|
):
|
||||||
ra = bytes(16)
|
ra = bytes(16)
|
||||||
rb = ra
|
rb = ra
|
||||||
@@ -1743,6 +1746,22 @@ class Session:
|
|||||||
assert self.passkey
|
assert self.passkey
|
||||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||||
rb = ra
|
rb = ra
|
||||||
|
elif self.pairing_method == PairingMethod.OOB:
|
||||||
|
if self.is_initiator:
|
||||||
|
if self.peer_oob_data:
|
||||||
|
rb = self.peer_oob_data.r
|
||||||
|
ra = self.r
|
||||||
|
else:
|
||||||
|
rb = bytes(16)
|
||||||
|
ra = self.r
|
||||||
|
else:
|
||||||
|
if self.peer_oob_data:
|
||||||
|
ra = self.peer_oob_data.r
|
||||||
|
rb = self.r
|
||||||
|
else:
|
||||||
|
ra = bytes(16)
|
||||||
|
rb = self.r
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import atexit
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import platform
|
||||||
import sys
|
import sys
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
from .common import (
|
import bumble
|
||||||
|
from bumble.transport.common import (
|
||||||
ParserSource,
|
ParserSource,
|
||||||
PumpedTransport,
|
PumpedTransport,
|
||||||
PumpedPacketSource,
|
PumpedPacketSource,
|
||||||
@@ -36,15 +38,15 @@ from .common import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
from .grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
|
||||||
PacketStreamerStub,
|
PacketStreamerStub,
|
||||||
PacketStreamerServicer,
|
PacketStreamerServicer,
|
||||||
add_PacketStreamerServicer_to_server,
|
add_PacketStreamerServicer_to_server,
|
||||||
)
|
)
|
||||||
from .grpc_protobuf.packet_streamer_pb2 import PacketRequest, PacketResponse
|
from .grpc_protobuf.netsim.packet_streamer_pb2 import PacketRequest, PacketResponse
|
||||||
from .grpc_protobuf.hci_packet_pb2 import HCIPacket
|
from .grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
|
||||||
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
|
from .grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
|
||||||
from .grpc_protobuf.common_pb2 import ChipKind
|
from .grpc_protobuf.netsim.common_pb2 import ChipKind
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -58,6 +60,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
DEFAULT_NAME = 'bumble0'
|
DEFAULT_NAME = 'bumble0'
|
||||||
DEFAULT_MANUFACTURER = 'Bumble'
|
DEFAULT_MANUFACTURER = 'Bumble'
|
||||||
|
DEFAULT_VARIANT = ''
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -70,6 +73,9 @@ def get_ini_dir() -> Optional[pathlib.Path]:
|
|||||||
elif sys.platform == 'linux':
|
elif sys.platform == 'linux':
|
||||||
if xdg_runtime_dir := os.environ.get('XDG_RUNTIME_DIR', None):
|
if xdg_runtime_dir := os.environ.get('XDG_RUNTIME_DIR', None):
|
||||||
return pathlib.Path(xdg_runtime_dir)
|
return pathlib.Path(xdg_runtime_dir)
|
||||||
|
tmpdir = os.environ.get('TMPDIR', '/tmp')
|
||||||
|
if pathlib.Path(tmpdir).is_dir():
|
||||||
|
return pathlib.Path(tmpdir)
|
||||||
elif sys.platform == 'win32':
|
elif sys.platform == 'win32':
|
||||||
if local_app_data_dir := os.environ.get('LOCALAPPDATA', None):
|
if local_app_data_dir := os.environ.get('LOCALAPPDATA', None):
|
||||||
return pathlib.Path(local_app_data_dir) / 'Temp'
|
return pathlib.Path(local_app_data_dir) / 'Temp'
|
||||||
@@ -196,7 +202,6 @@ async def open_android_netsim_controller_transport(
|
|||||||
data = (
|
data = (
|
||||||
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
||||||
)
|
)
|
||||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
|
||||||
self.on_data_received(data)
|
self.on_data_received(data)
|
||||||
|
|
||||||
async def send_packet(self, data):
|
async def send_packet(self, data):
|
||||||
@@ -250,7 +255,7 @@ async def open_android_netsim_controller_transport(
|
|||||||
|
|
||||||
# Check that we don't already have a device
|
# Check that we don't already have a device
|
||||||
if self.device:
|
if self.device:
|
||||||
logger.debug('busy, already serving a device')
|
logger.debug('Busy, already serving a device')
|
||||||
return PacketResponse(error='Busy')
|
return PacketResponse(error='Busy')
|
||||||
|
|
||||||
# Instantiate a new device
|
# Instantiate a new device
|
||||||
@@ -309,16 +314,24 @@ async def open_android_netsim_host_transport_with_channel(
|
|||||||
):
|
):
|
||||||
# Wrapper for I/O operations
|
# Wrapper for I/O operations
|
||||||
class HciDevice:
|
class HciDevice:
|
||||||
def __init__(self, name, manufacturer, hci_device):
|
def __init__(self, name, variant, manufacturer, hci_device):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.variant = variant
|
||||||
self.manufacturer = manufacturer
|
self.manufacturer = manufacturer
|
||||||
self.hci_device = hci_device
|
self.hci_device = hci_device
|
||||||
|
|
||||||
async def start(self): # Send the startup info
|
async def start(self): # Send the startup info
|
||||||
chip_info = ChipInfo(
|
device_info = DeviceInfo(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
chip=Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer),
|
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_info = ChipInfo(name=self.name, chip=chip, device_info=device_info)
|
||||||
logger.debug(f'Sending chip info to netsim: {chip_info}')
|
logger.debug(f'Sending chip info to netsim: {chip_info}')
|
||||||
await self.hci_device.write(PacketRequest(initial_info=chip_info))
|
await self.hci_device.write(PacketRequest(initial_info=chip_info))
|
||||||
|
|
||||||
@@ -346,12 +359,16 @@ async def open_android_netsim_host_transport_with_channel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
|
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
|
manufacturer = DEFAULT_MANUFACTURER
|
||||||
|
|
||||||
# Connect as a host
|
# Connect as a host
|
||||||
service = PacketStreamerStub(channel)
|
service = PacketStreamerStub(channel)
|
||||||
hci_device = HciDevice(
|
hci_device = HciDevice(
|
||||||
name=name,
|
name=name,
|
||||||
|
variant=variant,
|
||||||
manufacturer=manufacturer,
|
manufacturer=manufacturer,
|
||||||
hci_device=service.StreamPackets(),
|
hci_device=service.StreamPackets(),
|
||||||
)
|
)
|
||||||
@@ -401,6 +418,9 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
|||||||
The "chip" name, used to identify the "chip" instance. This
|
The "chip" name, used to identify the "chip" instance. This
|
||||||
may be useful when several clients are connected, since each needs to use a
|
may be useful when several clients are connected, since each needs to use a
|
||||||
different name.
|
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:
|
In `controller` mode:
|
||||||
The <host>:<port> part is required. <host> may be the address of a local network
|
The <host>:<port> part is required. <host> may be the address of a local network
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
0
bumble/transport/grpc_protobuf/netsim/__init__.py
Normal file
0
bumble/transport/grpc_protobuf/netsim/__init__.py
Normal file
@@ -1,11 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
# source: common.proto
|
# source: netsim/common.proto
|
||||||
|
# Protobuf Python Version: 4.25.1
|
||||||
"""Generated protocol buffer code."""
|
"""Generated protocol buffer code."""
|
||||||
from google.protobuf.internal import builder as _builder
|
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||||
from google.protobuf import symbol_database as _symbol_database
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
from google.protobuf.internal import builder as _builder
|
||||||
# @@protoc_insertion_point(imports)
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
_sym_db = _symbol_database.Default()
|
_sym_db = _symbol_database.Default()
|
||||||
@@ -13,13 +14,13 @@ _sym_db = _symbol_database.Default()
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
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')
|
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')
|
||||||
|
|
||||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
_globals = globals()
|
||||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', globals())
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
|
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.common_pb2', _globals)
|
||||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||||
|
|
||||||
DESCRIPTOR._options = None
|
DESCRIPTOR._options = None
|
||||||
_CHIPKIND._serialized_start=31
|
_globals['_CHIPKIND']._serialized_start=38
|
||||||
_CHIPKIND._serialized_end=92
|
_globals['_CHIPKIND']._serialized_end=121
|
||||||
# @@protoc_insertion_point(module_scope)
|
# @@protoc_insertion_point(module_scope)
|
||||||
@@ -2,11 +2,17 @@ from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
|||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
from typing import ClassVar as _ClassVar
|
from typing import ClassVar as _ClassVar
|
||||||
|
|
||||||
BLUETOOTH: ChipKind
|
|
||||||
DESCRIPTOR: _descriptor.FileDescriptor
|
DESCRIPTOR: _descriptor.FileDescriptor
|
||||||
UNSPECIFIED: ChipKind
|
|
||||||
UWB: ChipKind
|
|
||||||
WIFI: ChipKind
|
|
||||||
|
|
||||||
class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
__slots__ = []
|
__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
|
||||||
29
bumble/transport/grpc_protobuf/netsim/hci_packet_pb2.py
Normal file
29
bumble/transport/grpc_protobuf/netsim/hci_packet_pb2.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -6,17 +6,23 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
|||||||
DESCRIPTOR: _descriptor.FileDescriptor
|
DESCRIPTOR: _descriptor.FileDescriptor
|
||||||
|
|
||||||
class HCIPacket(_message.Message):
|
class HCIPacket(_message.Message):
|
||||||
__slots__ = ["packet", "packet_type"]
|
__slots__ = ("packet_type", "packet")
|
||||||
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
__slots__ = []
|
__slots__ = ()
|
||||||
ACL: HCIPacket.PacketType
|
HCI_PACKET_UNSPECIFIED: _ClassVar[HCIPacket.PacketType]
|
||||||
COMMAND: HCIPacket.PacketType
|
COMMAND: _ClassVar[HCIPacket.PacketType]
|
||||||
EVENT: HCIPacket.PacketType
|
ACL: _ClassVar[HCIPacket.PacketType]
|
||||||
|
SCO: _ClassVar[HCIPacket.PacketType]
|
||||||
|
EVENT: _ClassVar[HCIPacket.PacketType]
|
||||||
|
ISO: _ClassVar[HCIPacket.PacketType]
|
||||||
HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
|
HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
|
||||||
ISO: HCIPacket.PacketType
|
COMMAND: HCIPacket.PacketType
|
||||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
ACL: HCIPacket.PacketType
|
||||||
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
|
||||||
SCO: HCIPacket.PacketType
|
SCO: HCIPacket.PacketType
|
||||||
packet: bytes
|
EVENT: HCIPacket.PacketType
|
||||||
|
ISO: HCIPacket.PacketType
|
||||||
|
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||||
packet_type: HCIPacket.PacketType
|
packet_type: HCIPacket.PacketType
|
||||||
|
packet: bytes
|
||||||
def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
||||||
63
bumble/transport/grpc_protobuf/netsim/model_pb2.py
Normal file
63
bumble/transport/grpc_protobuf/netsim/model_pb2.py
Normal file
File diff suppressed because one or more lines are too long
238
bumble/transport/grpc_protobuf/netsim/model_pb2.pyi
Normal file
238
bumble/transport/grpc_protobuf/netsim/model_pb2.pyi
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
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: ...
|
||||||
32
bumble/transport/grpc_protobuf/netsim/packet_streamer_pb2.py
Normal file
32
bumble/transport/grpc_protobuf/netsim/packet_streamer_pb2.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# -*- 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,5 +1,5 @@
|
|||||||
from . import hci_packet_pb2 as _hci_packet_pb2
|
from bumble.transport.grpc_protobuf.netsim import hci_packet_pb2 as _hci_packet_pb2
|
||||||
from . import startup_pb2 as _startup_pb2
|
from bumble.transport.grpc_protobuf.netsim import startup_pb2 as _startup_pb2
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
from google.protobuf import message as _message
|
from google.protobuf import message as _message
|
||||||
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
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
|
DESCRIPTOR: _descriptor.FileDescriptor
|
||||||
|
|
||||||
class PacketRequest(_message.Message):
|
class PacketRequest(_message.Message):
|
||||||
__slots__ = ["hci_packet", "initial_info", "packet"]
|
__slots__ = ("initial_info", "hci_packet", "packet")
|
||||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
|
||||||
INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
|
INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||||
hci_packet: _hci_packet_pb2.HCIPacket
|
|
||||||
initial_info: _startup_pb2.ChipInfo
|
initial_info: _startup_pb2.ChipInfo
|
||||||
|
hci_packet: _hci_packet_pb2.HCIPacket
|
||||||
packet: bytes
|
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: ...
|
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):
|
class PacketResponse(_message.Message):
|
||||||
__slots__ = ["error", "hci_packet", "packet"]
|
__slots__ = ("error", "hci_packet", "packet")
|
||||||
ERROR_FIELD_NUMBER: _ClassVar[int]
|
ERROR_FIELD_NUMBER: _ClassVar[int]
|
||||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Client and server classes corresponding to protobuf-defined services."""
|
"""Client and server classes corresponding to protobuf-defined services."""
|
||||||
import grpc
|
import grpc
|
||||||
|
|
||||||
from . import packet_streamer_pb2 as packet__streamer__pb2
|
from bumble.transport.grpc_protobuf.netsim import packet_streamer_pb2 as netsim_dot_packet__streamer__pb2
|
||||||
|
|
||||||
|
|
||||||
class PacketStreamerStub(object):
|
class PacketStreamerStub(object):
|
||||||
@@ -30,8 +30,8 @@ class PacketStreamerStub(object):
|
|||||||
"""
|
"""
|
||||||
self.StreamPackets = channel.stream_stream(
|
self.StreamPackets = channel.stream_stream(
|
||||||
'/netsim.packet.PacketStreamer/StreamPackets',
|
'/netsim.packet.PacketStreamer/StreamPackets',
|
||||||
request_serializer=packet__streamer__pb2.PacketRequest.SerializeToString,
|
request_serializer=netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||||
response_deserializer=packet__streamer__pb2.PacketResponse.FromString,
|
response_deserializer=netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ def add_PacketStreamerServicer_to_server(servicer, server):
|
|||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
'StreamPackets': grpc.stream_stream_rpc_method_handler(
|
'StreamPackets': grpc.stream_stream_rpc_method_handler(
|
||||||
servicer.StreamPackets,
|
servicer.StreamPackets,
|
||||||
request_deserializer=packet__streamer__pb2.PacketRequest.FromString,
|
request_deserializer=netsim_dot_packet__streamer__pb2.PacketRequest.FromString,
|
||||||
response_serializer=packet__streamer__pb2.PacketResponse.SerializeToString,
|
response_serializer=netsim_dot_packet__streamer__pb2.PacketResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
@@ -103,7 +103,7 @@ class PacketStreamer(object):
|
|||||||
timeout=None,
|
timeout=None,
|
||||||
metadata=None):
|
metadata=None):
|
||||||
return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets',
|
return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets',
|
||||||
packet__streamer__pb2.PacketRequest.SerializeToString,
|
netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||||
packet__streamer__pb2.PacketResponse.FromString,
|
netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||||
options, channel_credentials,
|
options, channel_credentials,
|
||||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||||
41
bumble/transport/grpc_protobuf/netsim/startup_pb2.py
Normal file
41
bumble/transport/grpc_protobuf/netsim/startup_pb2.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- 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)
|
||||||
76
bumble/transport/grpc_protobuf/netsim/startup_pb2.pyi
Normal file
76
bumble/transport/grpc_protobuf/netsim/startup_pb2.pyi
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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,4 @@
|
|||||||
|
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||||
|
"""Client and server classes corresponding to protobuf-defined services."""
|
||||||
|
import grpc
|
||||||
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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: ...
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 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: 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)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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: ...
|
|
||||||
@@ -23,7 +23,7 @@ import time
|
|||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Set
|
||||||
from usb.core import Device as UsbDevice
|
from usb.core import Device as UsbDevice
|
||||||
from usb.core import USBError
|
from usb.core import USBError
|
||||||
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
|
||||||
@@ -46,6 +46,11 @@ RESET_DELAY = 3
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Global
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
devices_in_use: Set[int] = set()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_pyusb_transport(spec: str) -> Transport:
|
async def open_pyusb_transport(spec: str) -> Transport:
|
||||||
@@ -217,6 +222,8 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
await self.source.stop()
|
await self.source.stop()
|
||||||
await self.sink.stop()
|
await self.sink.stop()
|
||||||
usb.util.release_interface(self.device, 0)
|
usb.util.release_interface(self.device, 0)
|
||||||
|
if devices_in_use and device.address in devices_in_use:
|
||||||
|
devices_in_use.remove(device.address)
|
||||||
|
|
||||||
usb_find = usb.core.find
|
usb_find = usb.core.find
|
||||||
try:
|
try:
|
||||||
@@ -233,7 +240,18 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
spec = spec[1:]
|
spec = spec[1:]
|
||||||
if ':' in spec:
|
if ':' in spec:
|
||||||
vendor_id, product_id = spec.split(':')
|
vendor_id, product_id = spec.split(':')
|
||||||
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
|
device = None
|
||||||
|
devices = usb_find(
|
||||||
|
find_all=True, idVendor=int(vendor_id, 16), idProduct=int(product_id, 16)
|
||||||
|
)
|
||||||
|
for d in devices:
|
||||||
|
if d.address in devices_in_use:
|
||||||
|
continue
|
||||||
|
device = d
|
||||||
|
devices_in_use.add(d.address)
|
||||||
|
break
|
||||||
|
if device is None:
|
||||||
|
raise ValueError('device already in use')
|
||||||
elif '-' in spec:
|
elif '-' in spec:
|
||||||
|
|
||||||
def device_path(device):
|
def device_path(device):
|
||||||
|
|||||||
@@ -11,32 +11,44 @@ Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--device-config FILENAME Device configuration file
|
--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]
|
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||||
--extended-data-length TEXT Request a data length upon connection,
|
--extended-data-length TEXT Request a data length upon connection,
|
||||||
specified as tx_octets/tx_time
|
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-uuid TEXT RFComm service UUID to use (ignored if
|
||||||
--rfcomm-channel is not 0)
|
--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-psm INTEGER L2CAP PSM to use
|
||||||
--l2cap-mtu INTEGER L2CAP MTU to use
|
--l2cap-mtu INTEGER L2CAP MTU to use
|
||||||
--l2cap-mps INTEGER L2CAP MPS to use
|
--l2cap-mps INTEGER L2CAP MPS to use
|
||||||
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
||||||
the peer
|
the peer
|
||||||
-s, --packet-size SIZE Packet size (client or ping role)
|
-s, --packet-size SIZE Packet size (send or ping scenario)
|
||||||
[8<=x<=4096]
|
[8<=x<=8192]
|
||||||
-c, --packet-count COUNT Packet count (client or ping role)
|
-c, --packet-count COUNT Packet count (send or ping scenario)
|
||||||
-sd, --start-delay SECONDS Start delay (client or ping role)
|
-sd, --start-delay SECONDS Start delay (send or ping scenario)
|
||||||
--repeat N Repeat the run N times (client and ping
|
--repeat N Repeat the run N times (send and ping
|
||||||
roles)(0, which is the fault, to run just
|
scenario)(0, which is the fault, to run just
|
||||||
once)
|
once)
|
||||||
--repeat-delay SECONDS Delay, in seconds, between repeats
|
--repeat-delay SECONDS Delay, in seconds, between repeats
|
||||||
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
||||||
which is the fault, to send as fast as
|
which is the fault, to send as fast as
|
||||||
possible)
|
possible)
|
||||||
--linger Don't exit at the end of a run (server and
|
--linger Don't exit at the end of a run (receive and
|
||||||
pong roles)
|
pong scenarios)
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
@@ -71,19 +83,19 @@ using the ``--peripheral`` option. The address will be printed by the Peripheral
|
|||||||
it starts.
|
it starts.
|
||||||
|
|
||||||
Independently of whether the device is the Central or Peripheral, each device selects a
|
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.
|
must be compatible.
|
||||||
|
|
||||||
Device 1 mode | Device 2 mode
|
Device 1 scenario | Device 2 scenario
|
||||||
------------------|------------------
|
------------------|------------------
|
||||||
``gatt-client`` | ``gatt-server``
|
``gatt-client`` | ``gatt-server``
|
||||||
``l2cap-client`` | ``l2cap-server``
|
``l2cap-client`` | ``l2cap-server``
|
||||||
``rfcomm-client`` | ``rfcomm-server``
|
``rfcomm-client`` | ``rfcomm-server``
|
||||||
|
|
||||||
Device 1 role | Device 2 role
|
Device 1 scenario | Device 2 scenario
|
||||||
--------------|--------------
|
------------------|--------------
|
||||||
``sender`` | ``receiver``
|
``send`` | ``receive``
|
||||||
``ping`` | ``pong``
|
``ping`` | ``pong``
|
||||||
|
|
||||||
|
|
||||||
# Examples
|
# 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.
|
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||||
|
|
||||||
!!! example "GATT Throughput"
|
!!! 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:
|
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"
|
!!! example "Ping/Pong Latency"
|
||||||
In the first console/terminal:
|
In the first console/terminal:
|
||||||
```
|
```
|
||||||
$ bumble-bench --role pong peripheral usb:0
|
$ bumble-bench --scenario pong peripheral usb:0
|
||||||
```
|
```
|
||||||
|
|
||||||
In the second console/terminal:
|
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"
|
!!! 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
|
$ 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:
|
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:
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
EXAMPLES
|
EXAMPLES
|
||||||
========
|
========
|
||||||
|
|
||||||
The project includes a few simple example applications the illustrate some of the ways the library APIs can be used.
|
The project includes a few simple example applications to illustrate some of the ways the library APIs can be used.
|
||||||
These examples include:
|
These examples include:
|
||||||
|
|
||||||
## `battery_service.py`
|
## `battery_service.py`
|
||||||
@@ -25,6 +25,9 @@ An app that implements a virtual Bluetooth speaker that can receive audio.
|
|||||||
## `run_advertiser.py`
|
## `run_advertiser.py`
|
||||||
An app that runs a simple device that just advertises (BLE).
|
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`
|
## `run_classic_connect.py`
|
||||||
An app that connects to a Bluetooth Classic device and prints its services.
|
An app that connects to a Bluetooth Classic device and prints its services.
|
||||||
|
|
||||||
@@ -42,6 +45,9 @@ An app that connected to a device (BLE) and encrypts the connection.
|
|||||||
## `run_controller.py`
|
## `run_controller.py`
|
||||||
Creates two linked controllers, attaches one to a transport, and the other to a local host with a GATT server application. This can be used, for example, to attach a virtual controller to a native stack, like BlueZ on Linux, and use the native tools, like `bluetoothctl`, to scan and connect to the GATT server included in the example.
|
Creates two linked controllers, attaches one to a transport, and the other to a local host with a GATT server application. This can be used, for example, to attach a virtual controller to a native stack, like BlueZ on Linux, and use the native tools, like `bluetoothctl`, to scan and connect to the GATT server included in the example.
|
||||||
|
|
||||||
|
## `run_csis_servers.py`
|
||||||
|
Runs CSIS servers on two devices to form a Coordinated Set. **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_gatt_client_and_server.py`
|
## `run_gatt_client_and_server.py`
|
||||||
Runs a local GATT server and GATT client, connected to each other. The GATT client discovers and logs all the services and characteristics exposed by the GATT server
|
Runs a local GATT server and GATT client, connected to each other. The GATT client discovers and logs all the services and characteristics exposed by the GATT server
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ GETTING STARTED WITH BUMBLE
|
|||||||
|
|
||||||
# Prerequisites
|
# Prerequisites
|
||||||
|
|
||||||
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
|
You need Python 3.9 or above.
|
||||||
necessary (there may be some optional functionality that will not work on some platforms with
|
|
||||||
python 3.8).
|
|
||||||
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
||||||
for your platform.
|
for your platform.
|
||||||
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Some of the configurations that may be useful:
|
|||||||
|
|
||||||
See the [use cases page](use_cases/index.md) for more use cases.
|
See the [use cases page](use_cases/index.md) for more use cases.
|
||||||
|
|
||||||
The project is implemented in Python (Python >= 3.8 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
The project is implemented in Python (Python >= 3.9 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
PLATFORMS
|
PLATFORMS
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Most of the code included in the project should run on any platform that supports Python >= 3.8. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
Most of the code included in the project should run on any platform that supports Python >= 3.9. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
||||||
|
|
||||||
For platform-specific information, see the following pages:
|
For platform-specific information, see the following pages:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ channels:
|
|||||||
- conda-forge
|
- conda-forge
|
||||||
dependencies:
|
dependencies:
|
||||||
- pip=23
|
- pip=23
|
||||||
- python=3.8
|
- python=3.9
|
||||||
- pip:
|
- pip:
|
||||||
- --editable .[development,documentation,test]
|
- --editable .[development,documentation,test]
|
||||||
|
|||||||
95
examples/asha_sink.html
Normal file
95
examples/asha_sink.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<html data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
<script src="https://unpkg.com/pcm-player"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<span class="navbar-brand mb-0 h1">Bumble ASHA Sink</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-auto">
|
||||||
|
<button id="connect-audio" class="btn btn-danger" onclick="connectAudio()">Connect Audio</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Browser Gain</label>
|
||||||
|
<input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1"
|
||||||
|
onchange="setGain()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||||
|
<h3>Log</h3>
|
||||||
|
<code id="log" style="white-space: pre-line;"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let atResponseInput = document.getElementById("at_response")
|
||||||
|
let gainInput = document.getElementById('browser-gain')
|
||||||
|
let log = document.getElementById("log")
|
||||||
|
let socket = new WebSocket('ws://localhost:8888');
|
||||||
|
let sampleRate = 0;
|
||||||
|
let player;
|
||||||
|
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onopen = _ => {
|
||||||
|
log.textContent += 'SOCKET OPEN\n'
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
log.textContent += 'SOCKET CLOSED\n'
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
log.textContent += 'SOCKET ERROR\n'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
}
|
||||||
|
socket.onmessage = function (message) {
|
||||||
|
if (typeof message.data === 'string' || message.data instanceof String) {
|
||||||
|
log.textContent += `<-- ${event.data}\n`
|
||||||
|
} else {
|
||||||
|
// BINARY audio data.
|
||||||
|
if (player == null) return;
|
||||||
|
player.feed(message.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function connectAudio() {
|
||||||
|
player = new PCMPlayer({
|
||||||
|
inputCodec: 'Int16',
|
||||||
|
channels: 1,
|
||||||
|
sampleRate: 16000,
|
||||||
|
flushTime: 20,
|
||||||
|
});
|
||||||
|
player.volume(gainInput.value);
|
||||||
|
const button = document.getElementById("connect-audio")
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Audio Connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGain() {
|
||||||
|
if (player != null) {
|
||||||
|
player.volume(gainInput.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Bumble Aid Left",
|
"name": "Bumble Aid Left",
|
||||||
"address": "F1:F2:F3:F4:F5:F6",
|
"address": "F1:F2:F3:F4:F5:F6",
|
||||||
|
"identity_address_type": 1,
|
||||||
"keystore": "JsonKeyStore"
|
"keystore": "JsonKeyStore"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Bumble Aid Right",
|
"name": "Bumble Aid Right",
|
||||||
"address": "F7:F8:F9:FA:FB:FC",
|
"address": "F7:F8:F9:FA:FB:FC",
|
||||||
|
"identity_address_type": 1,
|
||||||
"keystore": "JsonKeyStore"
|
"keystore": "JsonKeyStore"
|
||||||
}
|
}
|
||||||
47
examples/mobly/bench/one_device_bench_test.py
Normal file
47
examples/mobly/bench/one_device_bench_test.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from mobly import base_test
|
||||||
|
from mobly import test_runner
|
||||||
|
from mobly.controllers import android_device
|
||||||
|
|
||||||
|
|
||||||
|
class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||||
|
|
||||||
|
def setup_class(self):
|
||||||
|
self.ads = self.register_controller(android_device)
|
||||||
|
self.dut = self.ads[0]
|
||||||
|
self.dut.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
|
||||||
|
def test_rfcomm_client_ping(self):
|
||||||
|
runner = self.dut.bench.runRfcommClient(
|
||||||
|
"ping", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_rfcomm_client_send(self):
|
||||||
|
runner = self.dut.bench.runRfcommClient(
|
||||||
|
"send", "DC:E5:5B:E5:51:2C", 100, 970, 0
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_l2cap_client_ping(self):
|
||||||
|
runner = self.dut.bench.runL2capClient(
|
||||||
|
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_l2cap_client_send(self):
|
||||||
|
runner = self.dut.bench.runL2capClient(
|
||||||
|
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_runner.main()
|
||||||
9
examples/mobly/bench/sample_config.yml
Normal file
9
examples/mobly/bench/sample_config.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
TestBeds:
|
||||||
|
- Name: BenchTestBed
|
||||||
|
Controllers:
|
||||||
|
AndroidDevice:
|
||||||
|
- serial: 37211FDJG000DJ
|
||||||
|
local_bt_address: 94:45:60:5E:03:B0
|
||||||
|
|
||||||
|
- serial: 23071FDEE001F7
|
||||||
|
local_bt_address: DC:E5:5B:E5:51:2C
|
||||||
38
examples/mobly/bench/two_devices_bench_test.py
Normal file
38
examples/mobly/bench/two_devices_bench_test.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from mobly import base_test
|
||||||
|
from mobly import test_runner
|
||||||
|
from mobly.controllers import android_device
|
||||||
|
|
||||||
|
|
||||||
|
class TwoDevicesBenchTest(base_test.BaseTestClass):
|
||||||
|
def setup_class(self):
|
||||||
|
self.ads = self.register_controller(android_device)
|
||||||
|
self.dut1 = self.ads[0]
|
||||||
|
self.dut1.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
self.dut2 = self.ads[1]
|
||||||
|
self.dut2.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
|
||||||
|
def test_rfcomm_client_send_receive(self):
|
||||||
|
print("### Starting Receiver")
|
||||||
|
receiver = self.dut2.bench.runRfcommServer("receive")
|
||||||
|
receiver_id = receiver["id"]
|
||||||
|
print("--- Receiver status:", receiver)
|
||||||
|
while not receiver["model"]["running"]:
|
||||||
|
print("--- Waiting for Receiver to be running...")
|
||||||
|
time.sleep(1)
|
||||||
|
receiver = self.dut2.bench.getRunner(receiver_id)
|
||||||
|
|
||||||
|
print("### Starting Sender")
|
||||||
|
sender = self.dut1.bench.runRfcommClient(
|
||||||
|
"send", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||||
|
)
|
||||||
|
print("--- Sender status:", sender)
|
||||||
|
|
||||||
|
print("--- Waiting for Sender to complete...")
|
||||||
|
sender_result = self.dut1.bench.waitForRunnerCompletion(sender["id"])
|
||||||
|
print("--- Sender result:", sender_result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_runner.main()
|
||||||
@@ -61,20 +61,23 @@ def codec_capabilities():
|
|||||||
return MediaCodecCapabilities(
|
return MediaCodecCapabilities(
|
||||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
media_codec_information=SbcMediaCodecInformation(
|
||||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||||
channel_modes=[
|
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||||
SBC_MONO_CHANNEL_MODE,
|
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||||
SBC_DUAL_CHANNEL_MODE,
|
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||||
SBC_STEREO_CHANNEL_MODE,
|
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||||
],
|
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||||
block_lengths=[4, 8, 12, 16],
|
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||||
subbands=[4, 8],
|
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||||
allocation_methods=[
|
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||||
SBC_SNR_ALLOCATION_METHOD,
|
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||||
],
|
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||||
|
| SbcMediaCodecInformation.Subbands.S_8,
|
||||||
|
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||||
|
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||||
minimum_bitpool_value=2,
|
minimum_bitpool_value=2,
|
||||||
maximum_bitpool_value=53,
|
maximum_bitpool_value=53,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ from bumble.avdtp import (
|
|||||||
Listener,
|
Listener,
|
||||||
)
|
)
|
||||||
from bumble.a2dp import (
|
from bumble.a2dp import (
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
|
||||||
make_audio_source_service_sdp_records,
|
make_audio_source_service_sdp_records,
|
||||||
A2DP_SBC_CODEC_TYPE,
|
A2DP_SBC_CODEC_TYPE,
|
||||||
SbcMediaCodecInformation,
|
SbcMediaCodecInformation,
|
||||||
@@ -59,12 +57,12 @@ def codec_capabilities():
|
|||||||
return MediaCodecCapabilities(
|
return MediaCodecCapabilities(
|
||||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||||
media_codec_information=SbcMediaCodecInformation.from_discrete_values(
|
media_codec_information=SbcMediaCodecInformation(
|
||||||
sampling_frequency=44100,
|
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||||
channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE,
|
channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||||
block_length=16,
|
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
|
||||||
subbands=8,
|
subbands=SbcMediaCodecInformation.Subbands.S_8,
|
||||||
allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD,
|
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||||
minimum_bitpool_value=2,
|
minimum_bitpool_value=2,
|
||||||
maximum_bitpool_value=53,
|
maximum_bitpool_value=53,
|
||||||
),
|
),
|
||||||
@@ -73,11 +71,9 @@ def codec_capabilities():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_avdtp_connection(read_function, protocol):
|
def on_avdtp_connection(read_function, protocol):
|
||||||
packet_source = SbcPacketSource(
|
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
|
||||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
|
||||||
)
|
|
||||||
packet_pump = MediaPacketPump(packet_source.packets)
|
packet_pump = MediaPacketPump(packet_source.packets)
|
||||||
protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
protocol.add_source(codec_capabilities(), packet_pump)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -97,11 +93,9 @@ async def stream_packets(read_function, protocol):
|
|||||||
print(f'### Selected sink: {sink.seid}')
|
print(f'### Selected sink: {sink.seid}')
|
||||||
|
|
||||||
# Stream the packets
|
# Stream the packets
|
||||||
packet_source = SbcPacketSource(
|
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
|
||||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
|
||||||
)
|
|
||||||
packet_pump = MediaPacketPump(packet_source.packets)
|
packet_pump = MediaPacketPump(packet_source.packets)
|
||||||
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
source = protocol.add_source(codec_capabilities(), packet_pump)
|
||||||
stream = await protocol.create_stream(source, sink)
|
stream = await protocol.create_stream(source, sink)
|
||||||
await stream.start()
|
await stream.start()
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|||||||
@@ -16,192 +16,104 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import websockets
|
||||||
|
|
||||||
from bumble import l2cap
|
from typing import Optional
|
||||||
|
|
||||||
|
from bumble import decoder
|
||||||
|
from bumble import gatt
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.device import Device
|
from bumble.device import Device, AdvertisingParameters
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.core import UUID
|
from bumble.profiles import asha
|
||||||
from bumble.gatt import Service, Characteristic, CharacteristicValue
|
|
||||||
|
ws_connection: Optional[websockets.WebSocketServerProtocol] = None
|
||||||
|
g722_decoder = decoder.G722Decoder()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
|
||||||
# Constants
|
del path
|
||||||
# -----------------------------------------------------------------------------
|
global ws_connection
|
||||||
ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
|
ws_connection = ws_client
|
||||||
ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID(
|
|
||||||
'6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties'
|
async for message in ws_client:
|
||||||
)
|
print(message)
|
||||||
ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC = UUID(
|
|
||||||
'f0d4de7e-4a88-476c-9d9f-1937b0996cc0', 'AudioControlPoint'
|
|
||||||
)
|
|
||||||
ASHA_AUDIO_STATUS_CHARACTERISTIC = UUID(
|
|
||||||
'38663f1a-e711-4cac-b641-326b56404837', 'AudioStatus'
|
|
||||||
)
|
|
||||||
ASHA_VOLUME_CHARACTERISTIC = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume')
|
|
||||||
ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID(
|
|
||||||
'2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
if len(sys.argv) != 4:
|
if len(sys.argv) != 3:
|
||||||
print(
|
print('Usage: python run_asha_sink.py <device-config> <transport-spec>')
|
||||||
'Usage: python run_asha_sink.py <device-config> <transport-spec> '
|
print('example: python run_asha_sink.py device1.json usb:0')
|
||||||
'<audio-file>'
|
|
||||||
)
|
|
||||||
print('example: python run_asha_sink.py device1.json usb:0 audio_out.g722')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
audio_out = open(sys.argv[3], 'wb')
|
|
||||||
|
|
||||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
device = Device.from_config_file_with_hci(
|
device = Device.from_config_file_with_hci(
|
||||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handler for audio control commands
|
def on_audio_packet(packet: bytes) -> None:
|
||||||
def on_audio_control_point_write(_connection, value):
|
global ws_connection
|
||||||
print('--- AUDIO CONTROL POINT Write:', value.hex())
|
if ws_connection:
|
||||||
opcode = value[0]
|
offset = 1
|
||||||
if opcode == 1:
|
while offset < len(packet):
|
||||||
# Start
|
pcm_data = g722_decoder.decode_frame(packet[offset : offset + 80])
|
||||||
audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]]
|
offset += 80
|
||||||
print(
|
asyncio.get_running_loop().create_task(ws_connection.send(pcm_data))
|
||||||
f'### START: codec={value[1]}, audio_type={audio_type}, '
|
else:
|
||||||
f'volume={value[3]}, otherstate={value[4]}'
|
logging.info("No active client")
|
||||||
)
|
|
||||||
elif opcode == 2:
|
|
||||||
print('### STOP')
|
|
||||||
elif opcode == 3:
|
|
||||||
print(f'### STATUS: connected={value[1]}')
|
|
||||||
|
|
||||||
# Respond with a status
|
asha_service = asha.AshaService(
|
||||||
asyncio.create_task(
|
capability=0,
|
||||||
device.notify_subscribers(audio_status_characteristic, force=True)
|
hisyncid=b'\x01\x02\x03\x04\x05\x06\x07\x08',
|
||||||
)
|
device=device,
|
||||||
|
audio_sink=on_audio_packet,
|
||||||
# Handler for volume control
|
|
||||||
def on_volume_write(_connection, value):
|
|
||||||
print('--- VOLUME Write:', value[0])
|
|
||||||
|
|
||||||
# Register an L2CAP CoC server
|
|
||||||
def on_coc(channel):
|
|
||||||
def on_data(data):
|
|
||||||
print('<<< Voice data received:', data.hex())
|
|
||||||
audio_out.write(data)
|
|
||||||
|
|
||||||
channel.sink = on_data
|
|
||||||
|
|
||||||
server = device.create_l2cap_server(
|
|
||||||
spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc
|
|
||||||
)
|
|
||||||
print(f'### LE_PSM_OUT = {server.psm}')
|
|
||||||
|
|
||||||
# Add the ASHA service to the GATT server
|
|
||||||
read_only_properties_characteristic = Characteristic(
|
|
||||||
ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
bytes(
|
|
||||||
[
|
|
||||||
0x01, # Version
|
|
||||||
0x00, # Device Capabilities [Left, Monaural]
|
|
||||||
0x01,
|
|
||||||
0x02,
|
|
||||||
0x03,
|
|
||||||
0x04,
|
|
||||||
0x05,
|
|
||||||
0x06,
|
|
||||||
0x07,
|
|
||||||
0x08, # HiSyncId
|
|
||||||
0x01, # Feature Map [LE CoC audio output streaming supported]
|
|
||||||
0x00,
|
|
||||||
0x00, # Render Delay
|
|
||||||
0x00,
|
|
||||||
0x00, # RFU
|
|
||||||
0x02,
|
|
||||||
0x00, # Codec IDs [G.722 at 16 kHz]
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
audio_control_point_characteristic = Characteristic(
|
|
||||||
ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
|
|
||||||
Characteristic.WRITEABLE,
|
|
||||||
CharacteristicValue(write=on_audio_control_point_write),
|
|
||||||
)
|
|
||||||
audio_status_characteristic = Characteristic(
|
|
||||||
ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
bytes([0]),
|
|
||||||
)
|
|
||||||
volume_characteristic = Characteristic(
|
|
||||||
ASHA_VOLUME_CHARACTERISTIC,
|
|
||||||
Characteristic.WRITE_WITHOUT_RESPONSE,
|
|
||||||
Characteristic.WRITEABLE,
|
|
||||||
CharacteristicValue(write=on_volume_write),
|
|
||||||
)
|
|
||||||
le_psm_out_characteristic = Characteristic(
|
|
||||||
ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
struct.pack('<H', server.psm),
|
|
||||||
)
|
|
||||||
device.add_service(
|
|
||||||
Service(
|
|
||||||
ASHA_SERVICE,
|
|
||||||
[
|
|
||||||
read_only_properties_characteristic,
|
|
||||||
audio_control_point_characteristic,
|
|
||||||
audio_status_characteristic,
|
|
||||||
volume_characteristic,
|
|
||||||
le_psm_out_characteristic,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
device.add_service(asha_service)
|
||||||
|
|
||||||
# Set the advertising data
|
# Set the advertising data
|
||||||
device.advertising_data = bytes(
|
advertising_data = (
|
||||||
AdvertisingData(
|
bytes(
|
||||||
[
|
AdvertisingData(
|
||||||
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')),
|
[
|
||||||
(AdvertisingData.FLAGS, bytes([0x06])),
|
(
|
||||||
(
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
bytes(device.name, 'utf-8'),
|
||||||
bytes(ASHA_SERVICE),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
|
||||||
bytes(ASHA_SERVICE)
|
|
||||||
+ bytes(
|
|
||||||
[
|
|
||||||
0x01, # Protocol Version
|
|
||||||
0x00, # Capability
|
|
||||||
0x01,
|
|
||||||
0x02,
|
|
||||||
0x03,
|
|
||||||
0x04, # Truncated HiSyncID
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
),
|
(AdvertisingData.FLAGS, bytes([0x06])),
|
||||||
]
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(gatt.GATT_ASHA_SERVICE),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
+ asha_service.get_advertising_data()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Go!
|
# Go!
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.create_advertising_set(
|
||||||
|
auto_restart=True,
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
primary_advertising_interval_min=100,
|
||||||
|
primary_advertising_interval_max=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await websockets.serve(ws_server, port=8888)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
logging.basicConfig(
|
||||||
|
level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper(),
|
||||||
|
format='%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
|
)
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -60,20 +60,23 @@ def codec_capabilities():
|
|||||||
return avdtp.MediaCodecCapabilities(
|
return avdtp.MediaCodecCapabilities(
|
||||||
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||||
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
|
media_codec_information=a2dp.SbcMediaCodecInformation(
|
||||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||||
channel_modes=[
|
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||||
a2dp.SBC_MONO_CHANNEL_MODE,
|
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||||
a2dp.SBC_DUAL_CHANNEL_MODE,
|
| a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||||
a2dp.SBC_STEREO_CHANNEL_MODE,
|
channel_mode=a2dp.SbcMediaCodecInformation.ChannelMode.MONO
|
||||||
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
|
| a2dp.SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||||
],
|
| a2dp.SbcMediaCodecInformation.ChannelMode.STEREO
|
||||||
block_lengths=[4, 8, 12, 16],
|
| a2dp.SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||||
subbands=[4, 8],
|
block_length=a2dp.SbcMediaCodecInformation.BlockLength.BL_4
|
||||||
allocation_methods=[
|
| a2dp.SbcMediaCodecInformation.BlockLength.BL_8
|
||||||
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
|
| a2dp.SbcMediaCodecInformation.BlockLength.BL_12
|
||||||
a2dp.SBC_SNR_ALLOCATION_METHOD,
|
| 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,
|
||||||
minimum_bitpool_value=2,
|
minimum_bitpool_value=2,
|
||||||
maximum_bitpool_value=53,
|
maximum_bitpool_value=53,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,13 +36,10 @@ from bumble.transport import open_transport_or_link
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
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>'
|
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
|
||||||
)
|
)
|
||||||
print(
|
print('example: run_cig_setup.py device1.json hci-socket:0 hci-socket:1')
|
||||||
'example: run_cig_setup.py device1.json'
|
|
||||||
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
@@ -65,18 +62,18 @@ async def main() -> None:
|
|||||||
advertising_set = await devices[0].create_advertising_set()
|
advertising_set = await devices[0].create_advertising_set()
|
||||||
|
|
||||||
connection = await devices[1].connect(
|
connection = await devices[1].connect(
|
||||||
devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
|
devices[0].random_address, own_address_type=OwnAddressType.RANDOM
|
||||||
)
|
)
|
||||||
|
|
||||||
cid_ids = [2, 3]
|
cid_ids = [2, 3]
|
||||||
cis_handles = await devices[1].setup_cig(
|
cis_handles = await devices[1].setup_cig(
|
||||||
cig_id=1,
|
cig_id=1,
|
||||||
cis_id=cid_ids,
|
cis_id=cid_ids,
|
||||||
sdu_interval=(10000, 0),
|
sdu_interval=(10000, 255),
|
||||||
framing=0,
|
framing=0,
|
||||||
max_sdu=(120, 0),
|
max_sdu=(120, 0),
|
||||||
retransmission_number=13,
|
retransmission_number=13,
|
||||||
max_transport_latency=(100, 0),
|
max_transport_latency=(100, 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_cis_request(
|
def on_cis_request(
|
||||||
|
|||||||
@@ -38,13 +38,10 @@ from bumble.transport import open_transport_or_link
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print(
|
||||||
'Usage: run_cig_setup.py <config-file>'
|
'Usage: run_csis_servers.py <config-file> '
|
||||||
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
|
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
|
||||||
)
|
)
|
||||||
print(
|
print('example: run_csis_servers.py device1.json ' 'hci-socket:0 hci-socket:1')
|
||||||
'example: run_cig_setup.py device1.json'
|
|
||||||
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ async def main() -> None:
|
|||||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=possibly-used-before-assignment
|
||||||
if device.host.number_of_supported_advertising_sets >= 2:
|
if device.host.number_of_supported_advertising_sets >= 2:
|
||||||
set2 = await device.create_advertising_set(
|
set2 = await device.create_advertising_set(
|
||||||
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
||||||
|
|||||||
107
examples/run_hap_server.py
Normal file
107
examples/run_hap_server.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble import att
|
||||||
|
from bumble.profiles.hap import (
|
||||||
|
HearingAccessService,
|
||||||
|
HearingAidFeatures,
|
||||||
|
HearingAidType,
|
||||||
|
PresetSynchronizationSupport,
|
||||||
|
IndependentPresets,
|
||||||
|
DynamicPresets,
|
||||||
|
WritablePresetsSupport,
|
||||||
|
PresetRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
server_features = HearingAidFeatures(
|
||||||
|
HearingAidType.MONAURAL_HEARING_AID,
|
||||||
|
PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED,
|
||||||
|
IndependentPresets.IDENTICAL_PRESET_RECORD,
|
||||||
|
DynamicPresets.PRESET_RECORDS_DOES_NOT_CHANGE,
|
||||||
|
WritablePresetsSupport.WRITABLE_PRESET_RECORDS_SUPPORTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
foo_preset = PresetRecord(1, "foo preset")
|
||||||
|
bar_preset = PresetRecord(50, "bar preset")
|
||||||
|
foobar_preset = PresetRecord(5, "foobar preset")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('Usage: run_hap_server.py <config-file> <transport-spec-for-device>')
|
||||||
|
print('example: run_hap_server.py device1.json pty:hci_pty')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
hap = HearingAccessService(
|
||||||
|
device, server_features, [foo_preset, bar_preset, foobar_preset]
|
||||||
|
)
|
||||||
|
device.add_service(hap)
|
||||||
|
|
||||||
|
advertising_data = bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
|
bytes('Bumble HearingAccessService', 'utf-8'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.FLAGS,
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_HOST_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_CONTROLLER_FLAG
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(HearingAccessService.UUID),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await device.create_advertising_set(
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
auto_restart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -57,6 +57,9 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
|||||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||||
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
raise RuntimeError("unknown active codec")
|
||||||
|
|
||||||
connection.abort_on(
|
connection.abort_on(
|
||||||
'disconnection',
|
'disconnection',
|
||||||
connection.device.send_command(
|
connection.device.send_command(
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ dependencies {
|
|||||||
implementation(libs.ui.graphics)
|
implementation(libs.ui.graphics)
|
||||||
implementation(libs.ui.tooling.preview)
|
implementation(libs.ui.tooling.preview)
|
||||||
implementation(libs.material3)
|
implementation(libs.material3)
|
||||||
|
implementation(libs.mobly.snippet)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BTBench"
|
android:theme="@style/Theme.BTBench"
|
||||||
>
|
>
|
||||||
|
<meta-data
|
||||||
|
android:name="mobly-snippets"
|
||||||
|
android:value="com.github.google.bumble.btbench.AutomationSnippet"/>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -35,5 +38,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<!-- <profileable android:shell="true"/>-->
|
<!-- <profileable android:shell="true"/>-->
|
||||||
</application>
|
</application>
|
||||||
|
<instrumentation
|
||||||
|
android:name="com.google.android.mobly.snippet.SnippetRunner"
|
||||||
|
android:targetPackage="com.github.google.bumble.btbench" />
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// Copyright 2024 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench;
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothManager;
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
|
|
||||||
|
import com.google.android.mobly.snippet.Snippet;
|
||||||
|
import com.google.android.mobly.snippet.rpc.Rpc;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.security.InvalidParameterException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
class Runner {
|
||||||
|
public UUID mId;
|
||||||
|
private final Mode mMode;
|
||||||
|
private final String mModeName;
|
||||||
|
private final String mScenario;
|
||||||
|
private final AppViewModel mModel;
|
||||||
|
|
||||||
|
Runner(Mode mode, String modeName, String scenario, AppViewModel model) {
|
||||||
|
this.mId = UUID.randomUUID();
|
||||||
|
this.mMode = mode;
|
||||||
|
this.mModeName = modeName;
|
||||||
|
this.mScenario = scenario;
|
||||||
|
this.mModel = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JSONObject toJson() throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.put("id", mId.toString());
|
||||||
|
result.put("mode", mModeName);
|
||||||
|
result.put("scenario", mScenario);
|
||||||
|
result.put("model", AutomationSnippet.modelToJson(mModel));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
mModel.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void waitForCompletion() {
|
||||||
|
mMode.waitForCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutomationSnippet implements Snippet {
|
||||||
|
private static final String TAG = "btbench.snippet";
|
||||||
|
private final BluetoothAdapter mBluetoothAdapter;
|
||||||
|
private final Context mContext;
|
||||||
|
private final ArrayList<Runner> mRunners = new ArrayList<>();
|
||||||
|
|
||||||
|
public AutomationSnippet() {
|
||||||
|
mContext = ApplicationProvider.getApplicationContext();
|
||||||
|
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
|
||||||
|
mBluetoothAdapter = bluetoothManager.getAdapter();
|
||||||
|
if (mBluetoothAdapter == null) {
|
||||||
|
throw new RuntimeException("bluetooth not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Runner runScenario(AppViewModel model, String mode, String scenario) {
|
||||||
|
Mode runnable;
|
||||||
|
switch (mode) {
|
||||||
|
case "rfcomm-client":
|
||||||
|
runnable = new RfcommClient(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rfcomm-server":
|
||||||
|
runnable = new RfcommServer(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "l2cap-client":
|
||||||
|
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "l2cap-server":
|
||||||
|
runnable = new L2capServer(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
runnable.run();
|
||||||
|
Runner runner = new Runner(runnable, mode, scenario, model);
|
||||||
|
mRunners.add(runner);
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) {
|
||||||
|
switch (scenario) {
|
||||||
|
case "send":
|
||||||
|
return new Sender(model, packetIO);
|
||||||
|
|
||||||
|
case "receive":
|
||||||
|
return new Receiver(model, packetIO);
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
return new Pinger(model, packetIO);
|
||||||
|
|
||||||
|
case "pong":
|
||||||
|
return new Ponger(model, packetIO);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JSONObject modelToJson(AppViewModel model) throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.put("status", model.getStatus());
|
||||||
|
result.put("running", model.getRunning());
|
||||||
|
result.put("l2cap_psm", model.getL2capPsm());
|
||||||
|
if (model.getStatus().equals("OK")) {
|
||||||
|
JSONObject stats = new JSONObject();
|
||||||
|
result.put("stats", stats);
|
||||||
|
stats.put("throughput", model.getThroughput());
|
||||||
|
JSONObject rttStats = new JSONObject();
|
||||||
|
stats.put("rtt", rttStats);
|
||||||
|
rttStats.put("compound", model.getStats());
|
||||||
|
} else {
|
||||||
|
result.put("last_error", model.getLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Runner findRunner(String runnerId) {
|
||||||
|
for (Runner runner : mRunners) {
|
||||||
|
if (runner.mId.toString().equals(runnerId)) {
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Run a scenario in RFComm Client mode")
|
||||||
|
public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount,
|
||||||
|
int packetSize, int packetInterval) throws JSONException {
|
||||||
|
assert (mBluetoothAdapter != null);
|
||||||
|
|
||||||
|
// We only support "send" and "ping" for this mode for now
|
||||||
|
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||||
|
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppViewModel model = new AppViewModel();
|
||||||
|
model.setPeerBluetoothAddress(peerBluetoothAddress);
|
||||||
|
model.setSenderPacketCount(packetCount);
|
||||||
|
model.setSenderPacketSize(packetSize);
|
||||||
|
model.setSenderPacketInterval(packetInterval);
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "rfcomm-client", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Run a scenario in RFComm Server mode")
|
||||||
|
public JSONObject runRfcommServer(String scenario) throws JSONException {
|
||||||
|
assert (mBluetoothAdapter != null);
|
||||||
|
|
||||||
|
// We only support "receive" and "pong" for this mode for now
|
||||||
|
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||||
|
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppViewModel model = new AppViewModel();
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "rfcomm-server", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Run a scenario in L2CAP Client mode")
|
||||||
|
public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm,
|
||||||
|
boolean use_2m_phy, int packetCount, int packetSize,
|
||||||
|
int packetInterval) throws JSONException {
|
||||||
|
assert (mBluetoothAdapter != null);
|
||||||
|
|
||||||
|
// We only support "send" and "ping" for this mode for now
|
||||||
|
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||||
|
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppViewModel model = new AppViewModel();
|
||||||
|
model.setPeerBluetoothAddress(peerBluetoothAddress);
|
||||||
|
model.setL2capPsm(psm);
|
||||||
|
model.setUse2mPhy(use_2m_phy);
|
||||||
|
model.setSenderPacketCount(packetCount);
|
||||||
|
model.setSenderPacketSize(packetSize);
|
||||||
|
model.setSenderPacketInterval(packetInterval);
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "l2cap-client", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Run a scenario in L2CAP Server mode")
|
||||||
|
public JSONObject runL2capServer(String scenario) throws JSONException {
|
||||||
|
assert (mBluetoothAdapter != null);
|
||||||
|
|
||||||
|
// We only support "receive" and "pong" for this mode for now
|
||||||
|
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||||
|
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppViewModel model = new AppViewModel();
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "l2cap-server", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Stop a Runner")
|
||||||
|
public JSONObject stopRunner(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
runner.stop();
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Wait for a Runner to complete")
|
||||||
|
public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
runner.waitForCompletion();
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Get a Runner by ID")
|
||||||
|
public JSONObject getRunner(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Get all Runners")
|
||||||
|
public JSONObject getRunners() throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
JSONArray runners = new JSONArray();
|
||||||
|
result.put("runners", runners);
|
||||||
|
for (Runner runner: mRunners) {
|
||||||
|
runners.put(runner.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2024 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
interface IoClient {
|
||||||
|
fun run()
|
||||||
|
fun abort()
|
||||||
|
}
|
||||||
@@ -29,10 +29,13 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
|
|||||||
class L2capClient(
|
class L2capClient(
|
||||||
private val viewModel: AppViewModel,
|
private val viewModel: AppViewModel,
|
||||||
private val bluetoothAdapter: BluetoothAdapter,
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
||||||
val address = viewModel.peerBluetoothAddress.take(17)
|
val address = viewModel.peerBluetoothAddress.take(17)
|
||||||
@@ -75,6 +78,7 @@ class L2capClient(
|
|||||||
) {
|
) {
|
||||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
if (viewModel.use2mPhy) {
|
if (viewModel.use2mPhy) {
|
||||||
|
Log.info("requesting 2M PHY")
|
||||||
gatt.setPreferredPhy(
|
gatt.setPreferredPhy(
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
@@ -95,7 +99,11 @@ class L2capClient(
|
|||||||
|
|
||||||
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||||
|
|
||||||
val client = SocketClient(viewModel, socket)
|
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||||
client.run()
|
socketClient!!.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketClient?.waitForCompletion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,11 +27,17 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.l2cap-server")
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
// Advertise so that the peer can find us and connect.
|
// Advertise so that the peer can find us and connect.
|
||||||
val callback = object: AdvertiseCallback() {
|
val callback = object : AdvertiseCallback() {
|
||||||
override fun onStartFailure(errorCode: Int) {
|
override fun onStartFailure(errorCode: Int) {
|
||||||
Log.warning("failed to start advertising: $errorCode")
|
Log.warning("failed to start advertising: $errorCode")
|
||||||
}
|
}
|
||||||
@@ -55,7 +61,14 @@ class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdap
|
|||||||
viewModel.l2capPsm = serverSocket.psm
|
viewModel.l2capPsm = serverSocket.psm
|
||||||
Log.info("psm = $serverSocket.psm")
|
Log.info("psm = $serverSocket.psm")
|
||||||
|
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||||
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
|
socketServer!!.run(
|
||||||
|
{ advertiser.stopAdvertising(callback) },
|
||||||
|
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketServer?.waitForCompletion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,12 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
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.focus.focusRequester
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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 PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
||||||
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
||||||
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val appViewModel = AppViewModel()
|
private val appViewModel = AppViewModel()
|
||||||
@@ -139,10 +146,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
MainView(
|
MainView(
|
||||||
appViewModel,
|
appViewModel,
|
||||||
::becomeDiscoverable,
|
::becomeDiscoverable,
|
||||||
::runRfcommClient,
|
::runScenario
|
||||||
::runRfcommServer,
|
|
||||||
::runL2capClient,
|
|
||||||
::runL2capServer,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,37 +163,54 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (packetSize > 0) {
|
if (packetSize > 0) {
|
||||||
appViewModel.senderPacketSize = packetSize
|
appViewModel.senderPacketSize = packetSize
|
||||||
}
|
}
|
||||||
|
val packetInterval = intent.getIntExtra("packet-interval", 0)
|
||||||
|
if (packetInterval > 0) {
|
||||||
|
appViewModel.senderPacketInterval = packetInterval
|
||||||
|
}
|
||||||
appViewModel.updateSenderPacketSizeSlider()
|
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 {
|
intent.getStringExtra("autostart")?.let {
|
||||||
when (it) {
|
when (it) {
|
||||||
"rfcomm-client" -> runRfcommClient()
|
"run-scenario" -> runScenario()
|
||||||
"rfcomm-server" -> runRfcommServer()
|
|
||||||
"l2cap-client" -> runL2capClient()
|
|
||||||
"l2cap-server" -> runL2capServer()
|
|
||||||
"scan-start" -> runScan(true)
|
"scan-start" -> runScan(true)
|
||||||
"stop-start" -> runScan(false)
|
"stop-start" -> runScan(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runRfcommClient() {
|
private fun runScenario() {
|
||||||
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
|
if (bluetoothAdapter == null) {
|
||||||
rfcommClient?.run()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runRfcommServer() {
|
val runner = when (appViewModel.mode) {
|
||||||
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
|
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
rfcommServer?.run()
|
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
}
|
L2CAP_CLIENT_MODE -> L2capClient(
|
||||||
|
appViewModel,
|
||||||
private fun runL2capClient() {
|
bluetoothAdapter!!,
|
||||||
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
|
baseContext,
|
||||||
l2capClient?.run()
|
::createIoClient
|
||||||
}
|
)
|
||||||
|
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
private fun runL2capServer() {
|
else -> throw IllegalStateException()
|
||||||
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
|
}
|
||||||
l2capServer?.run()
|
runner.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runScan(startScan: Boolean) {
|
private fun runScan(startScan: Boolean) {
|
||||||
@@ -197,6 +218,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
scan?.run(startScan)
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun becomeDiscoverable() {
|
fun becomeDiscoverable() {
|
||||||
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||||
@@ -210,10 +242,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun MainView(
|
fun MainView(
|
||||||
appViewModel: AppViewModel,
|
appViewModel: AppViewModel,
|
||||||
becomeDiscoverable: () -> Unit,
|
becomeDiscoverable: () -> Unit,
|
||||||
runRfcommClient: () -> Unit,
|
runScenario: () -> Unit,
|
||||||
runRfcommServer: () -> Unit,
|
|
||||||
runL2capClient: () -> Unit,
|
|
||||||
runL2capServer: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
BTBenchTheme {
|
BTBenchTheme {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -239,7 +268,9 @@ fun MainView(
|
|||||||
Text(text = "Peer Bluetooth Address")
|
Text(text = "Peer Bluetooth Address")
|
||||||
},
|
},
|
||||||
value = appViewModel.peerBluetoothAddress,
|
value = appViewModel.peerBluetoothAddress,
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
@@ -249,14 +280,18 @@ fun MainView(
|
|||||||
keyboardActions = KeyboardActions(onDone = {
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
})
|
}),
|
||||||
|
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
TextField(label = {
|
TextField(
|
||||||
Text(text = "L2CAP PSM")
|
label = {
|
||||||
},
|
Text(text = "L2CAP PSM")
|
||||||
|
},
|
||||||
value = appViewModel.l2capPsm.toString(),
|
value = appViewModel.l2capPsm.toString(),
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
@@ -271,7 +306,8 @@ fun MainView(
|
|||||||
keyboardActions = KeyboardActions(onDone = {
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
})
|
}),
|
||||||
|
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
Slider(
|
Slider(
|
||||||
@@ -290,6 +326,32 @@ fun MainView(
|
|||||||
)
|
)
|
||||||
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
||||||
Divider()
|
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(
|
ActionButton(
|
||||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||||
)
|
)
|
||||||
@@ -300,25 +362,78 @@ fun MainView(
|
|||||||
Text(text = "2M PHY")
|
Text(text = "2M PHY")
|
||||||
Spacer(modifier = Modifier.padding(start = 8.dp))
|
Spacer(modifier = Modifier.padding(start = 8.dp))
|
||||||
Switch(
|
Switch(
|
||||||
|
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
|
||||||
checked = appViewModel.use2mPhy,
|
checked = appViewModel.use2mPhy,
|
||||||
onCheckedChange = { appViewModel.use2mPhy = it }
|
onCheckedChange = { appViewModel.use2mPhy = it }
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
ActionButton(
|
Column(Modifier.selectableGroup()) {
|
||||||
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
listOf(
|
||||||
)
|
RFCOMM_CLIENT_MODE,
|
||||||
ActionButton(
|
RFCOMM_SERVER_MODE,
|
||||||
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
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 {
|
Row {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
text = "Start", onClick = runScenario, enabled = !appViewModel.running
|
||||||
)
|
)
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
|
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
@@ -328,6 +443,12 @@ fun MainView(
|
|||||||
Text(
|
Text(
|
||||||
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
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(
|
||||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||||
)
|
)
|
||||||
@@ -337,9 +458,8 @@ fun MainView(
|
|||||||
Text(
|
Text(
|
||||||
text = "Throughput: ${appViewModel.throughput}"
|
text = "Throughput: ${appViewModel.throughput}"
|
||||||
)
|
)
|
||||||
Divider()
|
Text(
|
||||||
ActionButton(
|
text = "Stats: ${appViewModel.stats}"
|
||||||
text = "Abort", onClick = appViewModel::abort, appViewModel.running
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||||
|
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
|
||||||
const val DEFAULT_PSM = 128
|
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() {
|
class AppViewModel : ViewModel() {
|
||||||
private var preferences: SharedPreferences? = null
|
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 peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||||
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||||
var use2mPhy by mutableStateOf(true)
|
var use2mPhy by mutableStateOf(true)
|
||||||
@@ -41,9 +56,11 @@ class AppViewModel : ViewModel() {
|
|||||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||||
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
||||||
|
var senderPacketInterval by mutableIntStateOf(DEFAULT_SENDER_PACKET_INTERVAL)
|
||||||
var packetsSent by mutableIntStateOf(0)
|
var packetsSent by mutableIntStateOf(0)
|
||||||
var packetsReceived by mutableIntStateOf(0)
|
var packetsReceived by mutableIntStateOf(0)
|
||||||
var throughput by mutableIntStateOf(0)
|
var throughput by mutableIntStateOf(0)
|
||||||
|
var stats by mutableStateOf("")
|
||||||
var running by mutableStateOf(false)
|
var running by mutableStateOf(false)
|
||||||
var aborter: (() -> Unit)? = null
|
var aborter: (() -> Unit)? = null
|
||||||
|
|
||||||
@@ -66,6 +83,21 @@ class AppViewModel : ViewModel() {
|
|||||||
senderPacketSize = savedSenderPacketSize
|
senderPacketSize = savedSenderPacketSize
|
||||||
}
|
}
|
||||||
updateSenderPacketSizeSlider()
|
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) {
|
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() {
|
fun abort() {
|
||||||
aborter?.let { it() }
|
aborter?.let { it() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ abstract class PacketSink {
|
|||||||
fun onPacket(packet: Packet) {
|
fun onPacket(packet: Packet) {
|
||||||
when (packet) {
|
when (packet) {
|
||||||
is ResetPacket -> onResetPacket()
|
is ResetPacket -> onResetPacket()
|
||||||
is AckPacket -> onAckPacket()
|
is AckPacket -> onAckPacket(packet)
|
||||||
is SequencePacket -> onSequencePacket(packet)
|
is SequencePacket -> onSequencePacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun onResetPacket()
|
abstract fun onResetPacket()
|
||||||
abstract fun onAckPacket()
|
abstract fun onAckPacket(packet: AckPacket)
|
||||||
abstract fun onSequencePacket(packet: SequencePacket)
|
abstract fun onSequencePacket(packet: SequencePacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Copyright 2024 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.pinger")
|
||||||
|
|
||||||
|
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||||
|
PacketSink() {
|
||||||
|
private val pingTimes: ArrayList<TimeSource.Monotonic.ValueTimeMark> = ArrayList()
|
||||||
|
private val rtts: ArrayList<Long> = ArrayList()
|
||||||
|
private val done = Semaphore(0)
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
|
||||||
|
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
|
Log.info("running")
|
||||||
|
|
||||||
|
Log.info("sending reset")
|
||||||
|
packetIO.sendPacket(ResetPacket())
|
||||||
|
|
||||||
|
val packetCount = viewModel.senderPacketCount
|
||||||
|
val packetSize = viewModel.senderPacketSize
|
||||||
|
|
||||||
|
val startTime = TimeSource.Monotonic.markNow()
|
||||||
|
for (i in 0..<packetCount) {
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||||
|
val delay = targetTime - now
|
||||||
|
if (delay.isPositive()) {
|
||||||
|
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||||
|
Thread.sleep(delay.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
pingTimes.add(TimeSource.Monotonic.markNow())
|
||||||
|
packetIO.sendPacket(
|
||||||
|
SequencePacket(
|
||||||
|
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
|
||||||
|
i,
|
||||||
|
ByteArray(packetSize - 6)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
viewModel.packetsSent = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the last ACK
|
||||||
|
Log.info("waiting for last ACK")
|
||||||
|
done.acquire()
|
||||||
|
Log.info("got last ACK")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
viewModel.packetsReceived += 1
|
||||||
|
if (packet.sequenceNumber < pingTimes.size) {
|
||||||
|
val rtt = (now - pingTimes[packet.sequenceNumber]).inWholeMilliseconds
|
||||||
|
rtts.add(rtt)
|
||||||
|
Log.info("received ACK ${packet.sequenceNumber}, RTT=$rtt")
|
||||||
|
} else {
|
||||||
|
Log.warning("received ACK with unexpected sequence ${packet.sequenceNumber}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||||
|
Log.info("last packet received")
|
||||||
|
val stats = "RTTs: min=${rtts.min()}, max=${rtts.max()}, avg=${rtts.sum() / rtts.size}"
|
||||||
|
Log.info(stats)
|
||||||
|
viewModel.stats = stats
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright 2024 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package com.github.google.bumble.btbench
|
||||||
|
|
||||||
|
import java.util.logging.Logger
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.receiver")
|
||||||
|
|
||||||
|
class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||||
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var expectedSequenceNumber: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = startTime
|
||||||
|
expectedSequenceNumber = 0
|
||||||
|
viewModel.packetsSent = 0
|
||||||
|
viewModel.packetsReceived = 0
|
||||||
|
viewModel.stats = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = now
|
||||||
|
viewModel.packetsReceived += 1
|
||||||
|
|
||||||
|
if (packet.sequenceNumber != expectedSequenceNumber) {
|
||||||
|
Log.warning("unexpected packet sequence number (expected ${expectedSequenceNumber}, got ${packet.sequenceNumber})")
|
||||||
|
}
|
||||||
|
expectedSequenceNumber += 1
|
||||||
|
|
||||||
|
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||||
|
viewModel.packetsSent += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import kotlin.time.TimeSource
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.receiver")
|
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 startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var bytesReceived = 0
|
private var bytesReceived = 0
|
||||||
@@ -29,6 +29,12 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
|||||||
packetIO.packetSink = this
|
packetIO.packetSink = this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {}
|
||||||
|
|
||||||
override fun onResetPacket() {
|
override fun onResetPacket() {
|
||||||
startTime = TimeSource.Monotonic.markNow()
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
lastPacketTime = startTime
|
lastPacketTime = startTime
|
||||||
@@ -36,9 +42,10 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
|||||||
viewModel.throughput = 0
|
viewModel.throughput = 0
|
||||||
viewModel.packetsSent = 0
|
viewModel.packetsSent = 0
|
||||||
viewModel.packetsReceived = 0
|
viewModel.packetsReceived = 0
|
||||||
|
viewModel.stats = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAckPacket() {
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,22 +16,30 @@ package com.github.google.bumble.btbench
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import java.io.IOException
|
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.rfcomm-client")
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
val address = viewModel.peerBluetoothAddress.take(17)
|
val address = viewModel.peerBluetoothAddress.take(17)
|
||||||
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
||||||
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
||||||
DEFAULT_RFCOMM_UUID
|
DEFAULT_RFCOMM_UUID
|
||||||
)
|
)
|
||||||
|
|
||||||
val client = SocketClient(viewModel, socket)
|
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||||
client.run()
|
socketClient!!.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketClient?.waitForCompletion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,27 @@ package com.github.google.bumble.btbench
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import java.io.IOException
|
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
||||||
"BumbleBench", DEFAULT_RFCOMM_UUID
|
"BumbleBench", DEFAULT_RFCOMM_UUID
|
||||||
)
|
)
|
||||||
|
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||||
|
socketServer!!.run({}, {})
|
||||||
|
}
|
||||||
|
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
override fun waitForCompletion() {
|
||||||
server.run({}, {})
|
socketServer?.waitForCompletion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,12 @@ import java.util.logging.Logger
|
|||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.sender")
|
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 startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var bytesSent = 0
|
private var bytesSent = 0
|
||||||
private val done = Semaphore(0)
|
private val done = Semaphore(0)
|
||||||
@@ -30,10 +33,12 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
|||||||
packetIO.packetSink = this
|
packetIO.packetSink = this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun run() {
|
override fun run() {
|
||||||
viewModel.packetsSent = 0
|
viewModel.clear()
|
||||||
viewModel.packetsReceived = 0
|
|
||||||
viewModel.throughput = 0
|
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
|
Log.info("running")
|
||||||
|
|
||||||
Log.info("sending reset")
|
Log.info("sending reset")
|
||||||
packetIO.sendPacket(ResetPacket())
|
packetIO.sendPacket(ResetPacket())
|
||||||
@@ -63,14 +68,14 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
|||||||
Log.info("got ACK")
|
Log.info("got ACK")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun abort() {
|
override fun abort() {
|
||||||
done.release()
|
done.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetPacket() {
|
override fun onResetPacket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAckPacket() {
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
Log.info("received ACK")
|
Log.info("received ACK")
|
||||||
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
||||||
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user