bumble 0.0.198__py3-none-any.whl → 0.0.200__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bumble/_version.py +2 -2
- bumble/a2dp.py +502 -202
- bumble/apps/controller_info.py +60 -0
- bumble/apps/pair.py +32 -5
- bumble/apps/player/player.py +608 -0
- bumble/apps/speaker/speaker.py +25 -27
- bumble/att.py +57 -41
- bumble/avc.py +1 -2
- bumble/avdtp.py +56 -99
- bumble/avrcp.py +48 -29
- bumble/codecs.py +214 -68
- bumble/decoder.py +14 -10
- bumble/device.py +19 -11
- bumble/drivers/rtk.py +19 -5
- bumble/gatt.py +24 -19
- bumble/gatt_client.py +5 -25
- bumble/gatt_server.py +14 -6
- bumble/hci.py +298 -7
- bumble/hfp.py +52 -48
- bumble/host.py +28 -6
- bumble/pandora/__init__.py +3 -0
- bumble/pandora/l2cap.py +310 -0
- bumble/profiles/aics.py +520 -0
- bumble/profiles/asha.py +295 -0
- bumble/profiles/hap.py +674 -0
- bumble/profiles/vcp.py +5 -3
- bumble/rtp.py +110 -0
- bumble/smp.py +23 -4
- bumble/transport/android_netsim.py +3 -0
- bumble/transport/pyusb.py +20 -2
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/METADATA +2 -2
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/RECORD +36 -31
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/WHEEL +1 -1
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/entry_points.txt +1 -0
- bumble/profiles/asha_service.py +0 -193
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/LICENSE +0 -0
- {bumble-0.0.198.dist-info → bumble-0.0.200.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
# Imports
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
import asyncio
|
|
20
|
+
import asyncio.subprocess
|
|
21
|
+
import os
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Optional, Union
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
from bumble.a2dp import (
|
|
28
|
+
make_audio_source_service_sdp_records,
|
|
29
|
+
A2DP_SBC_CODEC_TYPE,
|
|
30
|
+
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
31
|
+
A2DP_NON_A2DP_CODEC_TYPE,
|
|
32
|
+
AacFrame,
|
|
33
|
+
AacParser,
|
|
34
|
+
AacPacketSource,
|
|
35
|
+
AacMediaCodecInformation,
|
|
36
|
+
SbcFrame,
|
|
37
|
+
SbcParser,
|
|
38
|
+
SbcPacketSource,
|
|
39
|
+
SbcMediaCodecInformation,
|
|
40
|
+
OpusPacket,
|
|
41
|
+
OpusParser,
|
|
42
|
+
OpusPacketSource,
|
|
43
|
+
OpusMediaCodecInformation,
|
|
44
|
+
)
|
|
45
|
+
from bumble.avrcp import Protocol as AvrcpProtocol
|
|
46
|
+
from bumble.avdtp import (
|
|
47
|
+
find_avdtp_service_with_connection,
|
|
48
|
+
AVDTP_AUDIO_MEDIA_TYPE,
|
|
49
|
+
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
|
50
|
+
MediaCodecCapabilities,
|
|
51
|
+
MediaPacketPump,
|
|
52
|
+
Protocol as AvdtpProtocol,
|
|
53
|
+
)
|
|
54
|
+
from bumble.colors import color
|
|
55
|
+
from bumble.core import (
|
|
56
|
+
AdvertisingData,
|
|
57
|
+
ConnectionError as BumbleConnectionError,
|
|
58
|
+
DeviceClass,
|
|
59
|
+
BT_BR_EDR_TRANSPORT,
|
|
60
|
+
)
|
|
61
|
+
from bumble.device import Connection, Device, DeviceConfiguration
|
|
62
|
+
from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant
|
|
63
|
+
from bumble.pairing import PairingConfig
|
|
64
|
+
from bumble.transport import open_transport
|
|
65
|
+
from bumble.utils import AsyncRunner
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -----------------------------------------------------------------------------
|
|
69
|
+
# Logging
|
|
70
|
+
# -----------------------------------------------------------------------------
|
|
71
|
+
logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------------------------------------------------------------
|
|
75
|
+
def a2dp_source_sdp_records():
|
|
76
|
+
service_record_handle = 0x00010001
|
|
77
|
+
return {
|
|
78
|
+
service_record_handle: make_audio_source_service_sdp_records(
|
|
79
|
+
service_record_handle
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -----------------------------------------------------------------------------
|
|
85
|
+
async def sbc_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
|
86
|
+
sbc_parser = SbcParser(read_function)
|
|
87
|
+
sbc_frame: SbcFrame
|
|
88
|
+
async for sbc_frame in sbc_parser.frames:
|
|
89
|
+
# We only need the first frame
|
|
90
|
+
print(color(f"SBC format: {sbc_frame}", "cyan"))
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
channel_mode = [
|
|
94
|
+
SbcMediaCodecInformation.ChannelMode.MONO,
|
|
95
|
+
SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL,
|
|
96
|
+
SbcMediaCodecInformation.ChannelMode.STEREO,
|
|
97
|
+
SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
|
98
|
+
][sbc_frame.channel_mode]
|
|
99
|
+
block_length = {
|
|
100
|
+
4: SbcMediaCodecInformation.BlockLength.BL_4,
|
|
101
|
+
8: SbcMediaCodecInformation.BlockLength.BL_8,
|
|
102
|
+
12: SbcMediaCodecInformation.BlockLength.BL_12,
|
|
103
|
+
16: SbcMediaCodecInformation.BlockLength.BL_16,
|
|
104
|
+
}[sbc_frame.block_count]
|
|
105
|
+
subbands = {
|
|
106
|
+
4: SbcMediaCodecInformation.Subbands.S_4,
|
|
107
|
+
8: SbcMediaCodecInformation.Subbands.S_8,
|
|
108
|
+
}[sbc_frame.subband_count]
|
|
109
|
+
allocation_method = [
|
|
110
|
+
SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
|
111
|
+
SbcMediaCodecInformation.AllocationMethod.SNR,
|
|
112
|
+
][sbc_frame.allocation_method]
|
|
113
|
+
return MediaCodecCapabilities(
|
|
114
|
+
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
115
|
+
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
|
116
|
+
media_codec_information=SbcMediaCodecInformation(
|
|
117
|
+
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.from_int(
|
|
118
|
+
sbc_frame.sampling_frequency
|
|
119
|
+
),
|
|
120
|
+
channel_mode=channel_mode,
|
|
121
|
+
block_length=block_length,
|
|
122
|
+
subbands=subbands,
|
|
123
|
+
allocation_method=allocation_method,
|
|
124
|
+
minimum_bitpool_value=2,
|
|
125
|
+
maximum_bitpool_value=40,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -----------------------------------------------------------------------------
|
|
131
|
+
async def aac_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
|
132
|
+
aac_parser = AacParser(read_function)
|
|
133
|
+
aac_frame: AacFrame
|
|
134
|
+
async for aac_frame in aac_parser.frames:
|
|
135
|
+
# We only need the first frame
|
|
136
|
+
print(color(f"AAC format: {aac_frame}", "cyan"))
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
sampling_frequency = AacMediaCodecInformation.SamplingFrequency.from_int(
|
|
140
|
+
aac_frame.sampling_frequency
|
|
141
|
+
)
|
|
142
|
+
channels = (
|
|
143
|
+
AacMediaCodecInformation.Channels.MONO
|
|
144
|
+
if aac_frame.channel_configuration == 1
|
|
145
|
+
else AacMediaCodecInformation.Channels.STEREO
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return MediaCodecCapabilities(
|
|
149
|
+
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
150
|
+
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
151
|
+
media_codec_information=AacMediaCodecInformation(
|
|
152
|
+
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
|
153
|
+
sampling_frequency=sampling_frequency,
|
|
154
|
+
channels=channels,
|
|
155
|
+
vbr=1,
|
|
156
|
+
bitrate=128000,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# -----------------------------------------------------------------------------
|
|
162
|
+
async def opus_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
|
163
|
+
opus_parser = OpusParser(read_function)
|
|
164
|
+
opus_packet: OpusPacket
|
|
165
|
+
async for opus_packet in opus_parser.packets:
|
|
166
|
+
# We only need the first packet
|
|
167
|
+
print(color(f"Opus format: {opus_packet}", "cyan"))
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
if opus_packet.channel_mode == OpusPacket.ChannelMode.MONO:
|
|
171
|
+
channel_mode = OpusMediaCodecInformation.ChannelMode.MONO
|
|
172
|
+
elif opus_packet.channel_mode == OpusPacket.ChannelMode.STEREO:
|
|
173
|
+
channel_mode = OpusMediaCodecInformation.ChannelMode.STEREO
|
|
174
|
+
else:
|
|
175
|
+
channel_mode = OpusMediaCodecInformation.ChannelMode.DUAL_MONO
|
|
176
|
+
|
|
177
|
+
if opus_packet.duration == 10:
|
|
178
|
+
frame_size = OpusMediaCodecInformation.FrameSize.FS_10MS
|
|
179
|
+
else:
|
|
180
|
+
frame_size = OpusMediaCodecInformation.FrameSize.FS_20MS
|
|
181
|
+
|
|
182
|
+
return MediaCodecCapabilities(
|
|
183
|
+
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
184
|
+
media_codec_type=A2DP_NON_A2DP_CODEC_TYPE,
|
|
185
|
+
media_codec_information=OpusMediaCodecInformation(
|
|
186
|
+
channel_mode=channel_mode,
|
|
187
|
+
sampling_frequency=OpusMediaCodecInformation.SamplingFrequency.SF_48000,
|
|
188
|
+
frame_size=frame_size,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# -----------------------------------------------------------------------------
|
|
194
|
+
class Player:
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
transport: str,
|
|
198
|
+
device_config: Optional[str],
|
|
199
|
+
authenticate: bool,
|
|
200
|
+
encrypt: bool,
|
|
201
|
+
) -> None:
|
|
202
|
+
self.transport = transport
|
|
203
|
+
self.device_config = device_config
|
|
204
|
+
self.authenticate = authenticate
|
|
205
|
+
self.encrypt = encrypt
|
|
206
|
+
self.avrcp_protocol: Optional[AvrcpProtocol] = None
|
|
207
|
+
self.done: Optional[asyncio.Event]
|
|
208
|
+
|
|
209
|
+
async def run(self, workload) -> None:
|
|
210
|
+
self.done = asyncio.Event()
|
|
211
|
+
try:
|
|
212
|
+
await self._run(workload)
|
|
213
|
+
except Exception as error:
|
|
214
|
+
print(color(f"!!! ERROR: {error}", "red"))
|
|
215
|
+
|
|
216
|
+
async def _run(self, workload) -> None:
|
|
217
|
+
async with await open_transport(self.transport) as (hci_source, hci_sink):
|
|
218
|
+
# Create a device
|
|
219
|
+
device_config = DeviceConfiguration()
|
|
220
|
+
if self.device_config:
|
|
221
|
+
device_config.load_from_file(self.device_config)
|
|
222
|
+
else:
|
|
223
|
+
device_config.name = "Bumble Player"
|
|
224
|
+
device_config.class_of_device = DeviceClass.pack_class_of_device(
|
|
225
|
+
DeviceClass.AUDIO_SERVICE_CLASS,
|
|
226
|
+
DeviceClass.AUDIO_VIDEO_MAJOR_DEVICE_CLASS,
|
|
227
|
+
DeviceClass.AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS,
|
|
228
|
+
)
|
|
229
|
+
device_config.keystore = "JsonKeyStore"
|
|
230
|
+
|
|
231
|
+
device_config.classic_enabled = True
|
|
232
|
+
device_config.le_enabled = False
|
|
233
|
+
device_config.le_simultaneous_enabled = False
|
|
234
|
+
device_config.classic_sc_enabled = False
|
|
235
|
+
device_config.classic_smp_enabled = False
|
|
236
|
+
device = Device.from_config_with_hci(device_config, hci_source, hci_sink)
|
|
237
|
+
|
|
238
|
+
# Setup the SDP records to expose the SRC service
|
|
239
|
+
device.sdp_service_records = a2dp_source_sdp_records()
|
|
240
|
+
|
|
241
|
+
# Setup AVRCP
|
|
242
|
+
self.avrcp_protocol = AvrcpProtocol()
|
|
243
|
+
self.avrcp_protocol.listen(device)
|
|
244
|
+
|
|
245
|
+
# Don't require MITM when pairing.
|
|
246
|
+
device.pairing_config_factory = lambda connection: PairingConfig(mitm=False)
|
|
247
|
+
|
|
248
|
+
# Start the controller
|
|
249
|
+
await device.power_on()
|
|
250
|
+
|
|
251
|
+
# Print some of the config/properties
|
|
252
|
+
print(
|
|
253
|
+
"Player Bluetooth Address:",
|
|
254
|
+
color(
|
|
255
|
+
device.public_address.to_string(with_type_qualifier=False),
|
|
256
|
+
"yellow",
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Listen for connections
|
|
261
|
+
device.on("connection", self.on_bluetooth_connection)
|
|
262
|
+
|
|
263
|
+
# Run the workload
|
|
264
|
+
try:
|
|
265
|
+
await workload(device)
|
|
266
|
+
except BumbleConnectionError as error:
|
|
267
|
+
if error.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
|
|
268
|
+
print(color("Connection already established", "blue"))
|
|
269
|
+
else:
|
|
270
|
+
print(color(f"Failed to connect: {error}", "red"))
|
|
271
|
+
|
|
272
|
+
# Wait until it is time to exit
|
|
273
|
+
assert self.done is not None
|
|
274
|
+
await asyncio.wait(
|
|
275
|
+
[hci_source.terminated, asyncio.ensure_future(self.done.wait())],
|
|
276
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def on_bluetooth_connection(self, connection: Connection) -> None:
|
|
280
|
+
print(color(f"--- Connected: {connection}", "cyan"))
|
|
281
|
+
connection.on("disconnection", self.on_bluetooth_disconnection)
|
|
282
|
+
|
|
283
|
+
def on_bluetooth_disconnection(self, reason) -> None:
|
|
284
|
+
print(color(f"--- Disconnected: {HCI_Constant.error_name(reason)}", "cyan"))
|
|
285
|
+
self.set_done()
|
|
286
|
+
|
|
287
|
+
async def connect(self, device: Device, address: str) -> Connection:
|
|
288
|
+
print(color(f"Connecting to {address}...", "green"))
|
|
289
|
+
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
|
290
|
+
|
|
291
|
+
# Request authentication
|
|
292
|
+
if self.authenticate:
|
|
293
|
+
print(color("*** Authenticating...", "blue"))
|
|
294
|
+
await connection.authenticate()
|
|
295
|
+
print(color("*** Authenticated", "blue"))
|
|
296
|
+
|
|
297
|
+
# Enable encryption
|
|
298
|
+
if self.encrypt:
|
|
299
|
+
print(color("*** Enabling encryption...", "blue"))
|
|
300
|
+
await connection.encrypt()
|
|
301
|
+
print(color("*** Encryption on", "blue"))
|
|
302
|
+
|
|
303
|
+
return connection
|
|
304
|
+
|
|
305
|
+
async def create_avdtp_protocol(self, connection: Connection) -> AvdtpProtocol:
|
|
306
|
+
# Look for an A2DP service
|
|
307
|
+
avdtp_version = await find_avdtp_service_with_connection(connection)
|
|
308
|
+
if not avdtp_version:
|
|
309
|
+
raise RuntimeError("no A2DP service found")
|
|
310
|
+
|
|
311
|
+
print(color(f"AVDTP Version: {avdtp_version}"))
|
|
312
|
+
|
|
313
|
+
# Create a client to interact with the remote device
|
|
314
|
+
return await AvdtpProtocol.connect(connection, avdtp_version)
|
|
315
|
+
|
|
316
|
+
async def stream_packets(
|
|
317
|
+
self,
|
|
318
|
+
protocol: AvdtpProtocol,
|
|
319
|
+
codec_type: int,
|
|
320
|
+
vendor_id: int,
|
|
321
|
+
codec_id: int,
|
|
322
|
+
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource],
|
|
323
|
+
codec_capabilities: MediaCodecCapabilities,
|
|
324
|
+
):
|
|
325
|
+
# Discover all endpoints on the remote device
|
|
326
|
+
endpoints = await protocol.discover_remote_endpoints()
|
|
327
|
+
for endpoint in endpoints:
|
|
328
|
+
print('@@@', endpoint)
|
|
329
|
+
|
|
330
|
+
# Select a sink
|
|
331
|
+
sink = protocol.find_remote_sink_by_codec(
|
|
332
|
+
AVDTP_AUDIO_MEDIA_TYPE, codec_type, vendor_id, codec_id
|
|
333
|
+
)
|
|
334
|
+
if sink is None:
|
|
335
|
+
print(color('!!! no compatible sink found', 'red'))
|
|
336
|
+
return
|
|
337
|
+
print(f'### Selected sink: {sink.seid}')
|
|
338
|
+
|
|
339
|
+
# Check if the sink supports delay reporting
|
|
340
|
+
delay_reporting = False
|
|
341
|
+
for capability in sink.capabilities:
|
|
342
|
+
if capability.service_category == AVDTP_DELAY_REPORTING_SERVICE_CATEGORY:
|
|
343
|
+
delay_reporting = True
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
def on_delay_report(delay: int):
|
|
347
|
+
print(color(f"*** DELAY REPORT: {delay}", "blue"))
|
|
348
|
+
|
|
349
|
+
# Adjust the codec capabilities for certain codecs
|
|
350
|
+
for capability in sink.capabilities:
|
|
351
|
+
if isinstance(capability, MediaCodecCapabilities):
|
|
352
|
+
if isinstance(
|
|
353
|
+
codec_capabilities.media_codec_information, SbcMediaCodecInformation
|
|
354
|
+
) and isinstance(
|
|
355
|
+
capability.media_codec_information, SbcMediaCodecInformation
|
|
356
|
+
):
|
|
357
|
+
codec_capabilities.media_codec_information.minimum_bitpool_value = (
|
|
358
|
+
capability.media_codec_information.minimum_bitpool_value
|
|
359
|
+
)
|
|
360
|
+
codec_capabilities.media_codec_information.maximum_bitpool_value = (
|
|
361
|
+
capability.media_codec_information.maximum_bitpool_value
|
|
362
|
+
)
|
|
363
|
+
print(color("Source media codec:", "green"), codec_capabilities)
|
|
364
|
+
|
|
365
|
+
# Stream the packets
|
|
366
|
+
packet_pump = MediaPacketPump(packet_source.packets)
|
|
367
|
+
source = protocol.add_source(codec_capabilities, packet_pump, delay_reporting)
|
|
368
|
+
source.on("delay_report", on_delay_report)
|
|
369
|
+
stream = await protocol.create_stream(source, sink)
|
|
370
|
+
await stream.start()
|
|
371
|
+
|
|
372
|
+
await packet_pump.wait_for_completion()
|
|
373
|
+
|
|
374
|
+
async def discover(self, device: Device) -> None:
|
|
375
|
+
@device.listens_to("inquiry_result")
|
|
376
|
+
def on_inquiry_result(
|
|
377
|
+
address: Address, class_of_device: int, data: AdvertisingData, rssi: int
|
|
378
|
+
) -> None:
|
|
379
|
+
(
|
|
380
|
+
service_classes,
|
|
381
|
+
major_device_class,
|
|
382
|
+
minor_device_class,
|
|
383
|
+
) = DeviceClass.split_class_of_device(class_of_device)
|
|
384
|
+
separator = "\n "
|
|
385
|
+
print(f">>> {color(address.to_string(False), 'yellow')}:")
|
|
386
|
+
print(f" Device Class (raw): {class_of_device:06X}")
|
|
387
|
+
major_class_name = DeviceClass.major_device_class_name(major_device_class)
|
|
388
|
+
print(" Device Major Class: " f"{major_class_name}")
|
|
389
|
+
minor_class_name = DeviceClass.minor_device_class_name(
|
|
390
|
+
major_device_class, minor_device_class
|
|
391
|
+
)
|
|
392
|
+
print(" Device Minor Class: " f"{minor_class_name}")
|
|
393
|
+
print(
|
|
394
|
+
" Device Services: "
|
|
395
|
+
f"{', '.join(DeviceClass.service_class_labels(service_classes))}"
|
|
396
|
+
)
|
|
397
|
+
print(f" RSSI: {rssi}")
|
|
398
|
+
if data.ad_structures:
|
|
399
|
+
print(f" {data.to_string(separator)}")
|
|
400
|
+
|
|
401
|
+
await device.start_discovery()
|
|
402
|
+
|
|
403
|
+
async def pair(self, device: Device, address: str) -> None:
|
|
404
|
+
print(color(f"Connecting to {address}...", "green"))
|
|
405
|
+
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
|
406
|
+
|
|
407
|
+
print(color("Pairing...", "magenta"))
|
|
408
|
+
await connection.authenticate()
|
|
409
|
+
print(color("Pairing completed", "magenta"))
|
|
410
|
+
self.set_done()
|
|
411
|
+
|
|
412
|
+
async def inquire(self, device: Device, address: str) -> None:
|
|
413
|
+
connection = await self.connect(device, address)
|
|
414
|
+
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
|
415
|
+
|
|
416
|
+
# Discover the remote endpoints
|
|
417
|
+
endpoints = await avdtp_protocol.discover_remote_endpoints()
|
|
418
|
+
print(f'@@@ Found {len(list(endpoints))} endpoints')
|
|
419
|
+
for endpoint in endpoints:
|
|
420
|
+
print('@@@', endpoint)
|
|
421
|
+
|
|
422
|
+
self.set_done()
|
|
423
|
+
|
|
424
|
+
async def play(
|
|
425
|
+
self,
|
|
426
|
+
device: Device,
|
|
427
|
+
address: Optional[str],
|
|
428
|
+
audio_format: str,
|
|
429
|
+
audio_file: str,
|
|
430
|
+
) -> None:
|
|
431
|
+
if audio_format == "auto":
|
|
432
|
+
if audio_file.endswith(".sbc"):
|
|
433
|
+
audio_format = "sbc"
|
|
434
|
+
elif audio_file.endswith(".aac") or audio_file.endswith(".adts"):
|
|
435
|
+
audio_format = "aac"
|
|
436
|
+
elif audio_file.endswith(".ogg"):
|
|
437
|
+
audio_format = "opus"
|
|
438
|
+
else:
|
|
439
|
+
raise ValueError("Unable to determine audio format from file extension")
|
|
440
|
+
|
|
441
|
+
device.on(
|
|
442
|
+
"connection",
|
|
443
|
+
lambda connection: AsyncRunner.spawn(on_connection(connection)),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def on_connection(connection: Connection):
|
|
447
|
+
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
|
448
|
+
|
|
449
|
+
with open(audio_file, 'rb') as input_file:
|
|
450
|
+
# NOTE: this should be using asyncio file reading, but blocking reads
|
|
451
|
+
# are good enough for this command line app.
|
|
452
|
+
async def read_audio_data(byte_count):
|
|
453
|
+
return input_file.read(byte_count)
|
|
454
|
+
|
|
455
|
+
# Obtain the codec capabilities from the stream
|
|
456
|
+
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource]
|
|
457
|
+
vendor_id = 0
|
|
458
|
+
codec_id = 0
|
|
459
|
+
if audio_format == "sbc":
|
|
460
|
+
codec_type = A2DP_SBC_CODEC_TYPE
|
|
461
|
+
codec_capabilities = await sbc_codec_capabilities(read_audio_data)
|
|
462
|
+
packet_source = SbcPacketSource(
|
|
463
|
+
read_audio_data,
|
|
464
|
+
avdtp_protocol.l2cap_channel.peer_mtu,
|
|
465
|
+
)
|
|
466
|
+
elif audio_format == "aac":
|
|
467
|
+
codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE
|
|
468
|
+
codec_capabilities = await aac_codec_capabilities(read_audio_data)
|
|
469
|
+
packet_source = AacPacketSource(
|
|
470
|
+
read_audio_data,
|
|
471
|
+
avdtp_protocol.l2cap_channel.peer_mtu,
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
codec_type = A2DP_NON_A2DP_CODEC_TYPE
|
|
475
|
+
vendor_id = OpusMediaCodecInformation.VENDOR_ID
|
|
476
|
+
codec_id = OpusMediaCodecInformation.CODEC_ID
|
|
477
|
+
codec_capabilities = await opus_codec_capabilities(read_audio_data)
|
|
478
|
+
packet_source = OpusPacketSource(
|
|
479
|
+
read_audio_data,
|
|
480
|
+
avdtp_protocol.l2cap_channel.peer_mtu,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Rewind to the start
|
|
484
|
+
input_file.seek(0)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
await self.stream_packets(
|
|
488
|
+
avdtp_protocol,
|
|
489
|
+
codec_type,
|
|
490
|
+
vendor_id,
|
|
491
|
+
codec_id,
|
|
492
|
+
packet_source,
|
|
493
|
+
codec_capabilities,
|
|
494
|
+
)
|
|
495
|
+
except Exception as error:
|
|
496
|
+
print(color(f"!!! Error while streaming: {error}", "red"))
|
|
497
|
+
|
|
498
|
+
self.set_done()
|
|
499
|
+
|
|
500
|
+
if address:
|
|
501
|
+
await self.connect(device, address)
|
|
502
|
+
else:
|
|
503
|
+
print(color("Waiting for an incoming connection...", "magenta"))
|
|
504
|
+
|
|
505
|
+
def set_done(self) -> None:
|
|
506
|
+
if self.done:
|
|
507
|
+
self.done.set()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# -----------------------------------------------------------------------------
|
|
511
|
+
def create_player(context) -> Player:
|
|
512
|
+
return Player(
|
|
513
|
+
transport=context.obj["hci_transport"],
|
|
514
|
+
device_config=context.obj["device_config"],
|
|
515
|
+
authenticate=context.obj["authenticate"],
|
|
516
|
+
encrypt=context.obj["encrypt"],
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# -----------------------------------------------------------------------------
|
|
521
|
+
@click.group()
|
|
522
|
+
@click.pass_context
|
|
523
|
+
@click.option("--hci-transport", metavar="TRANSPORT", required=True)
|
|
524
|
+
@click.option("--device-config", metavar="FILENAME", help="Device configuration file")
|
|
525
|
+
@click.option(
|
|
526
|
+
"--authenticate",
|
|
527
|
+
is_flag=True,
|
|
528
|
+
help="Request authentication when connecting",
|
|
529
|
+
default=False,
|
|
530
|
+
)
|
|
531
|
+
@click.option(
|
|
532
|
+
"--encrypt", is_flag=True, help="Request encryption when connecting", default=True
|
|
533
|
+
)
|
|
534
|
+
def player_cli(ctx, hci_transport, device_config, authenticate, encrypt):
|
|
535
|
+
ctx.ensure_object(dict)
|
|
536
|
+
ctx.obj["hci_transport"] = hci_transport
|
|
537
|
+
ctx.obj["device_config"] = device_config
|
|
538
|
+
ctx.obj["authenticate"] = authenticate
|
|
539
|
+
ctx.obj["encrypt"] = encrypt
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@player_cli.command("discover")
|
|
543
|
+
@click.pass_context
|
|
544
|
+
def discover(context):
|
|
545
|
+
"""Discover speakers or headphones"""
|
|
546
|
+
player = create_player(context)
|
|
547
|
+
asyncio.run(player.run(player.discover))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@player_cli.command("inquire")
|
|
551
|
+
@click.pass_context
|
|
552
|
+
@click.argument(
|
|
553
|
+
"address",
|
|
554
|
+
metavar="ADDRESS",
|
|
555
|
+
)
|
|
556
|
+
def inquire(context, address):
|
|
557
|
+
"""Connect to a speaker or headphone and inquire about their capabilities"""
|
|
558
|
+
player = create_player(context)
|
|
559
|
+
asyncio.run(player.run(lambda device: player.inquire(device, address)))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@player_cli.command("pair")
|
|
563
|
+
@click.pass_context
|
|
564
|
+
@click.argument(
|
|
565
|
+
"address",
|
|
566
|
+
metavar="ADDRESS",
|
|
567
|
+
)
|
|
568
|
+
def pair(context, address):
|
|
569
|
+
"""Pair with a speaker or headphone"""
|
|
570
|
+
player = create_player(context)
|
|
571
|
+
asyncio.run(player.run(lambda device: player.pair(device, address)))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@player_cli.command("play")
|
|
575
|
+
@click.pass_context
|
|
576
|
+
@click.option(
|
|
577
|
+
"--connect",
|
|
578
|
+
"address",
|
|
579
|
+
metavar="ADDRESS",
|
|
580
|
+
help="Address or name to connect to",
|
|
581
|
+
)
|
|
582
|
+
@click.option(
|
|
583
|
+
"-f",
|
|
584
|
+
"--audio-format",
|
|
585
|
+
type=click.Choice(["auto", "sbc", "aac", "opus"]),
|
|
586
|
+
help="Audio file format (use 'auto' to infer the format from the file extension)",
|
|
587
|
+
default="auto",
|
|
588
|
+
)
|
|
589
|
+
@click.argument("audio_file")
|
|
590
|
+
def play(context, address, audio_format, audio_file):
|
|
591
|
+
"""Play and audio file"""
|
|
592
|
+
player = create_player(context)
|
|
593
|
+
asyncio.run(
|
|
594
|
+
player.run(
|
|
595
|
+
lambda device: player.play(device, address, audio_format, audio_file)
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# -----------------------------------------------------------------------------
|
|
601
|
+
def main():
|
|
602
|
+
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
|
603
|
+
player_cli()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# -----------------------------------------------------------------------------
|
|
607
|
+
if __name__ == "__main__":
|
|
608
|
+
main() # pylint: disable=no-value-for-parameter
|
bumble/apps/speaker/speaker.py
CHANGED
|
@@ -44,25 +44,18 @@ from bumble.avdtp import (
|
|
|
44
44
|
AVDTP_AUDIO_MEDIA_TYPE,
|
|
45
45
|
Listener,
|
|
46
46
|
MediaCodecCapabilities,
|
|
47
|
-
MediaPacket,
|
|
48
47
|
Protocol,
|
|
49
48
|
)
|
|
50
49
|
from bumble.a2dp import (
|
|
51
|
-
MPEG_2_AAC_LC_OBJECT_TYPE,
|
|
52
50
|
make_audio_sink_service_sdp_records,
|
|
53
51
|
A2DP_SBC_CODEC_TYPE,
|
|
54
52
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
55
|
-
SBC_MONO_CHANNEL_MODE,
|
|
56
|
-
SBC_DUAL_CHANNEL_MODE,
|
|
57
|
-
SBC_SNR_ALLOCATION_METHOD,
|
|
58
|
-
SBC_LOUDNESS_ALLOCATION_METHOD,
|
|
59
|
-
SBC_STEREO_CHANNEL_MODE,
|
|
60
|
-
SBC_JOINT_STEREO_CHANNEL_MODE,
|
|
61
53
|
SbcMediaCodecInformation,
|
|
62
54
|
AacMediaCodecInformation,
|
|
63
55
|
)
|
|
64
56
|
from bumble.utils import AsyncRunner
|
|
65
57
|
from bumble.codecs import AacAudioRtpPacket
|
|
58
|
+
from bumble.rtp import MediaPacket
|
|
66
59
|
|
|
67
60
|
|
|
68
61
|
# -----------------------------------------------------------------------------
|
|
@@ -93,7 +86,7 @@ class AudioExtractor:
|
|
|
93
86
|
# -----------------------------------------------------------------------------
|
|
94
87
|
class AacAudioExtractor:
|
|
95
88
|
def extract_audio(self, packet: MediaPacket) -> bytes:
|
|
96
|
-
return AacAudioRtpPacket(packet.payload).to_adts()
|
|
89
|
+
return AacAudioRtpPacket.from_bytes(packet.payload).to_adts()
|
|
97
90
|
|
|
98
91
|
|
|
99
92
|
# -----------------------------------------------------------------------------
|
|
@@ -451,10 +444,12 @@ class Speaker:
|
|
|
451
444
|
return MediaCodecCapabilities(
|
|
452
445
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
453
446
|
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
454
|
-
media_codec_information=AacMediaCodecInformation
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
447
|
+
media_codec_information=AacMediaCodecInformation(
|
|
448
|
+
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
|
449
|
+
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
|
450
|
+
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
|
451
|
+
channels=AacMediaCodecInformation.Channels.MONO
|
|
452
|
+
| AacMediaCodecInformation.Channels.STEREO,
|
|
458
453
|
vbr=1,
|
|
459
454
|
bitrate=256000,
|
|
460
455
|
),
|
|
@@ -464,20 +459,23 @@ class Speaker:
|
|
|
464
459
|
return MediaCodecCapabilities(
|
|
465
460
|
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
466
461
|
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
|
467
|
-
media_codec_information=SbcMediaCodecInformation
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
462
|
+
media_codec_information=SbcMediaCodecInformation(
|
|
463
|
+
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
|
464
|
+
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
|
465
|
+
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
|
466
|
+
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
|
467
|
+
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
|
468
|
+
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
|
469
|
+
| SbcMediaCodecInformation.ChannelMode.STEREO
|
|
470
|
+
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
|
471
|
+
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
|
472
|
+
| SbcMediaCodecInformation.BlockLength.BL_8
|
|
473
|
+
| SbcMediaCodecInformation.BlockLength.BL_12
|
|
474
|
+
| SbcMediaCodecInformation.BlockLength.BL_16,
|
|
475
|
+
subbands=SbcMediaCodecInformation.Subbands.S_4
|
|
476
|
+
| SbcMediaCodecInformation.Subbands.S_8,
|
|
477
|
+
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
|
478
|
+
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
|
481
479
|
minimum_bitpool_value=2,
|
|
482
480
|
maximum_bitpool_value=53,
|
|
483
481
|
),
|