bumble 0.0.199__py3-none-any.whl → 0.0.201__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/player/player.py +608 -0
- bumble/apps/speaker/speaker.py +25 -27
- bumble/att.py +2 -2
- bumble/avc.py +1 -2
- bumble/avdtp.py +54 -97
- bumble/avrcp.py +48 -29
- bumble/codecs.py +214 -68
- bumble/device.py +19 -11
- bumble/hci.py +31 -5
- bumble/hfp.py +52 -48
- bumble/host.py +12 -0
- bumble/profiles/hap.py +27 -18
- bumble/rtp.py +110 -0
- bumble/transport/android_netsim.py +31 -11
- bumble/transport/grpc_protobuf/netsim/__init__.py +0 -0
- bumble/transport/grpc_protobuf/{common_pb2.py → netsim/common_pb2.py} +9 -8
- bumble/transport/grpc_protobuf/{common_pb2.pyi → netsim/common_pb2.pyi} +11 -5
- bumble/transport/grpc_protobuf/netsim/hci_packet_pb2.py +29 -0
- bumble/transport/grpc_protobuf/{hci_packet_pb2.pyi → netsim/hci_packet_pb2.pyi} +13 -7
- bumble/transport/grpc_protobuf/netsim/model_pb2.py +63 -0
- bumble/transport/grpc_protobuf/netsim/model_pb2.pyi +238 -0
- bumble/transport/grpc_protobuf/netsim/packet_streamer_pb2.py +32 -0
- bumble/transport/grpc_protobuf/{packet_streamer_pb2.pyi → netsim/packet_streamer_pb2.pyi} +6 -6
- bumble/transport/grpc_protobuf/{packet_streamer_pb2_grpc.py → netsim/packet_streamer_pb2_grpc.py} +7 -7
- bumble/transport/grpc_protobuf/netsim/startup_pb2.py +41 -0
- bumble/transport/grpc_protobuf/netsim/startup_pb2.pyi +76 -0
- bumble/transport/grpc_protobuf/netsim/startup_pb2_grpc.py +4 -0
- bumble/transport/grpc_protobuf/rootcanal/__init__.py +0 -0
- bumble/transport/grpc_protobuf/rootcanal/configuration_pb2.py +39 -0
- bumble/transport/grpc_protobuf/rootcanal/configuration_pb2.pyi +78 -0
- bumble/transport/grpc_protobuf/rootcanal/configuration_pb2_grpc.py +4 -0
- bumble/transport/pyusb.py +2 -1
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/METADATA +2 -2
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/RECORD +44 -34
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/WHEEL +1 -1
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/entry_points.txt +1 -0
- bumble/transport/grpc_protobuf/hci_packet_pb2.py +0 -28
- bumble/transport/grpc_protobuf/packet_streamer_pb2.py +0 -31
- bumble/transport/grpc_protobuf/startup_pb2.py +0 -32
- bumble/transport/grpc_protobuf/startup_pb2.pyi +0 -46
- /bumble/transport/grpc_protobuf/{common_pb2_grpc.py → netsim/common_pb2_grpc.py} +0 -0
- /bumble/transport/grpc_protobuf/{hci_packet_pb2_grpc.py → netsim/hci_packet_pb2_grpc.py} +0 -0
- /bumble/transport/grpc_protobuf/{startup_pb2_grpc.py → netsim/model_pb2_grpc.py} +0 -0
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/LICENSE +0 -0
- {bumble-0.0.199.dist-info → bumble-0.0.201.dist-info}/top_level.txt +0 -0
bumble/apps/controller_info.py
CHANGED
|
@@ -27,6 +27,7 @@ from bumble.colors import color
|
|
|
27
27
|
from bumble.core import name_or_number
|
|
28
28
|
from bumble.hci import (
|
|
29
29
|
map_null_terminated_utf8_string,
|
|
30
|
+
CodecID,
|
|
30
31
|
LeFeature,
|
|
31
32
|
HCI_SUCCESS,
|
|
32
33
|
HCI_VERSION_NAMES,
|
|
@@ -50,6 +51,8 @@ from bumble.hci import (
|
|
|
50
51
|
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
|
51
52
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
|
52
53
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
|
54
|
+
HCI_Read_Local_Supported_Codecs_Command,
|
|
55
|
+
HCI_Read_Local_Supported_Codecs_V2_Command,
|
|
53
56
|
HCI_Read_Local_Version_Information_Command,
|
|
54
57
|
)
|
|
55
58
|
from bumble.host import Host
|
|
@@ -168,6 +171,60 @@ async def get_acl_flow_control_info(host: Host) -> None:
|
|
|
168
171
|
)
|
|
169
172
|
|
|
170
173
|
|
|
174
|
+
# -----------------------------------------------------------------------------
|
|
175
|
+
async def get_codecs_info(host: Host) -> None:
|
|
176
|
+
print()
|
|
177
|
+
|
|
178
|
+
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
|
179
|
+
response = await host.send_command(
|
|
180
|
+
HCI_Read_Local_Supported_Codecs_V2_Command(), check_result=True
|
|
181
|
+
)
|
|
182
|
+
print(color('Codecs:', 'yellow'))
|
|
183
|
+
|
|
184
|
+
for codec_id, transport in zip(
|
|
185
|
+
response.return_parameters.standard_codec_ids,
|
|
186
|
+
response.return_parameters.standard_codec_transports,
|
|
187
|
+
):
|
|
188
|
+
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
|
189
|
+
transport
|
|
190
|
+
).name
|
|
191
|
+
codec_name = CodecID(codec_id).name
|
|
192
|
+
print(f' {codec_name} - {transport_name}')
|
|
193
|
+
|
|
194
|
+
for codec_id, transport in zip(
|
|
195
|
+
response.return_parameters.vendor_specific_codec_ids,
|
|
196
|
+
response.return_parameters.vendor_specific_codec_transports,
|
|
197
|
+
):
|
|
198
|
+
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
|
199
|
+
transport
|
|
200
|
+
).name
|
|
201
|
+
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
|
202
|
+
print(f' {company} / {codec_id & 0xFFFF} - {transport_name}')
|
|
203
|
+
|
|
204
|
+
if not response.return_parameters.standard_codec_ids:
|
|
205
|
+
print(' No standard codecs')
|
|
206
|
+
if not response.return_parameters.vendor_specific_codec_ids:
|
|
207
|
+
print(' No Vendor-specific codecs')
|
|
208
|
+
|
|
209
|
+
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
|
210
|
+
response = await host.send_command(
|
|
211
|
+
HCI_Read_Local_Supported_Codecs_Command(), check_result=True
|
|
212
|
+
)
|
|
213
|
+
print(color('Codecs (BR/EDR):', 'yellow'))
|
|
214
|
+
for codec_id in response.return_parameters.standard_codec_ids:
|
|
215
|
+
codec_name = CodecID(codec_id).name
|
|
216
|
+
print(f' {codec_name}')
|
|
217
|
+
|
|
218
|
+
for codec_id in response.return_parameters.vendor_specific_codec_ids:
|
|
219
|
+
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
|
220
|
+
print(f' {company} / {codec_id & 0xFFFF}')
|
|
221
|
+
|
|
222
|
+
if not response.return_parameters.standard_codec_ids:
|
|
223
|
+
print(' No standard codecs')
|
|
224
|
+
if not response.return_parameters.vendor_specific_codec_ids:
|
|
225
|
+
print(' No Vendor-specific codecs')
|
|
226
|
+
|
|
227
|
+
|
|
171
228
|
# -----------------------------------------------------------------------------
|
|
172
229
|
async def async_main(latency_probes, transport):
|
|
173
230
|
print('<<< connecting to HCI...')
|
|
@@ -220,6 +277,9 @@ async def async_main(latency_probes, transport):
|
|
|
220
277
|
# Print the ACL flow control info
|
|
221
278
|
await get_acl_flow_control_info(host)
|
|
222
279
|
|
|
280
|
+
# Get codec info
|
|
281
|
+
await get_codecs_info(host)
|
|
282
|
+
|
|
223
283
|
# Print the list of commands supported by the controller
|
|
224
284
|
print()
|
|
225
285
|
print(color('Supported Commands:', 'yellow'))
|
|
@@ -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
|