bumble 0.0.204__py3-none-any.whl → 0.0.208__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.
Files changed (51) hide show
  1. bumble/_version.py +9 -4
  2. bumble/apps/auracast.py +631 -98
  3. bumble/apps/bench.py +238 -157
  4. bumble/apps/console.py +19 -12
  5. bumble/apps/controller_info.py +23 -7
  6. bumble/apps/device_info.py +50 -4
  7. bumble/apps/gg_bridge.py +1 -1
  8. bumble/apps/lea_unicast/app.py +61 -201
  9. bumble/att.py +51 -37
  10. bumble/audio/__init__.py +17 -0
  11. bumble/audio/io.py +553 -0
  12. bumble/controller.py +24 -9
  13. bumble/core.py +305 -156
  14. bumble/device.py +1090 -99
  15. bumble/gatt.py +36 -226
  16. bumble/gatt_adapters.py +374 -0
  17. bumble/gatt_client.py +52 -33
  18. bumble/gatt_server.py +5 -5
  19. bumble/hci.py +812 -14
  20. bumble/host.py +367 -65
  21. bumble/l2cap.py +3 -16
  22. bumble/pairing.py +5 -5
  23. bumble/pandora/host.py +7 -12
  24. bumble/profiles/aics.py +48 -57
  25. bumble/profiles/ascs.py +8 -19
  26. bumble/profiles/asha.py +16 -14
  27. bumble/profiles/bass.py +16 -22
  28. bumble/profiles/battery_service.py +13 -3
  29. bumble/profiles/device_information_service.py +16 -14
  30. bumble/profiles/gap.py +12 -8
  31. bumble/profiles/gatt_service.py +167 -0
  32. bumble/profiles/gmap.py +198 -0
  33. bumble/profiles/hap.py +8 -6
  34. bumble/profiles/heart_rate_service.py +20 -4
  35. bumble/profiles/le_audio.py +87 -4
  36. bumble/profiles/mcp.py +11 -9
  37. bumble/profiles/pacs.py +61 -16
  38. bumble/profiles/tmap.py +8 -12
  39. bumble/profiles/{vcp.py → vcs.py} +35 -29
  40. bumble/profiles/vocs.py +62 -85
  41. bumble/sdp.py +223 -93
  42. bumble/smp.py +1 -1
  43. bumble/utils.py +12 -2
  44. bumble/vendor/android/hci.py +1 -1
  45. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/METADATA +13 -11
  46. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/RECORD +50 -46
  47. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/WHEEL +1 -1
  48. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/entry_points.txt +1 -0
  49. bumble/apps/lea_unicast/liblc3.wasm +0 -0
  50. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/LICENSE +0 -0
  51. {bumble-0.0.204.dist-info → bumble-0.0.208.dist-info}/top_level.txt +0 -0
bumble/apps/auracast.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Google LLC
1
+ # Copyright 2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -16,29 +16,49 @@
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
+
19
20
  import asyncio
21
+ import asyncio.subprocess
22
+ import collections
20
23
  import contextlib
21
24
  import dataclasses
25
+ import functools
22
26
  import logging
23
27
  import os
24
- from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
28
+ import struct
29
+ from typing import (
30
+ Any,
31
+ AsyncGenerator,
32
+ Coroutine,
33
+ Deque,
34
+ Optional,
35
+ Tuple,
36
+ )
25
37
 
26
38
  import click
27
39
  import pyee
28
40
 
41
+ try:
42
+ import lc3 # type: ignore # pylint: disable=E0401
43
+ except ImportError as e:
44
+ raise ImportError(
45
+ "Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
46
+ ) from e
47
+
48
+ from bumble.audio import io as audio_io
29
49
  from bumble.colors import color
30
- import bumble.company_ids
31
- import bumble.core
50
+ from bumble import company_ids
51
+ from bumble import core
52
+ from bumble import gatt
53
+ from bumble import hci
54
+ from bumble.profiles import bap
55
+ from bumble.profiles import le_audio
56
+ from bumble.profiles import pbp
57
+ from bumble.profiles import bass
32
58
  import bumble.device
33
- import bumble.gatt
34
- import bumble.hci
35
- import bumble.profiles.bap
36
- import bumble.profiles.bass
37
- import bumble.profiles.pbp
38
59
  import bumble.transport
39
60
  import bumble.utils
40
61
 
41
-
42
62
  # -----------------------------------------------------------------------------
43
63
  # Logging
44
64
  # -----------------------------------------------------------------------------
@@ -49,9 +69,34 @@ logger = logging.getLogger(__name__)
49
69
  # Constants
50
70
  # -----------------------------------------------------------------------------
51
71
  AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
52
- AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address('F0:F1:F2:F3:F4:F5')
72
+ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
53
73
  AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
54
74
  AURACAST_DEFAULT_ATT_MTU = 256
75
+ AURACAST_DEFAULT_FRAME_DURATION = 10000
76
+ AURACAST_DEFAULT_SAMPLE_RATE = 48000
77
+ AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
78
+
79
+
80
+ # -----------------------------------------------------------------------------
81
+ # Utils
82
+ # -----------------------------------------------------------------------------
83
+ def codec_config_string(
84
+ codec_config: bap.CodecSpecificConfiguration, indent: str
85
+ ) -> str:
86
+ lines = []
87
+ if codec_config.sampling_frequency is not None:
88
+ lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz')
89
+ if codec_config.frame_duration is not None:
90
+ lines.append(f'Frame Duration: {codec_config.frame_duration.us} µs')
91
+ if codec_config.octets_per_codec_frame is not None:
92
+ lines.append(f'Frame Size: {codec_config.octets_per_codec_frame} bytes')
93
+ if codec_config.codec_frames_per_sdu is not None:
94
+ lines.append(f'Frames Per SDU: {codec_config.codec_frames_per_sdu}')
95
+ if codec_config.audio_channel_allocation is not None:
96
+ lines.append(
97
+ f'Audio Location: {codec_config.audio_channel_allocation.name}'
98
+ )
99
+ return '\n'.join(indent + line for line in lines)
55
100
 
56
101
 
57
102
  # -----------------------------------------------------------------------------
@@ -62,17 +107,12 @@ class BroadcastScanner(pyee.EventEmitter):
62
107
  class Broadcast(pyee.EventEmitter):
63
108
  name: str | None
64
109
  sync: bumble.device.PeriodicAdvertisingSync
110
+ broadcast_id: int
65
111
  rssi: int = 0
66
- public_broadcast_announcement: Optional[
67
- bumble.profiles.pbp.PublicBroadcastAnnouncement
68
- ] = None
69
- broadcast_audio_announcement: Optional[
70
- bumble.profiles.bap.BroadcastAudioAnnouncement
71
- ] = None
72
- basic_audio_announcement: Optional[
73
- bumble.profiles.bap.BasicAudioAnnouncement
74
- ] = None
75
- appearance: Optional[bumble.core.Appearance] = None
112
+ public_broadcast_announcement: Optional[pbp.PublicBroadcastAnnouncement] = None
113
+ broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
114
+ basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
115
+ appearance: Optional[core.Appearance] = None
76
116
  biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
77
117
  manufacturer_data: Optional[Tuple[str, bytes]] = None
78
118
 
@@ -86,42 +126,32 @@ class BroadcastScanner(pyee.EventEmitter):
86
126
  def update(self, advertisement: bumble.device.Advertisement) -> None:
87
127
  self.rssi = advertisement.rssi
88
128
  for service_data in advertisement.data.get_all(
89
- bumble.core.AdvertisingData.SERVICE_DATA
129
+ core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
90
130
  ):
91
- assert isinstance(service_data, tuple)
92
131
  service_uuid, data = service_data
93
- assert isinstance(data, bytes)
94
132
 
95
- if (
96
- service_uuid
97
- == bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
98
- ):
133
+ if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
99
134
  self.public_broadcast_announcement = (
100
- bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
135
+ pbp.PublicBroadcastAnnouncement.from_bytes(data)
101
136
  )
102
137
  continue
103
138
 
104
- if (
105
- service_uuid
106
- == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
107
- ):
139
+ if service_uuid == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE:
108
140
  self.broadcast_audio_announcement = (
109
- bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
141
+ bap.BroadcastAudioAnnouncement.from_bytes(data)
110
142
  )
111
143
  continue
112
144
 
113
- self.appearance = advertisement.data.get( # type: ignore[assignment]
114
- bumble.core.AdvertisingData.APPEARANCE
145
+ self.appearance = advertisement.data.get(
146
+ core.AdvertisingData.Type.APPEARANCE
115
147
  )
116
148
 
117
149
  if manufacturer_data := advertisement.data.get(
118
- bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
150
+ core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA
119
151
  ):
120
- assert isinstance(manufacturer_data, tuple)
121
- company_id = cast(int, manufacturer_data[0])
122
- data = cast(bytes, manufacturer_data[1])
152
+ company_id, data = manufacturer_data
123
153
  self.manufacturer_data = (
124
- bumble.company_ids.COMPANY_IDENTIFIERS.get(
154
+ company_ids.COMPANY_IDENTIFIERS.get(
125
155
  company_id, f'0x{company_id:04X}'
126
156
  ),
127
157
  data,
@@ -157,18 +187,17 @@ class BroadcastScanner(pyee.EventEmitter):
157
187
  if self.public_broadcast_announcement:
158
188
  print(
159
189
  f' {color("Features", "cyan")}: '
160
- f'{self.public_broadcast_announcement.features}'
161
- )
162
- print(
163
- f' {color("Metadata", "cyan")}: '
164
- f'{self.public_broadcast_announcement.metadata}'
190
+ f'{self.public_broadcast_announcement.features.name}'
165
191
  )
192
+ print(f' {color("Metadata", "cyan")}:')
193
+ print(self.public_broadcast_announcement.metadata.pretty_print(' '))
166
194
 
167
195
  if self.basic_audio_announcement:
168
196
  print(color(' Audio:', 'cyan'))
169
197
  print(
170
198
  color(' Presentation Delay:', 'magenta'),
171
199
  self.basic_audio_announcement.presentation_delay,
200
+ "µs",
172
201
  )
173
202
  for subgroup in self.basic_audio_announcement.subgroups:
174
203
  print(color(' Subgroup:', 'magenta'))
@@ -185,17 +214,22 @@ class BroadcastScanner(pyee.EventEmitter):
185
214
  color(' Vendor Specific Codec ID:', 'green'),
186
215
  subgroup.codec_id.vendor_specific_codec_id,
187
216
  )
217
+ print(color(' Codec Config:', 'yellow'))
188
218
  print(
189
- color(' Codec Config:', 'yellow'),
190
- subgroup.codec_specific_configuration,
219
+ codec_config_string(
220
+ subgroup.codec_specific_configuration, ' '
221
+ ),
191
222
  )
192
- print(color(' Metadata: ', 'yellow'), subgroup.metadata)
223
+ print(color(' Metadata: ', 'yellow'))
224
+ print(subgroup.metadata.pretty_print(' '))
193
225
 
194
226
  for bis in subgroup.bis:
195
227
  print(color(f' BIS [{bis.index}]:', 'yellow'))
228
+ print(color(' Codec Config:', 'green'))
196
229
  print(
197
- color(' Codec Config:', 'green'),
198
- bis.codec_specific_configuration,
230
+ codec_config_string(
231
+ bis.codec_specific_configuration, ' '
232
+ ),
199
233
  )
200
234
 
201
235
  if self.biginfo:
@@ -232,15 +266,13 @@ class BroadcastScanner(pyee.EventEmitter):
232
266
  return
233
267
 
234
268
  for service_data in advertisement.data.get_all(
235
- bumble.core.AdvertisingData.SERVICE_DATA
269
+ core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
236
270
  ):
237
- assert isinstance(service_data, tuple)
238
271
  service_uuid, data = service_data
239
- assert isinstance(data, bytes)
240
272
 
241
- if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
273
+ if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
242
274
  self.basic_audio_announcement = (
243
- bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
275
+ bap.BasicAudioAnnouncement.from_bytes(data)
244
276
  )
245
277
  break
246
278
 
@@ -262,7 +294,7 @@ class BroadcastScanner(pyee.EventEmitter):
262
294
  self.device = device
263
295
  self.filter_duplicates = filter_duplicates
264
296
  self.sync_timeout = sync_timeout
265
- self.broadcasts: Dict[bumble.hci.Address, BroadcastScanner.Broadcast] = {}
297
+ self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
266
298
  device.on('advertisement', self.on_advertisement)
267
299
 
268
300
  async def start(self) -> None:
@@ -277,33 +309,43 @@ class BroadcastScanner(pyee.EventEmitter):
277
309
  def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
278
310
  if not (
279
311
  ads := advertisement.data.get_all(
280
- bumble.core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
312
+ core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
281
313
  )
282
314
  ) or not (
283
- any(
284
- ad
285
- for ad in ads
286
- if isinstance(ad, tuple)
287
- and ad[0] == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
315
+ broadcast_audio_announcement := next(
316
+ (
317
+ ad
318
+ for ad in ads
319
+ if ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
320
+ ),
321
+ None,
288
322
  )
289
323
  ):
290
324
  return
291
325
 
292
- broadcast_name = advertisement.data.get(
293
- bumble.core.AdvertisingData.BROADCAST_NAME
326
+ broadcast_name = advertisement.data.get_all(
327
+ core.AdvertisingData.Type.BROADCAST_NAME
294
328
  )
295
- assert isinstance(broadcast_name, str) or broadcast_name is None
296
329
 
297
330
  if broadcast := self.broadcasts.get(advertisement.address):
298
331
  broadcast.update(advertisement)
299
332
  return
300
333
 
301
334
  bumble.utils.AsyncRunner.spawn(
302
- self.on_new_broadcast(broadcast_name, advertisement)
335
+ self.on_new_broadcast(
336
+ broadcast_name[0] if broadcast_name else None,
337
+ advertisement,
338
+ bap.BroadcastAudioAnnouncement.from_bytes(
339
+ broadcast_audio_announcement[1]
340
+ ).broadcast_id,
341
+ )
303
342
  )
304
343
 
305
344
  async def on_new_broadcast(
306
- self, name: str | None, advertisement: bumble.device.Advertisement
345
+ self,
346
+ name: str | None,
347
+ advertisement: bumble.device.Advertisement,
348
+ broadcast_id: int,
307
349
  ) -> None:
308
350
  periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
309
351
  advertiser_address=advertisement.address,
@@ -311,7 +353,7 @@ class BroadcastScanner(pyee.EventEmitter):
311
353
  sync_timeout=self.sync_timeout,
312
354
  filter_duplicates=self.filter_duplicates,
313
355
  )
314
- broadcast = self.Broadcast(name, periodic_advertising_sync)
356
+ broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
315
357
  broadcast.update(advertisement)
316
358
  self.broadcasts[advertisement.address] = broadcast
317
359
  periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
@@ -323,10 +365,11 @@ class BroadcastScanner(pyee.EventEmitter):
323
365
  self.emit('broadcast_loss', broadcast)
324
366
 
325
367
 
326
- class PrintingBroadcastScanner:
368
+ class PrintingBroadcastScanner(pyee.EventEmitter):
327
369
  def __init__(
328
370
  self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
329
371
  ) -> None:
372
+ super().__init__()
330
373
  self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
331
374
  self.scanner.on('new_broadcast', self.on_new_broadcast)
332
375
  self.scanner.on('broadcast_loss', self.on_broadcast_loss)
@@ -461,27 +504,34 @@ async def run_assist(
461
504
  await peer.request_mtu(mtu)
462
505
 
463
506
  # Get the BASS service
464
- bass = await peer.discover_service_and_create_proxy(
465
- bumble.profiles.bass.BroadcastAudioScanServiceProxy
507
+ bass_client = await peer.discover_service_and_create_proxy(
508
+ bass.BroadcastAudioScanServiceProxy
466
509
  )
467
510
 
468
511
  # Check that the service was found
469
- if not bass:
512
+ if not bass_client:
470
513
  print(color('!!! Broadcast Audio Scan Service not found', 'red'))
471
514
  return
472
515
 
473
516
  # Subscribe to and read the broadcast receive state characteristics
474
- for i, broadcast_receive_state in enumerate(bass.broadcast_receive_states):
517
+ def on_broadcast_receive_state_update(
518
+ value: bass.BroadcastReceiveState, index: int
519
+ ) -> None:
520
+ print(
521
+ f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
522
+ )
523
+
524
+ for i, broadcast_receive_state in enumerate(
525
+ bass_client.broadcast_receive_states
526
+ ):
475
527
  try:
476
528
  await broadcast_receive_state.subscribe(
477
- lambda value, i=i: print(
478
- f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
479
- )
529
+ functools.partial(on_broadcast_receive_state_update, index=i)
480
530
  )
481
- except bumble.core.ProtocolError as error:
531
+ except core.ProtocolError as error:
482
532
  print(
483
533
  color(
484
- f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
534
+ '!!! Failed to subscribe to Broadcast Receive State characteristic',
485
535
  'red',
486
536
  ),
487
537
  error,
@@ -497,7 +547,7 @@ async def run_assist(
497
547
 
498
548
  if command == 'add-source':
499
549
  # Find the requested broadcast
500
- await bass.remote_scan_started()
550
+ await bass_client.remote_scan_started()
501
551
  if broadcast_name:
502
552
  print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
503
553
  else:
@@ -517,15 +567,15 @@ async def run_assist(
517
567
 
518
568
  # Add the source
519
569
  print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
520
- await bass.add_source(
570
+ await bass_client.add_source(
521
571
  broadcast.sync.advertiser_address,
522
572
  broadcast.sync.sid,
523
573
  broadcast.broadcast_audio_announcement.broadcast_id,
524
- bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
574
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
525
575
  0xFFFF,
526
576
  [
527
- bumble.profiles.bass.SubgroupInfo(
528
- bumble.profiles.bass.SubgroupInfo.ANY_BIS,
577
+ bass.SubgroupInfo(
578
+ bass.SubgroupInfo.ANY_BIS,
529
579
  bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
530
580
  )
531
581
  ],
@@ -535,7 +585,7 @@ async def run_assist(
535
585
  await broadcast.sync.transfer(peer.connection)
536
586
 
537
587
  # Notify the sink that we're done scanning.
538
- await bass.remote_scan_stopped()
588
+ await bass_client.remote_scan_stopped()
539
589
 
540
590
  await peer.sustain()
541
591
  return
@@ -546,7 +596,7 @@ async def run_assist(
546
596
  return
547
597
 
548
598
  # Find the requested broadcast
549
- await bass.remote_scan_started()
599
+ await bass_client.remote_scan_started()
550
600
  if broadcast_name:
551
601
  print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
552
602
  else:
@@ -569,13 +619,13 @@ async def run_assist(
569
619
  color('Modifying source:', 'blue'),
570
620
  source_id,
571
621
  )
572
- await bass.modify_source(
622
+ await bass_client.modify_source(
573
623
  source_id,
574
- bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
624
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
575
625
  0xFFFF,
576
626
  [
577
- bumble.profiles.bass.SubgroupInfo(
578
- bumble.profiles.bass.SubgroupInfo.ANY_BIS,
627
+ bass.SubgroupInfo(
628
+ bass.SubgroupInfo.ANY_BIS,
579
629
  bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
580
630
  )
581
631
  ],
@@ -590,7 +640,7 @@ async def run_assist(
590
640
 
591
641
  # Remove the source
592
642
  print(color('Removing source:', 'blue'), source_id)
593
- await bass.remove_source(source_id)
643
+ await bass_client.remove_source(source_id)
594
644
  await peer.sustain()
595
645
  return
596
646
 
@@ -610,14 +660,339 @@ async def run_pair(transport: str, address: str) -> None:
610
660
  print("+++ Paired")
611
661
 
612
662
 
663
+ async def run_receive(
664
+ transport: str,
665
+ broadcast_id: Optional[int],
666
+ output: str,
667
+ broadcast_code: str | None,
668
+ sync_timeout: float,
669
+ subgroup_index: int,
670
+ ) -> None:
671
+ # Run a pre-flight check for the output.
672
+ try:
673
+ if not audio_io.check_audio_output(output):
674
+ return
675
+ except ValueError as error:
676
+ print(error)
677
+ return
678
+
679
+ async with create_device(transport) as device:
680
+ if not device.supports_le_periodic_advertising:
681
+ print(color('Periodic advertising not supported', 'red'))
682
+ return
683
+
684
+ scanner = BroadcastScanner(device, False, sync_timeout)
685
+ scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
686
+ asyncio.get_running_loop().create_future()
687
+ )
688
+
689
+ def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
690
+ if scan_result.done():
691
+ return
692
+ if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
693
+ scan_result.set_result(broadcast)
694
+
695
+ scanner.on('new_broadcast', on_new_broadcast)
696
+ await scanner.start()
697
+ print('Start scanning...')
698
+ broadcast = await scan_result
699
+ print('Advertisement found:')
700
+ broadcast.print()
701
+ basic_audio_announcement_scanned = asyncio.Event()
702
+
703
+ def on_change() -> None:
704
+ if (
705
+ broadcast.basic_audio_announcement
706
+ and not basic_audio_announcement_scanned.is_set()
707
+ ):
708
+ basic_audio_announcement_scanned.set()
709
+
710
+ broadcast.on('change', on_change)
711
+ if not broadcast.basic_audio_announcement:
712
+ print('Wait for Basic Audio Announcement...')
713
+ await basic_audio_announcement_scanned.wait()
714
+ print('Basic Audio Announcement found')
715
+ broadcast.print()
716
+ print('Stop scanning')
717
+ await scanner.stop()
718
+ print('Start sync to BIG')
719
+
720
+ assert broadcast.basic_audio_announcement
721
+ subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
722
+ configuration = subgroup.codec_specific_configuration
723
+ assert configuration
724
+ assert (sampling_frequency := configuration.sampling_frequency)
725
+ assert (frame_duration := configuration.frame_duration)
726
+
727
+ big_sync = await device.create_big_sync(
728
+ broadcast.sync,
729
+ bumble.device.BigSyncParameters(
730
+ big_sync_timeout=0x4000,
731
+ bis=[bis.index for bis in subgroup.bis],
732
+ broadcast_code=(
733
+ bytes.fromhex(broadcast_code) if broadcast_code else None
734
+ ),
735
+ ),
736
+ )
737
+ num_bis = len(big_sync.bis_links)
738
+ decoder = lc3.Decoder(
739
+ frame_duration_us=frame_duration.us,
740
+ sample_rate_hz=sampling_frequency.hz,
741
+ num_channels=num_bis,
742
+ )
743
+ lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)]
744
+ packet_stats = [0, 0]
745
+
746
+ audio_output = await audio_io.create_audio_output(output)
747
+ # This try should be replaced with contextlib.aclosing() when python 3.9 is no
748
+ # longer needed.
749
+ try:
750
+ await audio_output.open(
751
+ audio_io.PcmFormat(
752
+ audio_io.PcmFormat.Endianness.LITTLE,
753
+ audio_io.PcmFormat.SampleType.FLOAT32,
754
+ sampling_frequency.hz,
755
+ num_bis,
756
+ )
757
+ )
758
+
759
+ def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket):
760
+ # TODO: re-assemble fragments and detect errors
761
+ queue.append(packet.iso_sdu_fragment)
762
+
763
+ while all(lc3_queues):
764
+ # This assumes SDUs contain one LC3 frame each, which may not
765
+ # be correct for all cases. TODO: revisit this assumption.
766
+ frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
767
+ if not frame:
768
+ print(color('!!! received empty frame', 'red'))
769
+ continue
770
+
771
+ packet_stats[0] += len(frame)
772
+ packet_stats[1] += 1
773
+ print(
774
+ f'\rRECEIVED: {packet_stats[0]} bytes in '
775
+ f'{packet_stats[1]} packets',
776
+ end='',
777
+ )
778
+
779
+ try:
780
+ pcm = decoder.decode(frame).tobytes()
781
+ except lc3.BaseError as error:
782
+ print(color(f'!!! LC3 decoding error: {error}'))
783
+ continue
784
+
785
+ audio_output.write(pcm)
786
+
787
+ for i, bis_link in enumerate(big_sync.bis_links):
788
+ print(f'Setup ISO for BIS {bis_link.handle}')
789
+ bis_link.sink = functools.partial(sink, lc3_queues[i])
790
+ await bis_link.setup_data_path(
791
+ direction=bis_link.Direction.CONTROLLER_TO_HOST
792
+ )
793
+
794
+ terminated = asyncio.Event()
795
+ big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
796
+ await terminated.wait()
797
+ finally:
798
+ await audio_output.aclose()
799
+
800
+
801
+ async def run_transmit(
802
+ transport: str,
803
+ broadcast_id: int,
804
+ broadcast_code: str | None,
805
+ broadcast_name: str,
806
+ bitrate: int,
807
+ manufacturer_data: tuple[int, bytes] | None,
808
+ input: str,
809
+ input_format: str,
810
+ ) -> None:
811
+ # Run a pre-flight check for the input.
812
+ try:
813
+ if not audio_io.check_audio_input(input):
814
+ return
815
+ except ValueError as error:
816
+ print(error)
817
+ return
818
+
819
+ async with create_device(transport) as device:
820
+ if not device.supports_le_periodic_advertising:
821
+ print(color('Periodic advertising not supported', 'red'))
822
+ return
823
+
824
+ basic_audio_announcement = bap.BasicAudioAnnouncement(
825
+ presentation_delay=40000,
826
+ subgroups=[
827
+ bap.BasicAudioAnnouncement.Subgroup(
828
+ codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
829
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
830
+ sampling_frequency=bap.SamplingFrequency.FREQ_48000,
831
+ frame_duration=bap.FrameDuration.DURATION_10000_US,
832
+ octets_per_codec_frame=100,
833
+ ),
834
+ metadata=le_audio.Metadata(
835
+ [
836
+ le_audio.Metadata.Entry(
837
+ tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
838
+ ),
839
+ le_audio.Metadata.Entry(
840
+ tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
841
+ ),
842
+ ]
843
+ ),
844
+ bis=[
845
+ bap.BasicAudioAnnouncement.BIS(
846
+ index=1,
847
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
848
+ audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
849
+ ),
850
+ ),
851
+ bap.BasicAudioAnnouncement.BIS(
852
+ index=2,
853
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
854
+ audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
855
+ ),
856
+ ),
857
+ ],
858
+ )
859
+ ],
860
+ )
861
+ broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
862
+
863
+ advertising_manufacturer_data = (
864
+ b''
865
+ if manufacturer_data is None
866
+ else bytes(
867
+ core.AdvertisingData(
868
+ [
869
+ (
870
+ core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
871
+ struct.pack('<H', manufacturer_data[0])
872
+ + manufacturer_data[1],
873
+ )
874
+ ]
875
+ )
876
+ )
877
+ )
878
+
879
+ advertising_set = await device.create_advertising_set(
880
+ advertising_parameters=bumble.device.AdvertisingParameters(
881
+ advertising_event_properties=bumble.device.AdvertisingEventProperties(
882
+ is_connectable=False
883
+ ),
884
+ primary_advertising_interval_min=100,
885
+ primary_advertising_interval_max=200,
886
+ ),
887
+ advertising_data=(
888
+ broadcast_audio_announcement.get_advertising_data()
889
+ + bytes(
890
+ core.AdvertisingData(
891
+ [(core.AdvertisingData.BROADCAST_NAME, broadcast_name.encode())]
892
+ )
893
+ )
894
+ + advertising_manufacturer_data
895
+ ),
896
+ periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
897
+ periodic_advertising_interval_min=80,
898
+ periodic_advertising_interval_max=160,
899
+ ),
900
+ periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
901
+ auto_restart=True,
902
+ auto_start=True,
903
+ )
904
+
905
+ print('Start Periodic Advertising')
906
+ await advertising_set.start_periodic()
907
+
908
+ audio_input = await audio_io.create_audio_input(input, input_format)
909
+ pcm_format = await audio_input.open()
910
+ # This try should be replaced with contextlib.aclosing() when python 3.9 is no
911
+ # longer needed.
912
+ try:
913
+ if pcm_format.channels != 2:
914
+ print("Only 2 channels PCM configurations are supported")
915
+ return
916
+ if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
917
+ pcm_bit_depth = 16
918
+ elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
919
+ pcm_bit_depth = None
920
+ else:
921
+ print("Only INT16 and FLOAT32 sample types are supported")
922
+ return
923
+
924
+ encoder = lc3.Encoder(
925
+ frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
926
+ sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
927
+ num_channels=pcm_format.channels,
928
+ input_sample_rate_hz=pcm_format.sample_rate,
929
+ )
930
+ lc3_frame_samples = encoder.get_frame_samples()
931
+ lc3_frame_size = encoder.get_frame_bytes(bitrate)
932
+ print(
933
+ f'Encoding with {lc3_frame_samples} '
934
+ f'PCM samples per {lc3_frame_size} byte frame'
935
+ )
936
+
937
+ print('Setup BIG')
938
+ big = await device.create_big(
939
+ advertising_set,
940
+ parameters=bumble.device.BigParameters(
941
+ num_bis=pcm_format.channels,
942
+ sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
943
+ max_sdu=lc3_frame_size,
944
+ max_transport_latency=65,
945
+ rtn=4,
946
+ broadcast_code=(
947
+ bytes.fromhex(broadcast_code) if broadcast_code else None
948
+ ),
949
+ ),
950
+ )
951
+ for bis_link in big.bis_links:
952
+ print(f'Setup ISO for BIS {bis_link.handle}')
953
+ await bis_link.setup_data_path(
954
+ direction=bis_link.Direction.HOST_TO_CONTROLLER
955
+ )
956
+
957
+ iso_queues = [
958
+ bumble.device.IsoPacketStream(bis_link, 64)
959
+ for bis_link in big.bis_links
960
+ ]
961
+
962
+ def on_flow():
963
+ data_packet_queue = iso_queues[0].data_packet_queue
964
+ print(
965
+ f'\rPACKETS: pending={data_packet_queue.pending}, '
966
+ f'queued={data_packet_queue.queued}, '
967
+ f'completed={data_packet_queue.completed}',
968
+ end='',
969
+ )
970
+
971
+ iso_queues[0].data_packet_queue.on('flow', on_flow)
972
+
973
+ frame_count = 0
974
+ async for pcm_frame in audio_input.frames(lc3_frame_samples):
975
+ lc3_frame = encoder.encode(
976
+ pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
977
+ )
978
+
979
+ mid = len(lc3_frame) // 2
980
+ await iso_queues[0].write(lc3_frame[:mid])
981
+ await iso_queues[1].write(lc3_frame[mid:])
982
+
983
+ frame_count += 1
984
+ finally:
985
+ await audio_input.aclose()
986
+
987
+
613
988
  def run_async(async_command: Coroutine) -> None:
614
989
  try:
615
990
  asyncio.run(async_command)
616
- except bumble.core.ProtocolError as error:
991
+ except core.ProtocolError as error:
617
992
  if error.error_namespace == 'att' and error.error_code in list(
618
- bumble.profiles.bass.ApplicationError
993
+ bass.ApplicationError
619
994
  ):
620
- message = bumble.profiles.bass.ApplicationError(error.error_code).name
995
+ message = bass.ApplicationError(error.error_code).name
621
996
  else:
622
997
  message = str(error)
623
998
 
@@ -631,9 +1006,7 @@ def run_async(async_command: Coroutine) -> None:
631
1006
  # -----------------------------------------------------------------------------
632
1007
  @click.group()
633
1008
  @click.pass_context
634
- def auracast(
635
- ctx,
636
- ):
1009
+ def auracast(ctx):
637
1010
  ctx.ensure_object(dict)
638
1011
 
639
1012
 
@@ -678,7 +1051,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
678
1051
  @click.argument('address')
679
1052
  @click.pass_context
680
1053
  def assist(ctx, broadcast_name, source_id, command, transport, address):
681
- """Scan for broadcasts on behalf of a audio server"""
1054
+ """Scan for broadcasts on behalf of an audio server"""
682
1055
  run_async(run_assist(broadcast_name, source_id, command, transport, address))
683
1056
 
684
1057
 
@@ -691,6 +1064,166 @@ def pair(ctx, transport, address):
691
1064
  run_async(run_pair(transport, address))
692
1065
 
693
1066
 
1067
+ @auracast.command('receive')
1068
+ @click.argument('transport')
1069
+ @click.argument(
1070
+ 'broadcast_id',
1071
+ type=int,
1072
+ required=False,
1073
+ )
1074
+ @click.option(
1075
+ '--output',
1076
+ default='device',
1077
+ help=(
1078
+ "Audio output. "
1079
+ "'device' -> use the host's default sound output device, "
1080
+ "'device:<DEVICE_ID>' -> use one of the host's sound output device "
1081
+ "(specify 'device:?' to get a list of available sound output devices), "
1082
+ "'stdout' -> send audio to stdout, "
1083
+ "'file:<filename> -> write audio to a raw float32 PCM file, "
1084
+ "'ffplay' -> pipe the audio to ffplay"
1085
+ ),
1086
+ )
1087
+ @click.option(
1088
+ '--broadcast-code',
1089
+ metavar='BROADCAST_CODE',
1090
+ type=str,
1091
+ help='Broadcast encryption code in hex format',
1092
+ )
1093
+ @click.option(
1094
+ '--sync-timeout',
1095
+ metavar='SYNC_TIMEOUT',
1096
+ type=float,
1097
+ default=AURACAST_DEFAULT_SYNC_TIMEOUT,
1098
+ help='Sync timeout (in seconds)',
1099
+ )
1100
+ @click.option(
1101
+ '--subgroup',
1102
+ metavar='SUBGROUP',
1103
+ type=int,
1104
+ default=0,
1105
+ help='Index of Subgroup',
1106
+ )
1107
+ @click.pass_context
1108
+ def receive(
1109
+ ctx,
1110
+ transport,
1111
+ broadcast_id,
1112
+ output,
1113
+ broadcast_code,
1114
+ sync_timeout,
1115
+ subgroup,
1116
+ ):
1117
+ """Receive a broadcast source"""
1118
+ run_async(
1119
+ run_receive(
1120
+ transport,
1121
+ broadcast_id,
1122
+ output,
1123
+ broadcast_code,
1124
+ sync_timeout,
1125
+ subgroup,
1126
+ )
1127
+ )
1128
+
1129
+
1130
+ @auracast.command('transmit')
1131
+ @click.argument('transport')
1132
+ @click.option(
1133
+ '--input',
1134
+ required=True,
1135
+ help=(
1136
+ "Audio input. "
1137
+ "'device' -> use the host's default sound input device, "
1138
+ "'device:<DEVICE_ID>' -> use one of the host's sound input devices "
1139
+ "(specify 'device:?' to get a list of available sound input devices), "
1140
+ "'stdin' -> receive audio from stdin as int16 PCM, "
1141
+ "'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
1142
+ "(The file: prefix may be omitted if the file path does not start with "
1143
+ "the substring 'device:' or 'file:' and is not 'stdin')"
1144
+ ),
1145
+ )
1146
+ @click.option(
1147
+ '--input-format',
1148
+ metavar="FORMAT",
1149
+ default='auto',
1150
+ help=(
1151
+ "Audio input format. "
1152
+ "Use 'auto' for .wav files, or for the default setting with the devices. "
1153
+ "For other inputs, the format is specified as "
1154
+ "<sample-type>,<sample-rate>,<channels> (supported <sample-type>: 'int16le' "
1155
+ "for 16-bit signed integers with little-endian byte order or 'float32le' for "
1156
+ "32-bit floating point with little-endian byte order)"
1157
+ ),
1158
+ )
1159
+ @click.option(
1160
+ '--broadcast-id',
1161
+ metavar='BROADCAST_ID',
1162
+ type=int,
1163
+ default=123456,
1164
+ help='Broadcast ID',
1165
+ )
1166
+ @click.option(
1167
+ '--broadcast-code',
1168
+ metavar='BROADCAST_CODE',
1169
+ help='Broadcast encryption code in hex format',
1170
+ )
1171
+ @click.option(
1172
+ '--broadcast-name',
1173
+ metavar='BROADCAST_NAME',
1174
+ default='Bumble Auracast',
1175
+ help='Broadcast name',
1176
+ )
1177
+ @click.option(
1178
+ '--bitrate',
1179
+ type=int,
1180
+ default=AURACAST_DEFAULT_TRANSMIT_BITRATE,
1181
+ help='Bitrate, per channel, in bps',
1182
+ )
1183
+ @click.option(
1184
+ '--manufacturer-data',
1185
+ metavar='VENDOR-ID:DATA-HEX',
1186
+ help='Manufacturer data (specify as <vendor-id>:<data-hex>)',
1187
+ )
1188
+ @click.pass_context
1189
+ def transmit(
1190
+ ctx,
1191
+ transport,
1192
+ broadcast_id,
1193
+ broadcast_code,
1194
+ manufacturer_data,
1195
+ broadcast_name,
1196
+ bitrate,
1197
+ input,
1198
+ input_format,
1199
+ ):
1200
+ """Transmit a broadcast source"""
1201
+ if manufacturer_data:
1202
+ vendor_id_str, data_hex = manufacturer_data.split(':')
1203
+ vendor_id = int(vendor_id_str)
1204
+ data = bytes.fromhex(data_hex)
1205
+ manufacturer_data_tuple = (vendor_id, data)
1206
+ else:
1207
+ manufacturer_data_tuple = None
1208
+
1209
+ if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
1210
+ # Use a default format for device inputs
1211
+ input_format = 'int16le,48000,1'
1212
+
1213
+ run_async(
1214
+ run_transmit(
1215
+ transport=transport,
1216
+ broadcast_id=broadcast_id,
1217
+ broadcast_code=broadcast_code,
1218
+ broadcast_name=broadcast_name,
1219
+ bitrate=bitrate,
1220
+ manufacturer_data=manufacturer_data_tuple,
1221
+ input=input,
1222
+ input_format=input_format,
1223
+ )
1224
+ )
1225
+
1226
+
694
1227
  def main():
695
1228
  logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
696
1229
  auracast()