Compare commits

...

81 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
a00abd65b3 fix some linter warnings 2024-10-28 12:30:37 -07:00
Gilles Boccon-Gibod
f169ceaebb update linter and type checker 2024-10-28 12:30:32 -07:00
Gilles Boccon-Gibod
528af0d338 remove test for deprecated Python 3.8 and add 3.13 2024-10-28 12:29:21 -07:00
Gilles Boccon-Gibod
4b25eed869 Merge pull request #570 from google/gbg/bench-mobly-snippets
bench mobly snippets
2024-10-28 10:25:28 -07:00
Gilles Boccon-Gibod
fcd6bd7136 address PR comments 2024-10-28 10:13:55 -07:00
Gilles Boccon-Gibod
32642c5d7c Merge pull request #576 from google/gbg/netsim-device-info
update to new netsim proto with DeviceInfo
2024-10-25 04:43:00 -07:00
Gilles Boccon-Gibod
ff8b0c375d add support for netsim device info variant 2024-10-25 04:37:30 -07:00
Gilles Boccon-Gibod
ae0228aeb8 Merge pull request #578 from jmdietrich-gcx/add_missing_parameter_to_att_execute_write
Add missing parameter 'flags' to ATT_Execute_Write_Request PDU
2024-10-25 02:57:24 -07:00
Jan-Marcel Dietrich
5d2dac18c8 Add missing parameter 'flags' to ATT_Execute_Write_Request PDU
Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Table 3.36 shows that the
ATT_EXECUTE_WRITE_REQ PDU contains the parameter 'Flags' with size 1
octet, which allows to cancel all prepared writes (0x00) or to
immediately write all pending prepared values (0x01).
2024-10-24 15:08:10 +02:00
zxzxwu
d03fc14cfd Merge pull request #573 from ypomortsev/yegor
HFP: Fix reading multiple AT commands from a single data packet
2024-10-23 13:23:58 +08:00
Gilles Boccon-Gibod
ad7ce79bc4 use all caps for device kind 2024-10-22 16:30:46 -07:00
Yegor Pomortsev
c6bf27fd2c Fix test_hf_batched_response 2024-10-22 12:41:17 -07:00
Gilles Boccon-Gibod
7584daa3f9 update to new netsim proto with DeviceInfo 2024-10-22 11:48:42 -07:00
Yegor Pomortsev
654030e789 Add tests for batched HFP commands/responses; reformat 2024-10-21 16:32:20 -07:00
Gilles Boccon-Gibod
1de7d2cd6f Merge pull request #571 from google/gbg/a2dp-player
a2dp player
2024-10-19 07:40:43 -07:00
Gilles Boccon-Gibod
68db78c833 remove unnecessary import 2024-10-19 07:32:11 -07:00
Yegor Pomortsev
e1714c16cc HFP: Fix reading multiple AT commands from a single data packet
The `data` received in `_read_at` may have multiple commands.

This fixes `execute_command` timing out when waiting for an `OK`
response when it is in the same data buffer, e.g. during SLC
initialization: b'\r\n+BRSF: 3904\r\n\r\nOK\r\n'
2024-10-18 13:21:24 -07:00
Gilles Boccon-Gibod
0a20f14ea9 address PR comments 2024-10-15 15:26:19 -07:00
William Escande
23f46b36b3 HAP: wait for pairing event (#551) 2024-10-10 11:34:44 -07:00
Gilles Boccon-Gibod
009649abd1 remove unused section 2024-10-09 21:43:47 -07:00
Gilles Boccon-Gibod
855a007116 fix type checker 2024-10-09 21:34:03 -07:00
Gilles Boccon-Gibod
d064de35e0 wip 2024-10-09 21:34:03 -07:00
Gilles Boccon-Gibod
dab4d13303 wip 2024-10-09 21:34:03 -07:00
Gilles Boccon-Gibod
2bed50b353 add mobly to dev deps 2024-10-09 21:22:35 -07:00
Gilles Boccon-Gibod
1fe3778a74 adjust mypy excludes 2024-10-08 22:02:43 -07:00
Gilles Boccon-Gibod
f5443a9826 Merge pull request #564 from initializedd/fix-typo-in-comment
Fix typo in comment
2024-10-08 21:56:06 -07:00
zxzxwu
db723a5196 Merge pull request #569 from wpiet/cig-example-fix
examples/run_cig_setup: Fix the address type and CIG params
2024-10-05 17:20:32 +08:00
Gilles Boccon-Gibod
5e31bcf23d add mobly example 2024-10-04 18:17:56 -07:00
Gilles Boccon-Gibod
fe429cb2eb wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
c91695c23a wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
55f99e6887 wip 2024-10-04 18:13:31 -07:00
Gilles Boccon-Gibod
b190069f48 add snippets lib 2024-10-04 18:13:31 -07:00
Wojciech Pietraszewski
e16be1a8f4 docs/examples: Add run_cig_setup description
Adds basic information to the `examples` section of the documentation.
2024-10-02 18:51:11 +02:00
Wojciech Pietraszewski
2fa8075fb0 examples/run_cig_setup: Fix the address type and CIG params
Changes the address type used during connecting to what is actually advertised
by Device 0 by default (random address).

Amends CIG Parameters to use values allowed by the Core specification.

Updates the usage of the script and the example that show when executed incorrectly.
2024-10-02 18:50:57 +02:00
zxzxwu
566ca13d23 Merge pull request #561 from wpiet/csis-usage
run_csis_servers: Update `usage` and add docs entry
2024-10-01 17:34:22 +08:00
zxzxwu
e5666c0510 Merge pull request #565 from zxzxwu/controller
Add codecs info in controller info app
2024-10-01 15:35:32 +08:00
Slvr
46ec39ccfb avatar: update to latest version to correct flakiness (#568) 2024-10-01 00:19:41 -07:00
Slvr
eef418ae5f Collect Mobly logs (#566) 2024-09-30 15:21:19 -07:00
initializedd
9e663ad051 Clarify Bluetooth address comments 2024-09-30 18:39:02 +01:00
Wojciech Pietraszewski
f28eac4c14 docs/examples: Fix typo
Corrects the typo in the section's description.
2024-09-30 15:26:39 +02:00
Wojciech Pietraszewski
669bb3f3a8 run_csis_servers: Update usage and add docs entry
Amends the usage of the script and the example that show when executed incorrectly.
Adds basic information to the `examples` section of the documentation.
2024-09-30 15:25:40 +02:00
Josh Wu
347fe8b272 Add codecs info in controller info app 2024-09-30 00:24:06 +08:00
Gilles Boccon-Gibod
d56c4d0a11 Merge pull request #563 from initializedd/fix-whitespace
Fix whitespace
2024-09-27 18:31:59 -07:00
Gilles Boccon-Gibod
034140ccbd Merge pull request #562 from initializedd/support-netsim-ini-tmpdir-on-linux
Support netsim.ini tmpdir on linux
2024-09-27 14:08:47 -07:00
initializedd
35bef7d7b7 Fix whitespace 2024-09-27 20:49:30 +01:00
initializedd
d069708c79 Support netsim.ini tmpdir on linux 2024-09-27 19:25:49 +01:00
Slvr
bdba5c9d95 pyusb: check devices_in_use before removal (#559) 2024-09-24 13:40:58 -07:00
zxzxwu
ff659383f9 Merge pull request #556 from zxzxwu/default
Replace mutable default values
2024-09-21 16:18:13 +08:00
Josh Wu
f06a35713f Replace unsafe default values 2024-09-18 21:09:08 +08:00
Slvr
737abdc481 aics: make it a secondary service (#555)
* aics: make it a secondary service
---------

Co-authored-by: zxzxwu <92432172+zxzxwu@users.noreply.github.com>
2024-09-17 16:06:47 -07:00
Gilles Boccon-Gibod
02eb4d2e1c Merge pull request #554 from google/gbg/pair-app-fixes
add support for selecting the identity address
2024-09-15 17:21:06 -07:00
Gilles Boccon-Gibod
e7f9acb421 add support for selecting the identity address 2024-09-14 15:14:10 -07:00
zxzxwu
976e6cce57 Merge pull request #553 from zxzxwu/profiles
Remove att.CommonErrorCode
2024-09-14 18:12:27 +08:00
Josh Wu
dfdf37019c Remove att.CommonErrorCode 2024-09-14 00:50:19 +08:00
zxzxwu
56ca19600b Merge pull request #552 from zxzxwu/hci
Add some HCI commands and events
2024-09-13 13:46:19 +08:00
Slvr
cd9feeb455 Implement AICS (#547)
* aics: Implement AICS and tests
2024-09-12 08:51:20 -07:00
Josh Wu
f8e5b88be6 Add some HCI commands and events 2024-09-12 22:31:54 +08:00
Gilles Boccon-Gibod
0f71a63b42 Merge pull request #534 from hkpeprah/ford/bug/rtk-edimax-2
[Bug] Edimax BLE Dongle Fails After Teardown and Re-Instantiation
2024-09-11 09:00:02 -07:00
Ford Peprah
b7259abe3c Fix typing errors. 2024-09-10 10:59:46 -04:00
William Escande
00e660d410 Implement Hap support (#532)
* Implement Hap
2024-09-09 16:24:22 -07:00
Ford Peprah
88e3a2b87f Fix linting errors. 2024-09-09 10:54:01 -04:00
Ford Peprah
aa658418bc Bug: Edimax BLE Dongle Fails After Teardown and Re-Instantiation
This patch addresses an issue where the some RTK BLE dongles fail to perform
an HCI reset after the transport is torn down and re-instantiated. To address
that, we prevent crashing the background threads when invalid data comes in,
and time out if no response is received within a fixed amount of time. When
the timeout occurs, we retry the reset, and ultimately skip over reading the
local version information if that fails.
2024-09-09 10:54:01 -04:00
zxzxwu
ac0cff43b6 Merge pull request #549 from zxzxwu/gatt
Return ATT_Error_Response on rejected write request
2024-09-09 21:23:05 +08:00
Josh Wu
8051c23375 Return ATT_Error_Response on rejected write 2024-09-08 01:12:51 +08:00
zxzxwu
7b34bb4050 Merge pull request #548 from zxzxwu/gatt
Fix TBS Characteristics UUID
2024-09-05 22:58:50 +08:00
Josh Wu
fe38ab35cf Fix TBS Characteristics UUID 2024-09-05 17:59:28 +08:00
zxzxwu
65a9102ba1 Merge pull request #545 from google/pandora_l2cap_service
Pandora: refactor l2cap service
2024-09-05 11:14:03 +08:00
Charlie Boutier
1256170985 Pandora: refactor l2cap service
* Craft the PandoraChannel from the connection_handle and the source_cid
* Fix race on waitDisconnection
* Add ChannelContext to enable mutliple channels on the service
2024-09-03 15:52:40 +00:00
zxzxwu
4394a36332 Merge pull request #526 from Gopi-SB/oob
DH Key compute check modification for OOB Pairing
2024-08-29 16:56:45 +08:00
Gopi Sakshihally Bhuthaiah
0c9fd64434 DH Key compute check modification for OOB Pairing 2024-08-29 08:46:53 +00:00
Samad Atoro
2e99153696 Pandora: Add L2CAP service 2024-08-23 16:38:29 -07:00
zxzxwu
54a6f3cb36 Merge pull request #536 from zxzxwu/asha
Refactor ASHA service implementation and examples
2024-08-24 01:19:42 +08:00
Charlie Boutier
4a691c11d4 pyusb: allow to detect multiple usb dongle
Allow to detect multiple usb dongle by just provind the pid/vid
2024-08-23 08:22:43 -07:00
Gilles Boccon-Gibod
b114c0d63f Merge pull request #539 from google/gbg/usb-thread-hotfix
hotfix for usb transport
2024-08-22 22:36:24 -07:00
Josh Wu
04311b4c90 Refactor ASHA service and integrate with examples 2024-08-22 12:53:19 +08:00
Gopi Sakshihally Bhuthaiah
c44c89cc6e DH Key compute check modification for OOB Pairing 2024-08-13 02:10:41 +00:00
Gopi Sakshihally Bhuthaiah
414f2f3efb DH Key compute check modification for OOB Pairing 2024-08-12 07:00:51 +00:00
Gopi Sakshihally Bhuthaiah
ed00d44ae1 DH Key compute check modification for OOB Pairing 2024-08-09 17:30:19 +00:00
Gopi Sakshihally Bhuthaiah
b164524380 DH Key compute check modification for OOB Pairing 2024-08-08 10:31:26 +00:00
Gopi Sakshihally Bhuthaiah
29e4a843df DH Key compute check modification for OOB Pairing 2024-08-08 08:48:58 +00:00
Gopi Sakshihally Bhuthaiah
619b32d36e DH Key compute check modification for OOB Pairing 2024-08-08 07:53:05 +00:00
120 changed files with 7475 additions and 1518 deletions

View File

@@ -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:

View File

@@ -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/

View File

@@ -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:

View File

@@ -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,7 +632,34 @@ class Pong:
) )
) )
if packet_flags & PACKET_FLAG_LAST and not self.linger: if packet_flags & PACKET_FLAG_LAST:
if len(self.receive_times) >= 3:
# Show basic stats
intervals = [
self.receive_times[i + 1] - self.receive_times[i]
for i in range(len(self.receive_times) - 1)
]
log_stats('Packet intervals', intervals, 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(intervals)
interval_max = max(intervals)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count)
for i in range(bin_count)
]
for interval in intervals:
for i in reversed(range(bin_count)):
if interval >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
if not self.linger:
self.done.set() self.done.set()
async def run(self): async def run(self):
@@ -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
) )
if channel:
logging.info(color(f'@@@ Channel number = {channel}', 'cyan')) logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
if channel == 0: else:
logging.info(color('!!! No RFComm service with this UUID found', 'red')) 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()

View File

@@ -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'))

View File

@@ -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
View 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

View File

@@ -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))

View File

@@ -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,
), ),

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 :]

View File

@@ -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,6 +1341,18 @@ 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
): ):
if isinstance(
codec_capabilities.media_codec_information,
VendorSpecificMediaCodecInformation,
):
if (
codec_capabilities.media_codec_information.vendor_id
== vendor_id
and codec_capabilities.media_codec_information.codec_id
== codec_id
):
has_codec = True
else:
has_codec = True has_codec = True
if has_media_transport and has_codec: 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,

View File

@@ -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,7 +1904,13 @@ 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:
logger.debug("event not supported")
self.send_not_implemented_avrcp_response(
transaction_label, self.PduId.REGISTER_NOTIFICATION
)
return
if command.event_id == EventId.VOLUME_CHANGED: if command.event_id == EventId.VOLUME_CHANGED:
volume = await self.delegate.get_absolute_volume() volume = await self.delegate.get_absolute_volume()
response = RegisterNotificationResponse(VolumeChangedEvent(volume)) response = RegisterNotificationResponse(VolumeChangedEvent(volume))

View File

@@ -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)

View File

@@ -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

View File

@@ -1571,14 +1571,22 @@ class Connection(CompositeEventEmitter):
raise raise
def __str__(self): def __str__(self):
if self.transport == BT_LE_TRANSPORT:
return ( return (
f'Connection(handle=0x{self.handle:04X}, ' f'Connection(transport=LE, handle=0x{self.handle:04X}, '
f'role={self.role_name}, ' f'role={self.role_name}, '
f'self_address={self.self_address}, ' f'self_address={self.self_address}, '
f'self_resolvable_address={self.self_resolvable_address}, ' f'self_resolvable_address={self.self_resolvable_address}, '
f'peer_address={self.peer_address}, ' f'peer_address={self.peer_address}, '
f'peer_resolvable_address={self.peer_resolvable_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

View File

@@ -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(),
response = await host.send_command( check_result=True,
HCI_Read_Local_Version_Information_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
local_version = response.return_parameters local_version = response.return_parameters
logger.debug( logger.debug(

View File

@@ -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)

View File

@@ -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(

View File

@@ -942,11 +942,19 @@ class Server(EventEmitter):
) )
return return
try:
# Accept the value # Accept the value
await attribute.write_value(connection, request.attribute_value) await attribute.write_value(connection, request.attribute_value)
except ATT_Error as error:
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 # Done
self.send_response(connection, ATT_Write_Response()) 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):

View File

@@ -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(
[ [

View File

@@ -795,6 +795,7 @@ class HfProtocol(pyee.EventEmitter):
# Append to the read buffer. # Append to the read buffer.
self.read_buffer.extend(data) self.read_buffer.extend(data)
while self.read_buffer:
# Locate header and trailer. # Locate header and trailer.
header = self.read_buffer.find(b'\r\n') header = self.read_buffer.find(b'\r\n')
trailer = self.read_buffer.find(b'\r\n', header + 2) trailer = self.read_buffer.find(b'\r\n', header + 2)
@@ -817,7 +818,9 @@ class HfProtocol(pyee.EventEmitter):
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,6 +1247,7 @@ class AgProtocol(pyee.EventEmitter):
# Append to the read buffer. # Append to the read buffer.
self.read_buffer.extend(data) self.read_buffer.extend(data)
while self.read_buffer:
# Locate the trailer. # Locate the trailer.
trailer = self.read_buffer.find(b'\r') trailer = self.read_buffer.find(b'\r')
if trailer == -1: if trailer == -1:

View File

@@ -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:
try:
hci_packet = hci.HCI_Packet.from_bytes(packet) 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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
View 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
View 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
View 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])

View File

@@ -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]),
),
]
)
)

View File

@@ -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
View 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)
)

View File

@@ -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
View 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)})'
)

View File

@@ -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

View File

@@ -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,6 +1016,8 @@ class Session:
self.send_command(response) self.send_command(response)
def send_pairing_confirm_command(self) -> None: def send_pairing_confirm_command(self) -> None:
if self.pairing_method != PairingMethod.OOB:
self.r = crypto.r() self.r = crypto.r()
logger.debug(f'generated random: {self.r.hex()}') logger.debug(f'generated random: {self.r.hex()}')
@@ -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

View File

@@ -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

View File

@@ -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)

View 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)

View File

@@ -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

View 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)

View File

@@ -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: ...

File diff suppressed because one or more lines are too long

View 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: ...

View 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)

View File

@@ -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]

View File

@@ -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)

View 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)

View 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: ...

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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):

View File

@@ -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,18 +83,18 @@ 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``
@@ -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
``` ```

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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:

View File

@@ -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
View 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>

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

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

View File

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

View File

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

View File

@@ -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,
), ),

View File

@@ -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)

View File

@@ -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 = (
bytes(
AdvertisingData( AdvertisingData(
[ [
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')), (
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes(device.name, 'utf-8'),
),
(AdvertisingData.FLAGS, bytes([0x06])), (AdvertisingData.FLAGS, bytes([0x06])),
( (
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
bytes(ASHA_SERVICE), bytes(gatt.GATT_ASHA_SERVICE),
),
(
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
bytes(ASHA_SERVICE)
+ bytes(
[
0x01, # Protocol Version
0x00, # Capability
0x01,
0x02,
0x03,
0x04, # Truncated HiSyncID
]
),
), ),
] ]
) )
) )
+ 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())

View File

@@ -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,
), ),

View File

@@ -39,10 +39,7 @@ async def main() -> None:
'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(

View File

@@ -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...')

View File

@@ -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
View 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())

View File

@@ -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(

View File

@@ -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)

View File

@@ -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>

View File

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

View File

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

View File

@@ -29,10 +29,13 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
class L2capClient( 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()
} }
} }

View File

@@ -27,9 +27,15 @@ 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) {
@@ -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()
} }
} }

View File

@@ -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,
bluetoothAdapter!!,
baseContext,
::createIoClient
)
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
else -> throw IllegalStateException()
} }
runner.run()
private fun runL2capClient() {
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
l2capClient?.run()
}
private fun runL2capServer() {
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
l2capServer?.run()
} }
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(
label = {
Text(text = "L2CAP PSM") 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,
RFCOMM_SERVER_MODE,
L2CAP_CLIENT_MODE,
L2CAP_SERVER_MODE
).forEach { text ->
Row(
Modifier
.selectable(
selected = (text == appViewModel.mode),
onClick = { appViewModel.updateMode(text) },
role = Role.RadioButton
) )
ActionButton( .padding(horizontal = 16.dp),
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running 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
) )
} }
} }

View File

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

View File

@@ -27,10 +27,25 @@ val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF" const val DEFAULT_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() }
} }

View File

@@ -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)
} }

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ import kotlin.time.TimeSource
private val Log = Logger.getLogger("btbench.receiver") 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) {
} }

View File

@@ -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()
} }
} }

View File

@@ -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()
} }
} }

View File

@@ -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