bumble 0.0.220__py3-none-any.whl → 0.0.221__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 +5 -5
- bumble/apps/auracast.py +746 -473
- bumble/apps/bench.py +4 -5
- bumble/apps/console.py +5 -10
- bumble/apps/controller_info.py +12 -7
- bumble/apps/controller_loopback.py +1 -2
- bumble/apps/device_info.py +2 -3
- bumble/apps/gatt_dump.py +0 -1
- bumble/apps/lea_unicast/app.py +1 -1
- bumble/apps/pair.py +49 -46
- bumble/apps/pandora_server.py +2 -2
- bumble/apps/player/player.py +10 -12
- bumble/apps/rfcomm_bridge.py +10 -11
- bumble/apps/scan.py +1 -3
- bumble/apps/speaker/speaker.py +3 -4
- bumble/at.py +4 -5
- bumble/att.py +91 -25
- bumble/audio/io.py +5 -3
- bumble/avc.py +1 -2
- bumble/avctp.py +2 -3
- bumble/avdtp.py +53 -57
- bumble/avrcp.py +25 -27
- bumble/codecs.py +15 -15
- bumble/colors.py +7 -8
- bumble/controller.py +663 -391
- bumble/core.py +41 -49
- bumble/crypto/__init__.py +2 -1
- bumble/crypto/builtin.py +2 -8
- bumble/data_types.py +2 -1
- bumble/decoder.py +2 -3
- bumble/device.py +171 -142
- bumble/drivers/__init__.py +3 -2
- bumble/drivers/intel.py +6 -8
- bumble/drivers/rtk.py +1 -1
- bumble/gatt.py +9 -9
- bumble/gatt_adapters.py +6 -6
- bumble/gatt_client.py +110 -60
- bumble/gatt_server.py +209 -139
- bumble/hci.py +87 -74
- bumble/helpers.py +5 -5
- bumble/hfp.py +27 -26
- bumble/hid.py +9 -9
- bumble/host.py +44 -50
- bumble/keys.py +17 -17
- bumble/l2cap.py +1015 -218
- bumble/link.py +26 -159
- bumble/ll.py +200 -0
- bumble/pairing.py +14 -15
- bumble/pandora/__init__.py +2 -2
- bumble/pandora/device.py +6 -4
- bumble/pandora/host.py +19 -10
- bumble/pandora/l2cap.py +8 -9
- bumble/pandora/security.py +18 -16
- bumble/pandora/utils.py +4 -4
- bumble/profiles/aics.py +6 -8
- bumble/profiles/ams.py +3 -5
- bumble/profiles/ancs.py +11 -11
- bumble/profiles/ascs.py +5 -5
- bumble/profiles/asha.py +10 -9
- bumble/profiles/bass.py +9 -3
- bumble/profiles/battery_service.py +1 -2
- bumble/profiles/csip.py +9 -10
- bumble/profiles/device_information_service.py +16 -17
- bumble/profiles/gap.py +3 -4
- bumble/profiles/gatt_service.py +0 -1
- bumble/profiles/gmap.py +12 -13
- bumble/profiles/hap.py +3 -3
- bumble/profiles/heart_rate_service.py +7 -8
- bumble/profiles/le_audio.py +1 -1
- bumble/profiles/mcp.py +28 -28
- bumble/profiles/pacs.py +13 -17
- bumble/profiles/pbp.py +16 -0
- bumble/profiles/vcs.py +2 -2
- bumble/profiles/vocs.py +6 -9
- bumble/rfcomm.py +19 -18
- bumble/sdp.py +12 -11
- bumble/smp.py +20 -30
- bumble/snoop.py +2 -1
- bumble/tools/generate_company_id_list.py +1 -1
- bumble/tools/intel_util.py +2 -2
- bumble/tools/rtk_fw_download.py +1 -1
- bumble/tools/rtk_util.py +1 -1
- bumble/transport/__init__.py +1 -2
- bumble/transport/android_emulator.py +2 -3
- bumble/transport/android_netsim.py +49 -40
- bumble/transport/common.py +9 -9
- bumble/transport/file.py +1 -2
- bumble/transport/hci_socket.py +2 -3
- bumble/transport/pty.py +3 -5
- bumble/transport/pyusb.py +8 -5
- bumble/transport/serial.py +1 -2
- bumble/transport/vhci.py +1 -2
- bumble/transport/ws_server.py +2 -3
- bumble/utils.py +22 -9
- bumble/vendor/android/hci.py +4 -2
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/METADATA +3 -2
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/RECORD +102 -101
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/WHEEL +0 -0
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/entry_points.txt +0 -0
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/licenses/LICENSE +0 -0
- {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/top_level.txt +0 -0
bumble/apps/auracast.py
CHANGED
|
@@ -23,10 +23,14 @@ import contextlib
|
|
|
23
23
|
import dataclasses
|
|
24
24
|
import functools
|
|
25
25
|
import logging
|
|
26
|
-
import
|
|
27
|
-
from
|
|
26
|
+
import secrets
|
|
27
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
|
|
28
|
+
from typing import (
|
|
29
|
+
Any,
|
|
30
|
+
)
|
|
28
31
|
|
|
29
32
|
import click
|
|
33
|
+
import tomli
|
|
30
34
|
|
|
31
35
|
try:
|
|
32
36
|
import lc3 # type: ignore # pylint: disable=E0401
|
|
@@ -58,8 +62,11 @@ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
|
|
|
58
62
|
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
|
|
59
63
|
AURACAST_DEFAULT_ATT_MTU = 256
|
|
60
64
|
AURACAST_DEFAULT_FRAME_DURATION = 10000
|
|
61
|
-
AURACAST_DEFAULT_SAMPLE_RATE = 48000
|
|
62
65
|
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
|
|
66
|
+
AURACAST_DEFAULT_BROADCAST_ID = 123456
|
|
67
|
+
AURACAST_DEFAULT_BROADCAST_NAME = 'Bumble Auracast'
|
|
68
|
+
AURACAST_DEFAULT_LANGUAGE = 'en'
|
|
69
|
+
AURACAST_DEFAULT_PROGRAM_INFO = 'Disco'
|
|
63
70
|
|
|
64
71
|
|
|
65
72
|
# -----------------------------------------------------------------------------
|
|
@@ -103,6 +110,95 @@ def broadcast_code_bytes(broadcast_code: str) -> bytes:
|
|
|
103
110
|
return broadcast_code_utf8 + padding
|
|
104
111
|
|
|
105
112
|
|
|
113
|
+
def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
|
|
114
|
+
broadcasts: list[Broadcast] = []
|
|
115
|
+
|
|
116
|
+
with open(filename, "rb") as config_file:
|
|
117
|
+
config = tomli.load(config_file)
|
|
118
|
+
for broadcast in config.get("broadcasts", []):
|
|
119
|
+
sources = []
|
|
120
|
+
for source in broadcast.get("sources", []):
|
|
121
|
+
sources.append(
|
|
122
|
+
BroadcastSource(
|
|
123
|
+
input=source["input"],
|
|
124
|
+
input_format=source.get("format", "auto"),
|
|
125
|
+
bitrate=source.get(
|
|
126
|
+
"bitrate", AURACAST_DEFAULT_TRANSMIT_BITRATE
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
manufacturer_data = broadcast.get("manufacturer_data")
|
|
132
|
+
if manufacturer_data is not None:
|
|
133
|
+
manufacturer_data = (
|
|
134
|
+
manufacturer_data.get("company_id"),
|
|
135
|
+
bytes.fromhex(manufacturer_data["data"]),
|
|
136
|
+
)
|
|
137
|
+
broadcasts.append(
|
|
138
|
+
Broadcast(
|
|
139
|
+
sources=sources,
|
|
140
|
+
public=broadcast.get("public", True),
|
|
141
|
+
broadcast_id=broadcast.get("id", AURACAST_DEFAULT_BROADCAST_ID),
|
|
142
|
+
broadcast_name=broadcast["name"],
|
|
143
|
+
broadcast_code=broadcast.get("code"),
|
|
144
|
+
manufacturer_data=broadcast.get("manufacturer_data"),
|
|
145
|
+
language=broadcast.get("language"),
|
|
146
|
+
program_info=broadcast.get("program_info"),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return broadcasts
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def assign_broadcast_ids(broadcasts: Sequence[Broadcast]) -> None:
|
|
154
|
+
broadcast_ids = set()
|
|
155
|
+
for broadcast in broadcasts:
|
|
156
|
+
if broadcast.broadcast_id:
|
|
157
|
+
if broadcast.broadcast_id in broadcast_ids:
|
|
158
|
+
raise ValueError(f'duplicate broadcast ID {broadcast.broadcast_id}')
|
|
159
|
+
broadcast_ids.add(broadcast.broadcast_id)
|
|
160
|
+
else:
|
|
161
|
+
while True:
|
|
162
|
+
broadcast.broadcast_id = 1 + secrets.randbelow(0xFFFFFF)
|
|
163
|
+
if broadcast.broadcast_id not in broadcast_ids:
|
|
164
|
+
broadcast_ids.add(broadcast.broadcast_id)
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclasses.dataclass
|
|
169
|
+
class Broadcast:
|
|
170
|
+
sources: list[BroadcastSource]
|
|
171
|
+
public: bool
|
|
172
|
+
broadcast_id: int # 0 means unassigned
|
|
173
|
+
broadcast_name: str
|
|
174
|
+
broadcast_code: str | None
|
|
175
|
+
manufacturer_data: tuple[int, bytes] | None = None
|
|
176
|
+
language: str | None = None
|
|
177
|
+
program_info: str | None = None
|
|
178
|
+
audio_sources: list[AudioSource] = dataclasses.field(default_factory=list)
|
|
179
|
+
iso_queues: list[bumble.device.IsoPacketStream] = dataclasses.field(
|
|
180
|
+
default_factory=list
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclasses.dataclass
|
|
185
|
+
class BroadcastSource:
|
|
186
|
+
input: str
|
|
187
|
+
input_format: str
|
|
188
|
+
bitrate: int
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclasses.dataclass
|
|
192
|
+
class AudioSource:
|
|
193
|
+
audio_input: audio_io.AudioInput
|
|
194
|
+
pcm_format: audio_io.PcmFormat
|
|
195
|
+
pcm_bit_depth: int | None
|
|
196
|
+
lc3_encoder: lc3.Encoder
|
|
197
|
+
lc3_frame_samples: int
|
|
198
|
+
lc3_frame_size: int
|
|
199
|
+
audio_frames: AsyncGenerator
|
|
200
|
+
|
|
201
|
+
|
|
106
202
|
# -----------------------------------------------------------------------------
|
|
107
203
|
# Scan For Broadcasts
|
|
108
204
|
# -----------------------------------------------------------------------------
|
|
@@ -113,12 +209,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
113
209
|
sync: bumble.device.PeriodicAdvertisingSync
|
|
114
210
|
broadcast_id: int
|
|
115
211
|
rssi: int = 0
|
|
116
|
-
public_broadcast_announcement:
|
|
117
|
-
broadcast_audio_announcement:
|
|
118
|
-
basic_audio_announcement:
|
|
119
|
-
appearance:
|
|
120
|
-
biginfo:
|
|
121
|
-
manufacturer_data:
|
|
212
|
+
public_broadcast_announcement: pbp.PublicBroadcastAnnouncement | None = None
|
|
213
|
+
broadcast_audio_announcement: bap.BroadcastAudioAnnouncement | None = None
|
|
214
|
+
basic_audio_announcement: bap.BasicAudioAnnouncement | None = None
|
|
215
|
+
appearance: core.Appearance | None = None
|
|
216
|
+
biginfo: bumble.device.BigInfoAdvertisement | None = None
|
|
217
|
+
manufacturer_data: tuple[str, bytes] | None = None
|
|
218
|
+
device_name: str | None = None
|
|
122
219
|
|
|
123
220
|
def __post_init__(self) -> None:
|
|
124
221
|
super().__init__()
|
|
@@ -146,6 +243,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
146
243
|
)
|
|
147
244
|
continue
|
|
148
245
|
|
|
246
|
+
self.device_name = advertisement.data.get(
|
|
247
|
+
core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
|
|
248
|
+
)
|
|
249
|
+
|
|
149
250
|
self.appearance = advertisement.data.get(
|
|
150
251
|
core.AdvertisingData.Type.APPEARANCE
|
|
151
252
|
)
|
|
@@ -170,11 +271,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
170
271
|
color(self.sync.state.name, 'green'),
|
|
171
272
|
)
|
|
172
273
|
if self.name is not None:
|
|
173
|
-
print(f' {color("Name", "cyan")}:
|
|
274
|
+
print(f' {color("Broadcast Name", "cyan")}: {self.name}')
|
|
275
|
+
if self.device_name:
|
|
276
|
+
print(f' {color("Device Name", "cyan")}: {self.device_name}')
|
|
174
277
|
if self.appearance:
|
|
175
|
-
print(f' {color("Appearance", "cyan")}:
|
|
176
|
-
print(f' {color("RSSI", "cyan")}:
|
|
177
|
-
print(f' {color("SID", "cyan")}:
|
|
278
|
+
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
|
279
|
+
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
|
280
|
+
print(f' {color("SID", "cyan")}: {self.sync.sid}')
|
|
178
281
|
|
|
179
282
|
if self.manufacturer_data:
|
|
180
283
|
print(
|
|
@@ -184,17 +287,22 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
184
287
|
|
|
185
288
|
if self.broadcast_audio_announcement:
|
|
186
289
|
print(
|
|
187
|
-
f' {color("Broadcast ID", "cyan")}:
|
|
290
|
+
f' {color("Broadcast ID", "cyan")}: '
|
|
188
291
|
f'{self.broadcast_audio_announcement.broadcast_id}'
|
|
189
292
|
)
|
|
190
293
|
|
|
191
294
|
if self.public_broadcast_announcement:
|
|
192
295
|
print(
|
|
193
|
-
f' {color("Features", "cyan")}:
|
|
296
|
+
f' {color("Features", "cyan")}: '
|
|
194
297
|
f'{self.public_broadcast_announcement.features.name}'
|
|
195
298
|
)
|
|
196
|
-
|
|
197
|
-
|
|
299
|
+
if self.public_broadcast_announcement.metadata.entries:
|
|
300
|
+
print(f' {color("Metadata", "cyan")}: ')
|
|
301
|
+
print(
|
|
302
|
+
self.public_broadcast_announcement.metadata.pretty_print(
|
|
303
|
+
' '
|
|
304
|
+
)
|
|
305
|
+
)
|
|
198
306
|
|
|
199
307
|
if self.basic_audio_announcement:
|
|
200
308
|
print(color(' Audio:', 'cyan'))
|
|
@@ -210,22 +318,24 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
210
318
|
color(' Coding Format: ', 'green'),
|
|
211
319
|
subgroup.codec_id.codec_id.name,
|
|
212
320
|
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
321
|
+
if subgroup.codec_id.company_id:
|
|
322
|
+
print(
|
|
323
|
+
color(' Company ID: ', 'green'),
|
|
324
|
+
subgroup.codec_id.company_id,
|
|
325
|
+
)
|
|
326
|
+
print(
|
|
327
|
+
color(' Vendor Specific Codec ID:', 'green'),
|
|
328
|
+
subgroup.codec_id.vendor_specific_codec_id,
|
|
329
|
+
)
|
|
221
330
|
print(color(' Codec Config:', 'yellow'))
|
|
222
331
|
print(
|
|
223
332
|
codec_config_string(
|
|
224
333
|
subgroup.codec_specific_configuration, ' '
|
|
225
334
|
),
|
|
226
335
|
)
|
|
227
|
-
|
|
228
|
-
|
|
336
|
+
if subgroup.metadata.entries:
|
|
337
|
+
print(color(' Metadata: ', 'yellow'))
|
|
338
|
+
print(subgroup.metadata.pretty_print(' '))
|
|
229
339
|
|
|
230
340
|
for bis in subgroup.bis:
|
|
231
341
|
print(color(f' BIS [{bis.index}]:', 'yellow'))
|
|
@@ -292,7 +402,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
292
402
|
self.device = device
|
|
293
403
|
self.filter_duplicates = filter_duplicates
|
|
294
404
|
self.sync_timeout = sync_timeout
|
|
295
|
-
self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
|
|
405
|
+
self.broadcasts = dict[tuple[hci.Address, int], BroadcastScanner.Broadcast]()
|
|
296
406
|
device.on('advertisement', self.on_advertisement)
|
|
297
407
|
|
|
298
408
|
async def start(self) -> None:
|
|
@@ -310,7 +420,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
310
420
|
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
|
311
421
|
)
|
|
312
422
|
) or not (
|
|
313
|
-
|
|
423
|
+
broadcast_audio_announcement_ad := next(
|
|
314
424
|
(
|
|
315
425
|
ad
|
|
316
426
|
for ad in ads
|
|
@@ -325,7 +435,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
325
435
|
core.AdvertisingData.Type.BROADCAST_NAME
|
|
326
436
|
)
|
|
327
437
|
|
|
328
|
-
|
|
438
|
+
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement.from_bytes(
|
|
439
|
+
broadcast_audio_announcement_ad[1]
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if broadcast := self.broadcasts.get(
|
|
443
|
+
(advertisement.address, broadcast_audio_announcement.broadcast_id)
|
|
444
|
+
):
|
|
329
445
|
broadcast.update(advertisement)
|
|
330
446
|
return
|
|
331
447
|
|
|
@@ -333,9 +449,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
333
449
|
self.on_new_broadcast(
|
|
334
450
|
broadcast_name[0] if broadcast_name else None,
|
|
335
451
|
advertisement,
|
|
336
|
-
|
|
337
|
-
broadcast_audio_announcement[1]
|
|
338
|
-
).broadcast_id,
|
|
452
|
+
broadcast_audio_announcement.broadcast_id,
|
|
339
453
|
)
|
|
340
454
|
)
|
|
341
455
|
|
|
@@ -353,12 +467,12 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|
|
353
467
|
)
|
|
354
468
|
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
|
|
355
469
|
broadcast.update(advertisement)
|
|
356
|
-
self.broadcasts[advertisement.address] = broadcast
|
|
470
|
+
self.broadcasts[(advertisement.address, broadcast_id)] = broadcast
|
|
357
471
|
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
|
358
472
|
self.emit('new_broadcast', broadcast)
|
|
359
473
|
|
|
360
|
-
def on_broadcast_loss(self, broadcast: Broadcast) -> None:
|
|
361
|
-
del self.broadcasts[broadcast.sync.advertiser_address]
|
|
474
|
+
def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
|
|
475
|
+
del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
|
|
362
476
|
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
|
|
363
477
|
self.emit('broadcast_loss', broadcast)
|
|
364
478
|
|
|
@@ -434,7 +548,7 @@ async def create_device(transport: str) -> AsyncGenerator[bumble.device.Device,
|
|
|
434
548
|
|
|
435
549
|
|
|
436
550
|
async def find_broadcast_by_name(
|
|
437
|
-
device: bumble.device.Device, name:
|
|
551
|
+
device: bumble.device.Device, name: str | None
|
|
438
552
|
) -> BroadcastScanner.Broadcast:
|
|
439
553
|
result = asyncio.get_running_loop().create_future()
|
|
440
554
|
|
|
@@ -462,205 +576,198 @@ async def find_broadcast_by_name(
|
|
|
462
576
|
|
|
463
577
|
|
|
464
578
|
async def run_scan(
|
|
465
|
-
filter_duplicates: bool, sync_timeout: float
|
|
579
|
+
device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
|
|
466
580
|
) -> None:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return
|
|
581
|
+
if not device.supports_le_periodic_advertising:
|
|
582
|
+
print(color('Periodic advertising not supported', 'red'))
|
|
583
|
+
return
|
|
471
584
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
585
|
+
scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
|
|
586
|
+
await scanner.start()
|
|
587
|
+
await asyncio.get_running_loop().create_future()
|
|
475
588
|
|
|
476
589
|
|
|
477
590
|
async def run_assist(
|
|
478
|
-
|
|
479
|
-
|
|
591
|
+
device: bumble.device.Device,
|
|
592
|
+
broadcast_name: str | None,
|
|
593
|
+
source_id: int | None,
|
|
480
594
|
command: str,
|
|
481
|
-
transport: str,
|
|
482
595
|
address: str,
|
|
483
596
|
) -> None:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return
|
|
597
|
+
if not device.supports_le_periodic_advertising:
|
|
598
|
+
print(color('Periodic advertising not supported', 'red'))
|
|
599
|
+
return
|
|
488
600
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
601
|
+
# Connect to the server
|
|
602
|
+
print(f'=== Connecting to {address}...')
|
|
603
|
+
connection = await device.connect(address)
|
|
604
|
+
peer = bumble.device.Peer(connection)
|
|
605
|
+
print(f'=== Connected to {peer}')
|
|
494
606
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
607
|
+
print("+++ Encrypting connection...")
|
|
608
|
+
await peer.connection.encrypt()
|
|
609
|
+
print("+++ Connection encrypted")
|
|
498
610
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
611
|
+
# Request a larger MTU
|
|
612
|
+
mtu = AURACAST_DEFAULT_ATT_MTU
|
|
613
|
+
print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
|
|
614
|
+
await peer.request_mtu(mtu)
|
|
503
615
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
616
|
+
# Get the BASS service
|
|
617
|
+
bass_client = await peer.discover_service_and_create_proxy(
|
|
618
|
+
bass.BroadcastAudioScanServiceProxy
|
|
619
|
+
)
|
|
508
620
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
621
|
+
# Check that the service was found
|
|
622
|
+
if not bass_client:
|
|
623
|
+
print(color('!!! Broadcast Audio Scan Service not found', 'red'))
|
|
624
|
+
return
|
|
513
625
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
626
|
+
# Subscribe to and read the broadcast receive state characteristics
|
|
627
|
+
def on_broadcast_receive_state_update(
|
|
628
|
+
value: bass.BroadcastReceiveState | None, index: int
|
|
629
|
+
) -> None:
|
|
630
|
+
if value is not None:
|
|
518
631
|
print(
|
|
519
632
|
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
|
|
520
633
|
)
|
|
521
634
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
)
|
|
529
|
-
except core.ProtocolError as error:
|
|
530
|
-
print(
|
|
531
|
-
color(
|
|
532
|
-
'!!! Failed to subscribe to Broadcast Receive State characteristic',
|
|
533
|
-
'red',
|
|
534
|
-
),
|
|
535
|
-
error,
|
|
536
|
-
)
|
|
537
|
-
value = await broadcast_receive_state.read_value()
|
|
635
|
+
for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
|
|
636
|
+
try:
|
|
637
|
+
await broadcast_receive_state.subscribe(
|
|
638
|
+
functools.partial(on_broadcast_receive_state_update, index=i)
|
|
639
|
+
)
|
|
640
|
+
except core.ProtocolError as error:
|
|
538
641
|
print(
|
|
539
|
-
|
|
642
|
+
color(
|
|
643
|
+
'!!! Failed to subscribe to Broadcast Receive State characteristic',
|
|
644
|
+
'red',
|
|
645
|
+
),
|
|
646
|
+
error,
|
|
540
647
|
)
|
|
648
|
+
value = await broadcast_receive_state.read_value()
|
|
649
|
+
print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')
|
|
541
650
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if command == 'add-source':
|
|
547
|
-
# Find the requested broadcast
|
|
548
|
-
await bass_client.remote_scan_started()
|
|
549
|
-
if broadcast_name:
|
|
550
|
-
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
|
551
|
-
else:
|
|
552
|
-
print(color('Scanning for any broadcast', 'cyan'))
|
|
553
|
-
broadcast = await find_broadcast_by_name(device, broadcast_name)
|
|
651
|
+
if command == 'monitor-state':
|
|
652
|
+
await peer.sustain()
|
|
653
|
+
return
|
|
554
654
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
655
|
+
if command == 'add-source':
|
|
656
|
+
# Find the requested broadcast
|
|
657
|
+
await bass_client.remote_scan_started()
|
|
658
|
+
if broadcast_name:
|
|
659
|
+
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
|
660
|
+
else:
|
|
661
|
+
print(color('Scanning for any broadcast', 'cyan'))
|
|
662
|
+
broadcast = await find_broadcast_by_name(device, broadcast_name)
|
|
558
663
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
):
|
|
563
|
-
print(color('No subgroups found', 'red'))
|
|
564
|
-
return
|
|
664
|
+
if broadcast.broadcast_audio_announcement is None:
|
|
665
|
+
print(color('No broadcast audio announcement found', 'red'))
|
|
666
|
+
return
|
|
565
667
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
|
573
|
-
0xFFFF,
|
|
574
|
-
[
|
|
575
|
-
bass.SubgroupInfo(
|
|
576
|
-
bass.SubgroupInfo.ANY_BIS,
|
|
577
|
-
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
|
578
|
-
)
|
|
579
|
-
],
|
|
580
|
-
)
|
|
668
|
+
if (
|
|
669
|
+
broadcast.basic_audio_announcement is None
|
|
670
|
+
or not broadcast.basic_audio_announcement.subgroups
|
|
671
|
+
):
|
|
672
|
+
print(color('No subgroups found', 'red'))
|
|
673
|
+
return
|
|
581
674
|
|
|
582
|
-
|
|
583
|
-
|
|
675
|
+
# Add the source
|
|
676
|
+
print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
|
|
677
|
+
await bass_client.add_source(
|
|
678
|
+
broadcast.sync.advertiser_address,
|
|
679
|
+
broadcast.sync.sid,
|
|
680
|
+
broadcast.broadcast_audio_announcement.broadcast_id,
|
|
681
|
+
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
|
682
|
+
0xFFFF,
|
|
683
|
+
[
|
|
684
|
+
bass.SubgroupInfo(
|
|
685
|
+
bass.SubgroupInfo.ANY_BIS,
|
|
686
|
+
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
|
687
|
+
)
|
|
688
|
+
],
|
|
689
|
+
)
|
|
584
690
|
|
|
585
|
-
|
|
586
|
-
|
|
691
|
+
# Initiate a PA Sync Transfer
|
|
692
|
+
await broadcast.sync.transfer(peer.connection)
|
|
587
693
|
|
|
588
|
-
|
|
589
|
-
|
|
694
|
+
# Notify the sink that we're done scanning.
|
|
695
|
+
await bass_client.remote_scan_stopped()
|
|
590
696
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
print(color('!!! modify-source requires --source-id'))
|
|
594
|
-
return
|
|
697
|
+
await peer.sustain()
|
|
698
|
+
return
|
|
595
699
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
else:
|
|
601
|
-
print(color('Scanning for any broadcast', 'cyan'))
|
|
602
|
-
broadcast = await find_broadcast_by_name(device, broadcast_name)
|
|
700
|
+
if command == 'modify-source':
|
|
701
|
+
if source_id is None:
|
|
702
|
+
print(color('!!! modify-source requires --source-id'))
|
|
703
|
+
return
|
|
603
704
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
705
|
+
# Find the requested broadcast
|
|
706
|
+
await bass_client.remote_scan_started()
|
|
707
|
+
if broadcast_name:
|
|
708
|
+
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
|
709
|
+
else:
|
|
710
|
+
print(color('Scanning for any broadcast', 'cyan'))
|
|
711
|
+
broadcast = await find_broadcast_by_name(device, broadcast_name)
|
|
607
712
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
):
|
|
612
|
-
print(color('No subgroups found', 'red'))
|
|
613
|
-
return
|
|
713
|
+
if broadcast.broadcast_audio_announcement is None:
|
|
714
|
+
print(color('No broadcast audio announcement found', 'red'))
|
|
715
|
+
return
|
|
614
716
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
)
|
|
620
|
-
await bass_client.modify_source(
|
|
621
|
-
source_id,
|
|
622
|
-
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
|
623
|
-
0xFFFF,
|
|
624
|
-
[
|
|
625
|
-
bass.SubgroupInfo(
|
|
626
|
-
bass.SubgroupInfo.ANY_BIS,
|
|
627
|
-
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
|
628
|
-
)
|
|
629
|
-
],
|
|
630
|
-
)
|
|
631
|
-
await peer.sustain()
|
|
717
|
+
if (
|
|
718
|
+
broadcast.basic_audio_announcement is None
|
|
719
|
+
or not broadcast.basic_audio_announcement.subgroups
|
|
720
|
+
):
|
|
721
|
+
print(color('No subgroups found', 'red'))
|
|
632
722
|
return
|
|
633
723
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
724
|
+
# Modify the source
|
|
725
|
+
print(
|
|
726
|
+
color('Modifying source:', 'blue'),
|
|
727
|
+
source_id,
|
|
728
|
+
)
|
|
729
|
+
await bass_client.modify_source(
|
|
730
|
+
source_id,
|
|
731
|
+
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
|
732
|
+
0xFFFF,
|
|
733
|
+
[
|
|
734
|
+
bass.SubgroupInfo(
|
|
735
|
+
bass.SubgroupInfo.ANY_BIS,
|
|
736
|
+
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
|
737
|
+
)
|
|
738
|
+
],
|
|
739
|
+
)
|
|
740
|
+
await peer.sustain()
|
|
741
|
+
return
|
|
638
742
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
await peer.sustain()
|
|
743
|
+
if command == 'remove-source':
|
|
744
|
+
if source_id is None:
|
|
745
|
+
print(color('!!! remove-source requires --source-id'))
|
|
643
746
|
return
|
|
644
747
|
|
|
645
|
-
|
|
748
|
+
# Remove the source
|
|
749
|
+
print(color('Removing source:', 'blue'), source_id)
|
|
750
|
+
await bass_client.remove_source(source_id)
|
|
751
|
+
await peer.sustain()
|
|
752
|
+
return
|
|
646
753
|
|
|
754
|
+
print(color(f'!!! invalid command {command}'))
|
|
647
755
|
|
|
648
|
-
async def run_pair(transport: str, address: str) -> None:
|
|
649
|
-
async with create_device(transport) as device:
|
|
650
756
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
757
|
+
async def run_pair(device: bumble.device.Device, address: str) -> None:
|
|
758
|
+
# Connect to the server
|
|
759
|
+
print(f'=== Connecting to {address}...')
|
|
760
|
+
async with device.connect_as_gatt(address) as peer:
|
|
761
|
+
print(f'=== Connected to {peer}')
|
|
655
762
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
763
|
+
print("+++ Initiating pairing...")
|
|
764
|
+
await peer.connection.pair()
|
|
765
|
+
print("+++ Paired")
|
|
659
766
|
|
|
660
767
|
|
|
661
768
|
async def run_receive(
|
|
662
|
-
|
|
663
|
-
broadcast_id:
|
|
769
|
+
device: bumble.device.Device,
|
|
770
|
+
broadcast_id: int | None,
|
|
664
771
|
output: str,
|
|
665
772
|
broadcast_code: str | None,
|
|
666
773
|
sync_timeout: float,
|
|
@@ -674,300 +781,425 @@ async def run_receive(
|
|
|
674
781
|
print(error)
|
|
675
782
|
return
|
|
676
783
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return
|
|
784
|
+
if not device.supports_le_periodic_advertising:
|
|
785
|
+
print(color('Periodic advertising not supported', 'red'))
|
|
786
|
+
return
|
|
681
787
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
788
|
+
scanner = BroadcastScanner(device, False, sync_timeout)
|
|
789
|
+
scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
|
|
790
|
+
asyncio.get_running_loop().create_future()
|
|
791
|
+
)
|
|
686
792
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
),
|
|
793
|
+
def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
|
|
794
|
+
if scan_result.done():
|
|
795
|
+
return
|
|
796
|
+
if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
|
|
797
|
+
scan_result.set_result(broadcast)
|
|
798
|
+
|
|
799
|
+
scanner.on('new_broadcast', on_new_broadcast)
|
|
800
|
+
await scanner.start()
|
|
801
|
+
print('Start scanning...')
|
|
802
|
+
broadcast = await scan_result
|
|
803
|
+
print('Advertisement found:')
|
|
804
|
+
broadcast.print()
|
|
805
|
+
basic_audio_announcement_scanned = asyncio.Event()
|
|
806
|
+
|
|
807
|
+
def on_change() -> None:
|
|
808
|
+
if (
|
|
809
|
+
broadcast.basic_audio_announcement and broadcast.biginfo
|
|
810
|
+
) and not basic_audio_announcement_scanned.is_set():
|
|
811
|
+
basic_audio_announcement_scanned.set()
|
|
812
|
+
|
|
813
|
+
broadcast.on('change', on_change)
|
|
814
|
+
if not broadcast.basic_audio_announcement or not broadcast.biginfo:
|
|
815
|
+
print('Wait for Basic Audio Announcement and BIG Info...')
|
|
816
|
+
await basic_audio_announcement_scanned.wait()
|
|
817
|
+
print('Basic Audio Announcement found')
|
|
818
|
+
broadcast.print()
|
|
819
|
+
print('Stop scanning')
|
|
820
|
+
await scanner.stop()
|
|
821
|
+
print('Start sync to BIG')
|
|
822
|
+
|
|
823
|
+
assert broadcast.basic_audio_announcement
|
|
824
|
+
subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
|
|
825
|
+
configuration = subgroup.codec_specific_configuration
|
|
826
|
+
assert configuration
|
|
827
|
+
assert (sampling_frequency := configuration.sampling_frequency)
|
|
828
|
+
assert (frame_duration := configuration.frame_duration)
|
|
829
|
+
|
|
830
|
+
big_sync = await device.create_big_sync(
|
|
831
|
+
broadcast.sync,
|
|
832
|
+
bumble.device.BigSyncParameters(
|
|
833
|
+
big_sync_timeout=0x4000,
|
|
834
|
+
bis=[bis.index for bis in subgroup.bis],
|
|
835
|
+
broadcast_code=(
|
|
836
|
+
broadcast_code_bytes(broadcast_code) if broadcast_code else None
|
|
732
837
|
),
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
num_bis = len(big_sync.bis_links)
|
|
841
|
+
decoder = lc3.Decoder(
|
|
842
|
+
frame_duration_us=frame_duration.us,
|
|
843
|
+
sample_rate_hz=sampling_frequency.hz,
|
|
844
|
+
num_channels=num_bis,
|
|
845
|
+
)
|
|
846
|
+
lc3_queues: list[collections.deque[bytes]] = [
|
|
847
|
+
collections.deque() for i in range(num_bis)
|
|
848
|
+
]
|
|
849
|
+
packet_stats = [0, 0]
|
|
850
|
+
|
|
851
|
+
audio_output = await audio_io.create_audio_output(output)
|
|
852
|
+
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
|
853
|
+
# longer needed.
|
|
854
|
+
try:
|
|
855
|
+
await audio_output.open(
|
|
856
|
+
audio_io.PcmFormat(
|
|
857
|
+
audio_io.PcmFormat.Endianness.LITTLE,
|
|
858
|
+
audio_io.PcmFormat.SampleType.FLOAT32,
|
|
859
|
+
sampling_frequency.hz,
|
|
860
|
+
num_bis,
|
|
755
861
|
)
|
|
862
|
+
)
|
|
756
863
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
864
|
+
def sink(queue: collections.deque[bytes], packet: hci.HCI_IsoDataPacket):
|
|
865
|
+
# TODO: re-assemble fragments and detect errors
|
|
866
|
+
queue.append(packet.iso_sdu_fragment)
|
|
760
867
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
868
|
+
while all(lc3_queues):
|
|
869
|
+
# This assumes SDUs contain one LC3 frame each, which may not
|
|
870
|
+
# be correct for all cases. TODO: revisit this assumption.
|
|
871
|
+
frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
|
|
872
|
+
if not frame:
|
|
873
|
+
print(color('!!! received empty frame', 'red'))
|
|
874
|
+
continue
|
|
768
875
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
876
|
+
packet_stats[0] += len(frame)
|
|
877
|
+
packet_stats[1] += 1
|
|
878
|
+
print(
|
|
879
|
+
f'\rRECEIVED: {packet_stats[0]} bytes in '
|
|
880
|
+
f'{packet_stats[1]} packets',
|
|
881
|
+
end='',
|
|
882
|
+
)
|
|
776
883
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
884
|
+
try:
|
|
885
|
+
pcm = decoder.decode(frame).tobytes()
|
|
886
|
+
except lc3.BaseError as error:
|
|
887
|
+
print(color(f'!!! LC3 decoding error: {error}'))
|
|
888
|
+
continue
|
|
782
889
|
|
|
783
|
-
|
|
890
|
+
audio_output.write(pcm)
|
|
784
891
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
892
|
+
for i, bis_link in enumerate(big_sync.bis_links):
|
|
893
|
+
print(f'Setup ISO for BIS {bis_link.handle}')
|
|
894
|
+
bis_link.sink = functools.partial(sink, lc3_queues[i])
|
|
895
|
+
await bis_link.setup_data_path(
|
|
896
|
+
direction=bis_link.Direction.CONTROLLER_TO_HOST
|
|
897
|
+
)
|
|
791
898
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
899
|
+
terminated = asyncio.Event()
|
|
900
|
+
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
|
|
901
|
+
await terminated.wait()
|
|
902
|
+
finally:
|
|
903
|
+
await audio_output.aclose()
|
|
795
904
|
|
|
796
905
|
|
|
797
906
|
async def run_transmit(
|
|
798
|
-
|
|
799
|
-
broadcast_id: int,
|
|
800
|
-
broadcast_code: str | None,
|
|
801
|
-
broadcast_name: str,
|
|
802
|
-
bitrate: int,
|
|
803
|
-
manufacturer_data: tuple[int, bytes] | None,
|
|
804
|
-
input: str,
|
|
805
|
-
input_format: str,
|
|
907
|
+
device: bumble.device.Device, broadcasts: Iterable[Broadcast]
|
|
806
908
|
) -> None:
|
|
807
|
-
# Run a pre-flight check for the input.
|
|
909
|
+
# Run a pre-flight check for the input(s).
|
|
808
910
|
try:
|
|
809
|
-
|
|
810
|
-
|
|
911
|
+
for broadcast in broadcasts:
|
|
912
|
+
for source in broadcast.sources:
|
|
913
|
+
if not audio_io.check_audio_input(source.input):
|
|
914
|
+
return
|
|
811
915
|
except ValueError as error:
|
|
812
916
|
print(error)
|
|
813
917
|
return
|
|
814
918
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
return
|
|
919
|
+
if not device.supports_le_periodic_advertising:
|
|
920
|
+
print(color('Periodic advertising not supported', 'red'))
|
|
921
|
+
return
|
|
819
922
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
923
|
+
def on_flow():
|
|
924
|
+
pending = []
|
|
925
|
+
queued = []
|
|
926
|
+
completed = []
|
|
927
|
+
for broadcast in broadcasts:
|
|
928
|
+
for iso_queue in broadcast.iso_queues:
|
|
929
|
+
data_packet_queue = iso_queue.data_packet_queue
|
|
930
|
+
pending.append(str(data_packet_queue.pending))
|
|
931
|
+
queued.append(str(data_packet_queue.queued))
|
|
932
|
+
completed.append(str(data_packet_queue.completed))
|
|
933
|
+
print(
|
|
934
|
+
f'\rPACKETS: '
|
|
935
|
+
f'pending={",".join(pending)} | '
|
|
936
|
+
f'queued={",".join(queued)} | '
|
|
937
|
+
f'completed={",".join(completed)}',
|
|
938
|
+
end='',
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
audio_inputs: list[audio_io.AudioInput] = []
|
|
942
|
+
try:
|
|
943
|
+
# Setup audio sources
|
|
944
|
+
for broadcast_index, broadcast in enumerate(broadcasts):
|
|
945
|
+
channel_count = 0
|
|
946
|
+
max_lc3_frame_size = 0
|
|
947
|
+
max_sample_rate = 0
|
|
948
|
+
for source in broadcast.sources:
|
|
949
|
+
print(f'Setting up audio input: {source.input}')
|
|
950
|
+
|
|
951
|
+
# Open the audio input
|
|
952
|
+
audio_input = await audio_io.create_audio_input(
|
|
953
|
+
source.input, source.input_format
|
|
954
|
+
)
|
|
955
|
+
pcm_format = await audio_input.open()
|
|
956
|
+
audio_inputs.append(audio_input)
|
|
957
|
+
|
|
958
|
+
# Check that the number of channels is supported
|
|
959
|
+
if pcm_format.channels not in (1, 2):
|
|
960
|
+
print("Only 1 and 2 channels PCM configurations are supported")
|
|
961
|
+
return
|
|
962
|
+
channel_count += pcm_format.channels
|
|
963
|
+
|
|
964
|
+
# Check that the sample type is supported
|
|
965
|
+
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
|
966
|
+
pcm_bit_depth = 16
|
|
967
|
+
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
|
968
|
+
pcm_bit_depth = None
|
|
969
|
+
else:
|
|
970
|
+
print("Only INT16 and FLOAT32 sample types are supported")
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
# Check that the sample rate is supported
|
|
974
|
+
if pcm_format.sample_rate not in (16000, 24000, 48000):
|
|
975
|
+
print(f'Sample rate {pcm_format.sample_rate} not supported')
|
|
976
|
+
return
|
|
977
|
+
max_sample_rate = max(max_sample_rate, pcm_format.sample_rate)
|
|
978
|
+
|
|
979
|
+
# Compute LC3 parameters and create and encoder
|
|
980
|
+
encoder = lc3.Encoder(
|
|
981
|
+
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
|
|
982
|
+
sample_rate_hz=pcm_format.sample_rate,
|
|
983
|
+
num_channels=pcm_format.channels,
|
|
984
|
+
input_sample_rate_hz=pcm_format.sample_rate,
|
|
985
|
+
)
|
|
986
|
+
lc3_frame_samples = encoder.get_frame_samples()
|
|
987
|
+
lc3_frame_size = encoder.get_frame_bytes(source.bitrate)
|
|
988
|
+
max_lc3_frame_size = max(max_lc3_frame_size, lc3_frame_size)
|
|
989
|
+
print(
|
|
990
|
+
f'Encoding {source.input} with {lc3_frame_samples} '
|
|
991
|
+
f'PCM samples per {lc3_frame_size} byte frame'
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
broadcast.audio_sources.append(
|
|
995
|
+
AudioSource(
|
|
996
|
+
audio_input=audio_input,
|
|
997
|
+
pcm_format=pcm_format,
|
|
998
|
+
pcm_bit_depth=pcm_bit_depth,
|
|
999
|
+
lc3_encoder=encoder,
|
|
1000
|
+
lc3_frame_samples=lc3_frame_samples,
|
|
1001
|
+
lc3_frame_size=lc3_frame_size,
|
|
1002
|
+
audio_frames=audio_input.frames(lc3_frame_samples),
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
# Setup advertising and BIGs
|
|
1007
|
+
metadata = le_audio.Metadata()
|
|
1008
|
+
if broadcast.language is not None:
|
|
1009
|
+
metadata.entries.append(
|
|
1010
|
+
le_audio.Metadata.Entry(
|
|
1011
|
+
tag=le_audio.Metadata.Tag.LANGUAGE,
|
|
1012
|
+
data=broadcast.language.encode('utf-8'),
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
if broadcast.program_info is not None:
|
|
1016
|
+
metadata.entries.append(
|
|
1017
|
+
le_audio.Metadata.Entry(
|
|
1018
|
+
tag=le_audio.Metadata.Tag.PROGRAM_INFO,
|
|
1019
|
+
data=broadcast.program_info.encode('utf-8'),
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if broadcast.public:
|
|
1024
|
+
# Infer features from sources
|
|
1025
|
+
features = pbp.PublicBroadcastAnnouncement.Features(0)
|
|
1026
|
+
if broadcast.broadcast_code is not None:
|
|
1027
|
+
features |= pbp.PublicBroadcastAnnouncement.Features.ENCRYPTED
|
|
1028
|
+
for audio_source in broadcast.audio_sources:
|
|
1029
|
+
if audio_source.pcm_format.sample_rate == 48000:
|
|
1030
|
+
features |= (
|
|
1031
|
+
pbp.PublicBroadcastAnnouncement.Features.HIGH_QUALITY_CONFIGURATION
|
|
1032
|
+
)
|
|
1033
|
+
else:
|
|
1034
|
+
features |= (
|
|
1035
|
+
pbp.PublicBroadcastAnnouncement.Features.STANDARD_QUALITY_CONFIGURATION
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
public_broadcast_announcement = pbp.PublicBroadcastAnnouncement(
|
|
1039
|
+
features=features, metadata=metadata
|
|
1040
|
+
)
|
|
1041
|
+
else:
|
|
1042
|
+
public_broadcast_announcement = None
|
|
1043
|
+
|
|
1044
|
+
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(
|
|
1045
|
+
broadcast.broadcast_id
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
basic_audio_announcement = bap.BasicAudioAnnouncement(
|
|
1049
|
+
presentation_delay=40000,
|
|
1050
|
+
subgroups=[
|
|
1051
|
+
bap.BasicAudioAnnouncement.Subgroup(
|
|
1052
|
+
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
|
1053
|
+
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
1054
|
+
sampling_frequency=bap.SamplingFrequency.from_hz(
|
|
1055
|
+
audio_source.pcm_format.sample_rate
|
|
845
1056
|
),
|
|
1057
|
+
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
|
1058
|
+
octets_per_codec_frame=audio_source.lc3_frame_size,
|
|
846
1059
|
),
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1060
|
+
metadata=le_audio.Metadata(),
|
|
1061
|
+
bis=(
|
|
1062
|
+
[
|
|
1063
|
+
bap.BasicAudioAnnouncement.BIS(
|
|
1064
|
+
index=1,
|
|
1065
|
+
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
1066
|
+
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
|
1067
|
+
),
|
|
1068
|
+
),
|
|
1069
|
+
bap.BasicAudioAnnouncement.BIS(
|
|
1070
|
+
index=2,
|
|
1071
|
+
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
1072
|
+
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
|
|
1073
|
+
),
|
|
1074
|
+
),
|
|
1075
|
+
]
|
|
1076
|
+
if audio_source.pcm_format.channels == 2
|
|
1077
|
+
else [
|
|
1078
|
+
bap.BasicAudioAnnouncement.BIS(
|
|
1079
|
+
index=1,
|
|
1080
|
+
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
1081
|
+
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
|
1082
|
+
),
|
|
1083
|
+
),
|
|
1084
|
+
]
|
|
852
1085
|
),
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
)
|
|
857
|
-
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
|
|
858
|
-
|
|
859
|
-
advertising_data_types: list[core.DataType] = [
|
|
860
|
-
data_types.BroadcastName(broadcast_name)
|
|
861
|
-
]
|
|
862
|
-
if manufacturer_data is not None:
|
|
863
|
-
advertising_data_types.append(
|
|
864
|
-
data_types.ManufacturerSpecificData(*manufacturer_data)
|
|
1086
|
+
)
|
|
1087
|
+
for audio_source in broadcast.audio_sources
|
|
1088
|
+
],
|
|
865
1089
|
)
|
|
866
1090
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1091
|
+
advertising_data_types: list[core.DataType] = [
|
|
1092
|
+
data_types.CompleteLocalName(AURACAST_DEFAULT_DEVICE_NAME),
|
|
1093
|
+
data_types.Appearance(
|
|
1094
|
+
core.Appearance.Category.AUDIO_SOURCE,
|
|
1095
|
+
core.Appearance.AudioSourceSubcategory.BROADCASTING_DEVICE,
|
|
871
1096
|
),
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
),
|
|
875
|
-
advertising_data=(
|
|
876
|
-
broadcast_audio_announcement.get_advertising_data()
|
|
877
|
-
+ bytes(core.AdvertisingData(advertising_data_types))
|
|
878
|
-
),
|
|
879
|
-
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
|
880
|
-
periodic_advertising_interval_min=80,
|
|
881
|
-
periodic_advertising_interval_max=160,
|
|
882
|
-
),
|
|
883
|
-
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
|
884
|
-
auto_restart=True,
|
|
885
|
-
auto_start=True,
|
|
886
|
-
)
|
|
1097
|
+
data_types.BroadcastName(broadcast.broadcast_name),
|
|
1098
|
+
]
|
|
887
1099
|
|
|
888
|
-
|
|
889
|
-
|
|
1100
|
+
if broadcast.manufacturer_data is not None:
|
|
1101
|
+
advertising_data_types.append(
|
|
1102
|
+
data_types.ManufacturerSpecificData(*broadcast.manufacturer_data)
|
|
1103
|
+
)
|
|
890
1104
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
|
899
|
-
pcm_bit_depth = 16
|
|
900
|
-
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
|
901
|
-
pcm_bit_depth = None
|
|
902
|
-
else:
|
|
903
|
-
print("Only INT16 and FLOAT32 sample types are supported")
|
|
904
|
-
return
|
|
1105
|
+
advertising_data = bytes(core.AdvertisingData(advertising_data_types))
|
|
1106
|
+
if public_broadcast_announcement:
|
|
1107
|
+
advertising_data += bytes(
|
|
1108
|
+
public_broadcast_announcement.get_advertising_data()
|
|
1109
|
+
)
|
|
1110
|
+
if broadcast_audio_announcement:
|
|
1111
|
+
advertising_data += broadcast_audio_announcement.get_advertising_data()
|
|
905
1112
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
|
|
909
|
-
num_channels=pcm_format.channels,
|
|
910
|
-
input_sample_rate_hz=pcm_format.sample_rate,
|
|
911
|
-
)
|
|
912
|
-
lc3_frame_samples = encoder.get_frame_samples()
|
|
913
|
-
lc3_frame_size = encoder.get_frame_bytes(bitrate)
|
|
1113
|
+
print('Starting Periodic Advertising:')
|
|
1114
|
+
print(f" Extended Advertising data size: {len(advertising_data)}")
|
|
914
1115
|
print(
|
|
915
|
-
f
|
|
916
|
-
f'PCM samples per {lc3_frame_size} byte frame'
|
|
1116
|
+
f" Periodic Advertising data size: {len(basic_audio_announcement.get_advertising_data())}"
|
|
917
1117
|
)
|
|
1118
|
+
advertising_set = await device.create_advertising_set(
|
|
1119
|
+
advertising_parameters=bumble.device.AdvertisingParameters(
|
|
1120
|
+
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
|
1121
|
+
is_connectable=False
|
|
1122
|
+
),
|
|
1123
|
+
primary_advertising_interval_min=100,
|
|
1124
|
+
primary_advertising_interval_max=1000,
|
|
1125
|
+
advertising_sid=broadcast_index,
|
|
1126
|
+
),
|
|
1127
|
+
advertising_data=advertising_data,
|
|
1128
|
+
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
|
1129
|
+
periodic_advertising_interval_min=100,
|
|
1130
|
+
periodic_advertising_interval_max=1000,
|
|
1131
|
+
),
|
|
1132
|
+
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
|
1133
|
+
auto_restart=True,
|
|
1134
|
+
auto_start=True,
|
|
1135
|
+
)
|
|
1136
|
+
await advertising_set.start_periodic()
|
|
918
1137
|
|
|
919
|
-
print('
|
|
1138
|
+
print('Setting up BIG')
|
|
920
1139
|
big = await device.create_big(
|
|
921
1140
|
advertising_set,
|
|
922
1141
|
parameters=bumble.device.BigParameters(
|
|
923
|
-
num_bis=
|
|
1142
|
+
num_bis=channel_count,
|
|
924
1143
|
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
|
|
925
|
-
max_sdu=
|
|
1144
|
+
max_sdu=max_lc3_frame_size,
|
|
926
1145
|
max_transport_latency=65,
|
|
927
1146
|
rtn=4,
|
|
928
1147
|
broadcast_code=(
|
|
929
|
-
broadcast_code_bytes(broadcast_code)
|
|
1148
|
+
broadcast_code_bytes(broadcast.broadcast_code)
|
|
1149
|
+
if broadcast.broadcast_code
|
|
1150
|
+
else None
|
|
930
1151
|
),
|
|
931
1152
|
),
|
|
932
1153
|
)
|
|
933
|
-
|
|
1154
|
+
|
|
1155
|
+
for i, bis_link in enumerate(big.bis_links):
|
|
934
1156
|
print(f'Setup ISO for BIS {bis_link.handle}')
|
|
935
1157
|
await bis_link.setup_data_path(
|
|
936
1158
|
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
|
937
1159
|
)
|
|
1160
|
+
iso_queue = bumble.device.IsoPacketStream(bis_link, 64)
|
|
1161
|
+
iso_queue.data_packet_queue.on('flow', on_flow)
|
|
1162
|
+
broadcast.iso_queues.append(iso_queue)
|
|
1163
|
+
|
|
1164
|
+
print('Transmitting audio from source(s)')
|
|
1165
|
+
while True:
|
|
1166
|
+
for broadcast in broadcasts:
|
|
1167
|
+
iso_queue_index = 0
|
|
1168
|
+
for audio_source in broadcast.audio_sources:
|
|
1169
|
+
pcm_frame = await anext(audio_source.audio_frames)
|
|
1170
|
+
lc3_frame = audio_source.lc3_encoder.encode(
|
|
1171
|
+
pcm_frame,
|
|
1172
|
+
num_bytes=audio_source.pcm_format.channels
|
|
1173
|
+
* audio_source.lc3_frame_size,
|
|
1174
|
+
bit_depth=audio_source.pcm_bit_depth,
|
|
1175
|
+
)
|
|
938
1176
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
iso_queues[0].data_packet_queue.on('flow', on_flow)
|
|
954
|
-
|
|
955
|
-
frame_count = 0
|
|
956
|
-
async for pcm_frame in audio_input.frames(lc3_frame_samples):
|
|
957
|
-
lc3_frame = encoder.encode(
|
|
958
|
-
pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
|
|
959
|
-
)
|
|
960
|
-
|
|
961
|
-
mid = len(lc3_frame) // 2
|
|
962
|
-
await iso_queues[0].write(lc3_frame[:mid])
|
|
963
|
-
await iso_queues[1].write(lc3_frame[mid:])
|
|
1177
|
+
for lc3_chunk_start in range(
|
|
1178
|
+
0, len(lc3_frame), audio_source.lc3_frame_size
|
|
1179
|
+
):
|
|
1180
|
+
await broadcast.iso_queues[iso_queue_index].write(
|
|
1181
|
+
lc3_frame[
|
|
1182
|
+
lc3_chunk_start : lc3_chunk_start
|
|
1183
|
+
+ audio_source.lc3_frame_size
|
|
1184
|
+
]
|
|
1185
|
+
)
|
|
1186
|
+
iso_queue_index += 1
|
|
1187
|
+
finally:
|
|
1188
|
+
for audio_input in audio_inputs:
|
|
1189
|
+
await audio_input.aclose()
|
|
964
1190
|
|
|
965
|
-
frame_count += 1
|
|
966
1191
|
|
|
1192
|
+
def run_async(
|
|
1193
|
+
async_command: Callable[..., Awaitable[Any]],
|
|
1194
|
+
transport: str,
|
|
1195
|
+
*args,
|
|
1196
|
+
) -> None:
|
|
1197
|
+
async def run_with_transport():
|
|
1198
|
+
async with create_device(transport) as device:
|
|
1199
|
+
await async_command(device, *args)
|
|
967
1200
|
|
|
968
|
-
def run_async(async_command: Coroutine) -> None:
|
|
969
1201
|
try:
|
|
970
|
-
asyncio.run(
|
|
1202
|
+
asyncio.run(run_with_transport())
|
|
971
1203
|
except core.ProtocolError as error:
|
|
972
1204
|
if error.error_namespace == 'att' and error.error_code in list(
|
|
973
1205
|
bass.ApplicationError
|
|
@@ -1005,7 +1237,7 @@ def auracast(ctx):
|
|
|
1005
1237
|
@click.pass_context
|
|
1006
1238
|
def scan(ctx, filter_duplicates, sync_timeout, transport):
|
|
1007
1239
|
"""Scan for public broadcasts"""
|
|
1008
|
-
run_async(run_scan
|
|
1240
|
+
run_async(run_scan, transport, filter_duplicates, sync_timeout)
|
|
1009
1241
|
|
|
1010
1242
|
|
|
1011
1243
|
@auracast.command('assist')
|
|
@@ -1032,7 +1264,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
|
|
|
1032
1264
|
@click.pass_context
|
|
1033
1265
|
def assist(ctx, broadcast_name, source_id, command, transport, address):
|
|
1034
1266
|
"""Scan for broadcasts on behalf of an audio server"""
|
|
1035
|
-
run_async(run_assist
|
|
1267
|
+
run_async(run_assist, transport, broadcast_name, source_id, command, address)
|
|
1036
1268
|
|
|
1037
1269
|
|
|
1038
1270
|
@auracast.command('pair')
|
|
@@ -1041,7 +1273,7 @@ def assist(ctx, broadcast_name, source_id, command, transport, address):
|
|
|
1041
1273
|
@click.pass_context
|
|
1042
1274
|
def pair(ctx, transport, address):
|
|
1043
1275
|
"""Pair with an audio server"""
|
|
1044
|
-
run_async(run_pair
|
|
1276
|
+
run_async(run_pair, transport, address)
|
|
1045
1277
|
|
|
1046
1278
|
|
|
1047
1279
|
@auracast.command('receive')
|
|
@@ -1096,22 +1328,29 @@ def receive(
|
|
|
1096
1328
|
):
|
|
1097
1329
|
"""Receive a broadcast source"""
|
|
1098
1330
|
run_async(
|
|
1099
|
-
run_receive
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
)
|
|
1331
|
+
run_receive,
|
|
1332
|
+
transport,
|
|
1333
|
+
broadcast_id,
|
|
1334
|
+
output,
|
|
1335
|
+
broadcast_code,
|
|
1336
|
+
sync_timeout,
|
|
1337
|
+
subgroup,
|
|
1107
1338
|
)
|
|
1108
1339
|
|
|
1109
1340
|
|
|
1110
1341
|
@auracast.command('transmit')
|
|
1111
1342
|
@click.argument('transport')
|
|
1343
|
+
@click.option(
|
|
1344
|
+
'--broadcast-list',
|
|
1345
|
+
metavar='BROADCAST_LIST',
|
|
1346
|
+
help=(
|
|
1347
|
+
'Filename of a TOML broadcast list with specification(s) for one or more '
|
|
1348
|
+
'broadcast sources. When used, single-source options, including --input, '
|
|
1349
|
+
'--input-format and others, are ignored.'
|
|
1350
|
+
),
|
|
1351
|
+
)
|
|
1112
1352
|
@click.option(
|
|
1113
1353
|
'--input',
|
|
1114
|
-
required=True,
|
|
1115
1354
|
help=(
|
|
1116
1355
|
"Audio input. "
|
|
1117
1356
|
"'device' -> use the host's default sound input device, "
|
|
@@ -1140,18 +1379,18 @@ def receive(
|
|
|
1140
1379
|
'--broadcast-id',
|
|
1141
1380
|
metavar='BROADCAST_ID',
|
|
1142
1381
|
type=int,
|
|
1143
|
-
default=
|
|
1382
|
+
default=AURACAST_DEFAULT_BROADCAST_ID,
|
|
1144
1383
|
help='Broadcast ID',
|
|
1145
1384
|
)
|
|
1146
1385
|
@click.option(
|
|
1147
1386
|
'--broadcast-code',
|
|
1148
1387
|
metavar='BROADCAST_CODE',
|
|
1149
|
-
help='Broadcast encryption code in hex format',
|
|
1388
|
+
help='Broadcast encryption code in hex format or as a string',
|
|
1150
1389
|
)
|
|
1151
1390
|
@click.option(
|
|
1152
1391
|
'--broadcast-name',
|
|
1153
1392
|
metavar='BROADCAST_NAME',
|
|
1154
|
-
default=
|
|
1393
|
+
default=AURACAST_DEFAULT_BROADCAST_NAME,
|
|
1155
1394
|
help='Broadcast name',
|
|
1156
1395
|
)
|
|
1157
1396
|
@click.option(
|
|
@@ -1169,39 +1408,73 @@ def receive(
|
|
|
1169
1408
|
def transmit(
|
|
1170
1409
|
ctx,
|
|
1171
1410
|
transport,
|
|
1411
|
+
broadcast_list,
|
|
1412
|
+
input,
|
|
1413
|
+
input_format,
|
|
1172
1414
|
broadcast_id,
|
|
1173
1415
|
broadcast_code,
|
|
1174
|
-
manufacturer_data,
|
|
1175
1416
|
broadcast_name,
|
|
1176
1417
|
bitrate,
|
|
1177
|
-
|
|
1178
|
-
input_format,
|
|
1418
|
+
manufacturer_data,
|
|
1179
1419
|
):
|
|
1180
1420
|
"""Transmit a broadcast source"""
|
|
1181
|
-
if
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1421
|
+
if broadcast_list:
|
|
1422
|
+
broadcasts = parse_broadcast_list(broadcast_list)
|
|
1423
|
+
if not broadcasts:
|
|
1424
|
+
print(color('!!! Broadcast list is empty or invalid', 'red'))
|
|
1425
|
+
return
|
|
1426
|
+
for broadcast in broadcasts:
|
|
1427
|
+
if not broadcast.sources:
|
|
1428
|
+
print(
|
|
1429
|
+
color(
|
|
1430
|
+
f'!!! Broadcast "{broadcast.broadcast_name}" has no sources',
|
|
1431
|
+
'red',
|
|
1432
|
+
)
|
|
1433
|
+
)
|
|
1434
|
+
return
|
|
1186
1435
|
else:
|
|
1187
|
-
|
|
1436
|
+
if input is None and broadcast_list is None:
|
|
1437
|
+
print(
|
|
1438
|
+
color('!!! --input is required if --broadcast-list is not used', 'red')
|
|
1439
|
+
)
|
|
1440
|
+
return
|
|
1441
|
+
|
|
1442
|
+
if (
|
|
1443
|
+
input == 'device' or input.startswith('device:')
|
|
1444
|
+
) and input_format == 'auto':
|
|
1445
|
+
# Use a default format for device inputs
|
|
1446
|
+
input_format = 'int16le,48000,1'
|
|
1447
|
+
|
|
1448
|
+
if manufacturer_data:
|
|
1449
|
+
vendor_id_str, data_hex = manufacturer_data.split(':')
|
|
1450
|
+
vendor_id = int(vendor_id_str)
|
|
1451
|
+
data = bytes.fromhex(data_hex)
|
|
1452
|
+
manufacturer_data_tuple = (vendor_id, data)
|
|
1453
|
+
else:
|
|
1454
|
+
manufacturer_data_tuple = None
|
|
1455
|
+
|
|
1456
|
+
broadcasts = [
|
|
1457
|
+
Broadcast(
|
|
1458
|
+
sources=[
|
|
1459
|
+
BroadcastSource(
|
|
1460
|
+
input=input,
|
|
1461
|
+
input_format=input_format,
|
|
1462
|
+
bitrate=bitrate,
|
|
1463
|
+
)
|
|
1464
|
+
],
|
|
1465
|
+
public=True,
|
|
1466
|
+
broadcast_id=broadcast_id,
|
|
1467
|
+
broadcast_name=broadcast_name,
|
|
1468
|
+
broadcast_code=broadcast_code,
|
|
1469
|
+
manufacturer_data=manufacturer_data_tuple,
|
|
1470
|
+
language=AURACAST_DEFAULT_LANGUAGE,
|
|
1471
|
+
program_info=AURACAST_DEFAULT_PROGRAM_INFO,
|
|
1472
|
+
)
|
|
1473
|
+
]
|
|
1188
1474
|
|
|
1189
|
-
|
|
1190
|
-
# Use a default format for device inputs
|
|
1191
|
-
input_format = 'int16le,48000,1'
|
|
1475
|
+
assign_broadcast_ids(broadcasts)
|
|
1192
1476
|
|
|
1193
|
-
run_async(
|
|
1194
|
-
run_transmit(
|
|
1195
|
-
transport=transport,
|
|
1196
|
-
broadcast_id=broadcast_id,
|
|
1197
|
-
broadcast_code=broadcast_code,
|
|
1198
|
-
broadcast_name=broadcast_name,
|
|
1199
|
-
bitrate=bitrate,
|
|
1200
|
-
manufacturer_data=manufacturer_data_tuple,
|
|
1201
|
-
input=input,
|
|
1202
|
-
input_format=input_format,
|
|
1203
|
-
)
|
|
1204
|
-
)
|
|
1477
|
+
run_async(run_transmit, transport, broadcasts)
|
|
1205
1478
|
|
|
1206
1479
|
|
|
1207
1480
|
def main():
|