bumble 0.0.194__py3-none-any.whl → 0.0.198__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 (54) hide show
  1. bumble/_version.py +2 -2
  2. bumble/apps/auracast.py +692 -0
  3. bumble/apps/bench.py +77 -23
  4. bumble/apps/console.py +5 -20
  5. bumble/apps/controller_info.py +3 -3
  6. bumble/apps/device_info.py +230 -0
  7. bumble/apps/gatt_dump.py +4 -0
  8. bumble/apps/lea_unicast/app.py +16 -17
  9. bumble/at.py +12 -6
  10. bumble/avc.py +8 -5
  11. bumble/avctp.py +3 -2
  12. bumble/avdtp.py +5 -1
  13. bumble/avrcp.py +2 -1
  14. bumble/codecs.py +17 -13
  15. bumble/colors.py +6 -2
  16. bumble/core.py +726 -122
  17. bumble/device.py +817 -117
  18. bumble/drivers/rtk.py +13 -8
  19. bumble/gatt.py +6 -1
  20. bumble/gatt_client.py +10 -4
  21. bumble/hci.py +283 -20
  22. bumble/hid.py +24 -28
  23. bumble/host.py +29 -0
  24. bumble/l2cap.py +24 -17
  25. bumble/link.py +8 -3
  26. bumble/pandora/host.py +3 -2
  27. bumble/profiles/ascs.py +739 -0
  28. bumble/profiles/bap.py +85 -862
  29. bumble/profiles/bass.py +440 -0
  30. bumble/profiles/csip.py +4 -4
  31. bumble/profiles/gap.py +110 -0
  32. bumble/profiles/heart_rate_service.py +4 -3
  33. bumble/profiles/le_audio.py +83 -0
  34. bumble/profiles/mcp.py +448 -0
  35. bumble/profiles/pacs.py +210 -0
  36. bumble/profiles/pbp.py +46 -0
  37. bumble/profiles/tmap.py +89 -0
  38. bumble/rfcomm.py +14 -3
  39. bumble/sdp.py +13 -11
  40. bumble/smp.py +20 -8
  41. bumble/snoop.py +5 -4
  42. bumble/transport/__init__.py +8 -2
  43. bumble/transport/android_emulator.py +9 -3
  44. bumble/transport/android_netsim.py +9 -7
  45. bumble/transport/common.py +46 -18
  46. bumble/transport/pyusb.py +2 -2
  47. bumble/transport/unix.py +56 -0
  48. bumble/transport/usb.py +57 -46
  49. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/METADATA +41 -41
  50. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/RECORD +54 -43
  51. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/WHEEL +1 -1
  52. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/LICENSE +0 -0
  53. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/entry_points.txt +0 -0
  54. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/top_level.txt +0 -0
bumble/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.0.194'
16
- __version_tuple__ = version_tuple = (0, 0, 194)
15
+ __version__ = version = '0.0.198'
16
+ __version_tuple__ = version_tuple = (0, 0, 198)
@@ -0,0 +1,692 @@
1
+ # Copyright 2024 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # -----------------------------------------------------------------------------
16
+ # Imports
17
+ # -----------------------------------------------------------------------------
18
+ from __future__ import annotations
19
+ import asyncio
20
+ import contextlib
21
+ import dataclasses
22
+ import logging
23
+ import os
24
+ from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
25
+
26
+ import click
27
+ import pyee
28
+
29
+ from bumble.colors import color
30
+ import bumble.company_ids
31
+ import bumble.core
32
+ 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
+ import bumble.transport
39
+ import bumble.utils
40
+
41
+
42
+ # -----------------------------------------------------------------------------
43
+ # Logging
44
+ # -----------------------------------------------------------------------------
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # -----------------------------------------------------------------------------
49
+ # Constants
50
+ # -----------------------------------------------------------------------------
51
+ AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
52
+ AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address('F0:F1:F2:F3:F4:F5')
53
+ AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
54
+ AURACAST_DEFAULT_ATT_MTU = 256
55
+
56
+
57
+ # -----------------------------------------------------------------------------
58
+ # Scan For Broadcasts
59
+ # -----------------------------------------------------------------------------
60
+ class BroadcastScanner(pyee.EventEmitter):
61
+ @dataclasses.dataclass
62
+ class Broadcast(pyee.EventEmitter):
63
+ name: str
64
+ sync: bumble.device.PeriodicAdvertisingSync
65
+ 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
76
+ biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
77
+ manufacturer_data: Optional[Tuple[str, bytes]] = None
78
+
79
+ def __post_init__(self) -> None:
80
+ super().__init__()
81
+ self.sync.on('establishment', self.on_sync_establishment)
82
+ self.sync.on('loss', self.on_sync_loss)
83
+ self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
84
+ self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)
85
+
86
+ def update(self, advertisement: bumble.device.Advertisement) -> None:
87
+ self.rssi = advertisement.rssi
88
+ for service_data in advertisement.data.get_all(
89
+ bumble.core.AdvertisingData.SERVICE_DATA
90
+ ):
91
+ assert isinstance(service_data, tuple)
92
+ service_uuid, data = service_data
93
+ assert isinstance(data, bytes)
94
+
95
+ if (
96
+ service_uuid
97
+ == bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
98
+ ):
99
+ self.public_broadcast_announcement = (
100
+ bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
101
+ )
102
+ continue
103
+
104
+ if (
105
+ service_uuid
106
+ == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
107
+ ):
108
+ self.broadcast_audio_announcement = (
109
+ bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
110
+ )
111
+ continue
112
+
113
+ self.appearance = advertisement.data.get( # type: ignore[assignment]
114
+ bumble.core.AdvertisingData.APPEARANCE
115
+ )
116
+
117
+ if manufacturer_data := advertisement.data.get(
118
+ bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
119
+ ):
120
+ assert isinstance(manufacturer_data, tuple)
121
+ company_id = cast(int, manufacturer_data[0])
122
+ data = cast(bytes, manufacturer_data[1])
123
+ self.manufacturer_data = (
124
+ bumble.company_ids.COMPANY_IDENTIFIERS.get(
125
+ company_id, f'0x{company_id:04X}'
126
+ ),
127
+ data,
128
+ )
129
+
130
+ self.emit('update')
131
+
132
+ def print(self) -> None:
133
+ print(
134
+ color('Broadcast:', 'yellow'),
135
+ self.sync.advertiser_address,
136
+ color(self.sync.state.name, 'green'),
137
+ )
138
+ print(f' {color("Name", "cyan")}: {self.name}')
139
+ if self.appearance:
140
+ print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
141
+ print(f' {color("RSSI", "cyan")}: {self.rssi}')
142
+ print(f' {color("SID", "cyan")}: {self.sync.sid}')
143
+
144
+ if self.manufacturer_data:
145
+ print(
146
+ f' {color("Manufacturer Data", "cyan")}: '
147
+ f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
148
+ )
149
+
150
+ if self.broadcast_audio_announcement:
151
+ print(
152
+ f' {color("Broadcast ID", "cyan")}: '
153
+ f'{self.broadcast_audio_announcement.broadcast_id}'
154
+ )
155
+
156
+ if self.public_broadcast_announcement:
157
+ print(
158
+ f' {color("Features", "cyan")}: '
159
+ f'{self.public_broadcast_announcement.features}'
160
+ )
161
+ print(
162
+ f' {color("Metadata", "cyan")}: '
163
+ f'{self.public_broadcast_announcement.metadata}'
164
+ )
165
+
166
+ if self.basic_audio_announcement:
167
+ print(color(' Audio:', 'cyan'))
168
+ print(
169
+ color(' Presentation Delay:', 'magenta'),
170
+ self.basic_audio_announcement.presentation_delay,
171
+ )
172
+ for subgroup in self.basic_audio_announcement.subgroups:
173
+ print(color(' Subgroup:', 'magenta'))
174
+ print(color(' Codec ID:', 'yellow'))
175
+ print(
176
+ color(' Coding Format: ', 'green'),
177
+ subgroup.codec_id.coding_format.name,
178
+ )
179
+ print(
180
+ color(' Company ID: ', 'green'),
181
+ subgroup.codec_id.company_id,
182
+ )
183
+ print(
184
+ color(' Vendor Specific Codec ID:', 'green'),
185
+ subgroup.codec_id.vendor_specific_codec_id,
186
+ )
187
+ print(
188
+ color(' Codec Config:', 'yellow'),
189
+ subgroup.codec_specific_configuration,
190
+ )
191
+ print(color(' Metadata: ', 'yellow'), subgroup.metadata)
192
+
193
+ for bis in subgroup.bis:
194
+ print(color(f' BIS [{bis.index}]:', 'yellow'))
195
+ print(
196
+ color(' Codec Config:', 'green'),
197
+ bis.codec_specific_configuration,
198
+ )
199
+
200
+ if self.biginfo:
201
+ print(color(' BIG:', 'cyan'))
202
+ print(
203
+ color(' Number of BIS:', 'magenta'),
204
+ self.biginfo.num_bis,
205
+ )
206
+ print(
207
+ color(' PHY: ', 'magenta'),
208
+ self.biginfo.phy.name,
209
+ )
210
+ print(
211
+ color(' Framed: ', 'magenta'),
212
+ self.biginfo.framed,
213
+ )
214
+ print(
215
+ color(' Encrypted: ', 'magenta'),
216
+ self.biginfo.encrypted,
217
+ )
218
+
219
+ def on_sync_establishment(self) -> None:
220
+ self.emit('sync_establishment')
221
+
222
+ def on_sync_loss(self) -> None:
223
+ self.basic_audio_announcement = None
224
+ self.biginfo = None
225
+ self.emit('sync_loss')
226
+
227
+ def on_periodic_advertisement(
228
+ self, advertisement: bumble.device.PeriodicAdvertisement
229
+ ) -> None:
230
+ if advertisement.data is None:
231
+ return
232
+
233
+ for service_data in advertisement.data.get_all(
234
+ bumble.core.AdvertisingData.SERVICE_DATA
235
+ ):
236
+ assert isinstance(service_data, tuple)
237
+ service_uuid, data = service_data
238
+ assert isinstance(data, bytes)
239
+
240
+ if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
241
+ self.basic_audio_announcement = (
242
+ bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
243
+ )
244
+ break
245
+
246
+ self.emit('change')
247
+
248
+ def on_biginfo_advertisement(
249
+ self, advertisement: bumble.device.BIGInfoAdvertisement
250
+ ) -> None:
251
+ self.biginfo = advertisement
252
+ self.emit('change')
253
+
254
+ def __init__(
255
+ self,
256
+ device: bumble.device.Device,
257
+ filter_duplicates: bool,
258
+ sync_timeout: float,
259
+ ):
260
+ super().__init__()
261
+ self.device = device
262
+ self.filter_duplicates = filter_duplicates
263
+ self.sync_timeout = sync_timeout
264
+ self.broadcasts: Dict[bumble.hci.Address, BroadcastScanner.Broadcast] = {}
265
+ device.on('advertisement', self.on_advertisement)
266
+
267
+ async def start(self) -> None:
268
+ await self.device.start_scanning(
269
+ active=False,
270
+ filter_duplicates=False,
271
+ )
272
+
273
+ async def stop(self) -> None:
274
+ await self.device.stop_scanning()
275
+
276
+ def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
277
+ if (
278
+ broadcast_name := advertisement.data.get(
279
+ bumble.core.AdvertisingData.BROADCAST_NAME
280
+ )
281
+ ) is None:
282
+ return
283
+ assert isinstance(broadcast_name, str)
284
+
285
+ if broadcast := self.broadcasts.get(advertisement.address):
286
+ broadcast.update(advertisement)
287
+ return
288
+
289
+ bumble.utils.AsyncRunner.spawn(
290
+ self.on_new_broadcast(broadcast_name, advertisement)
291
+ )
292
+
293
+ async def on_new_broadcast(
294
+ self, name: str, advertisement: bumble.device.Advertisement
295
+ ) -> None:
296
+ periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
297
+ advertiser_address=advertisement.address,
298
+ sid=advertisement.sid,
299
+ sync_timeout=self.sync_timeout,
300
+ filter_duplicates=self.filter_duplicates,
301
+ )
302
+ broadcast = self.Broadcast(
303
+ name,
304
+ periodic_advertising_sync,
305
+ )
306
+ broadcast.update(advertisement)
307
+ self.broadcasts[advertisement.address] = broadcast
308
+ periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
309
+ self.emit('new_broadcast', broadcast)
310
+
311
+ def on_broadcast_loss(self, broadcast: Broadcast) -> None:
312
+ del self.broadcasts[broadcast.sync.advertiser_address]
313
+ bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
314
+ self.emit('broadcast_loss', broadcast)
315
+
316
+
317
+ class PrintingBroadcastScanner:
318
+ def __init__(
319
+ self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
320
+ ) -> None:
321
+ self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
322
+ self.scanner.on('new_broadcast', self.on_new_broadcast)
323
+ self.scanner.on('broadcast_loss', self.on_broadcast_loss)
324
+ self.scanner.on('update', self.refresh)
325
+ self.status_message = ''
326
+
327
+ async def start(self) -> None:
328
+ self.status_message = color('Scanning...', 'green')
329
+ await self.scanner.start()
330
+
331
+ def on_new_broadcast(self, broadcast: BroadcastScanner.Broadcast) -> None:
332
+ self.status_message = color(
333
+ f'+Found {len(self.scanner.broadcasts)} broadcasts', 'green'
334
+ )
335
+ broadcast.on('change', self.refresh)
336
+ broadcast.on('update', self.refresh)
337
+ self.refresh()
338
+
339
+ def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
340
+ self.status_message = color(
341
+ f'-Found {len(self.scanner.broadcasts)} broadcasts', 'green'
342
+ )
343
+ self.refresh()
344
+
345
+ def refresh(self) -> None:
346
+ # Clear the screen from the top
347
+ print('\033[H')
348
+ print('\033[0J')
349
+ print('\033[H')
350
+
351
+ # Print the status message
352
+ print(self.status_message)
353
+ print("==========================================")
354
+
355
+ # Print all broadcasts
356
+ for broadcast in self.scanner.broadcasts.values():
357
+ broadcast.print()
358
+ print('------------------------------------------')
359
+
360
+ # Clear the screen to the bottom
361
+ print('\033[0J')
362
+
363
+
364
+ @contextlib.asynccontextmanager
365
+ async def create_device(transport: str) -> AsyncGenerator[bumble.device.Device, Any]:
366
+ async with await bumble.transport.open_transport(transport) as (
367
+ hci_source,
368
+ hci_sink,
369
+ ):
370
+ device_config = bumble.device.DeviceConfiguration(
371
+ name=AURACAST_DEFAULT_DEVICE_NAME,
372
+ address=AURACAST_DEFAULT_DEVICE_ADDRESS,
373
+ keystore='JsonKeyStore',
374
+ )
375
+
376
+ device = bumble.device.Device.from_config_with_hci(
377
+ device_config,
378
+ hci_source,
379
+ hci_sink,
380
+ )
381
+ await device.power_on()
382
+
383
+ yield device
384
+
385
+
386
+ async def find_broadcast_by_name(
387
+ device: bumble.device.Device, name: Optional[str]
388
+ ) -> BroadcastScanner.Broadcast:
389
+ result = asyncio.get_running_loop().create_future()
390
+
391
+ def on_broadcast_change(broadcast: BroadcastScanner.Broadcast) -> None:
392
+ if broadcast.basic_audio_announcement and not result.done():
393
+ print(color('Broadcast basic audio announcement received', 'green'))
394
+ result.set_result(broadcast)
395
+
396
+ def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
397
+ if name is None or broadcast.name == name:
398
+ print(color('Broadcast found:', 'green'), broadcast.name)
399
+ broadcast.on('change', lambda: on_broadcast_change(broadcast))
400
+ return
401
+
402
+ print(color(f'Skipping broadcast {broadcast.name}'))
403
+
404
+ scanner = BroadcastScanner(device, False, AURACAST_DEFAULT_SYNC_TIMEOUT)
405
+ scanner.on('new_broadcast', on_new_broadcast)
406
+ await scanner.start()
407
+
408
+ broadcast = await result
409
+ await scanner.stop()
410
+
411
+ return broadcast
412
+
413
+
414
+ async def run_scan(
415
+ filter_duplicates: bool, sync_timeout: float, transport: str
416
+ ) -> None:
417
+ async with create_device(transport) as device:
418
+ if not device.supports_le_periodic_advertising:
419
+ print(color('Periodic advertising not supported', 'red'))
420
+ return
421
+
422
+ scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
423
+ await scanner.start()
424
+ await asyncio.get_running_loop().create_future()
425
+
426
+
427
+ async def run_assist(
428
+ broadcast_name: Optional[str],
429
+ source_id: Optional[int],
430
+ command: str,
431
+ transport: str,
432
+ address: str,
433
+ ) -> None:
434
+ async with create_device(transport) as device:
435
+ if not device.supports_le_periodic_advertising:
436
+ print(color('Periodic advertising not supported', 'red'))
437
+ return
438
+
439
+ # Connect to the server
440
+ print(f'=== Connecting to {address}...')
441
+ connection = await device.connect(address)
442
+ peer = bumble.device.Peer(connection)
443
+ print(f'=== Connected to {peer}')
444
+
445
+ print("+++ Encrypting connection...")
446
+ await peer.connection.encrypt()
447
+ print("+++ Connection encrypted")
448
+
449
+ # Request a larger MTU
450
+ mtu = AURACAST_DEFAULT_ATT_MTU
451
+ print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
452
+ await peer.request_mtu(mtu)
453
+
454
+ # Get the BASS service
455
+ bass = await peer.discover_service_and_create_proxy(
456
+ bumble.profiles.bass.BroadcastAudioScanServiceProxy
457
+ )
458
+
459
+ # Check that the service was found
460
+ if not bass:
461
+ print(color('!!! Broadcast Audio Scan Service not found', 'red'))
462
+ return
463
+
464
+ # Subscribe to and read the broadcast receive state characteristics
465
+ for i, broadcast_receive_state in enumerate(bass.broadcast_receive_states):
466
+ try:
467
+ await broadcast_receive_state.subscribe(
468
+ lambda value, i=i: print(
469
+ f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
470
+ )
471
+ )
472
+ except bumble.core.ProtocolError as error:
473
+ print(
474
+ color(
475
+ f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
476
+ 'red',
477
+ ),
478
+ error,
479
+ )
480
+ value = await broadcast_receive_state.read_value()
481
+ print(
482
+ f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
483
+ )
484
+
485
+ if command == 'monitor-state':
486
+ await peer.sustain()
487
+ return
488
+
489
+ if command == 'add-source':
490
+ # Find the requested broadcast
491
+ await bass.remote_scan_started()
492
+ if broadcast_name:
493
+ print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
494
+ else:
495
+ print(color('Scanning for any broadcast', 'cyan'))
496
+ broadcast = await find_broadcast_by_name(device, broadcast_name)
497
+
498
+ if broadcast.broadcast_audio_announcement is None:
499
+ print(color('No broadcast audio announcement found', 'red'))
500
+ return
501
+
502
+ if (
503
+ broadcast.basic_audio_announcement is None
504
+ or not broadcast.basic_audio_announcement.subgroups
505
+ ):
506
+ print(color('No subgroups found', 'red'))
507
+ return
508
+
509
+ # Add the source
510
+ print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
511
+ await bass.add_source(
512
+ broadcast.sync.advertiser_address,
513
+ broadcast.sync.sid,
514
+ broadcast.broadcast_audio_announcement.broadcast_id,
515
+ bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
516
+ 0xFFFF,
517
+ [
518
+ bumble.profiles.bass.SubgroupInfo(
519
+ bumble.profiles.bass.SubgroupInfo.ANY_BIS,
520
+ bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
521
+ )
522
+ ],
523
+ )
524
+
525
+ # Initiate a PA Sync Transfer
526
+ await broadcast.sync.transfer(peer.connection)
527
+
528
+ # Notify the sink that we're done scanning.
529
+ await bass.remote_scan_stopped()
530
+
531
+ await peer.sustain()
532
+ return
533
+
534
+ if command == 'modify-source':
535
+ if source_id is None:
536
+ print(color('!!! modify-source requires --source-id'))
537
+ return
538
+
539
+ # Find the requested broadcast
540
+ await bass.remote_scan_started()
541
+ if broadcast_name:
542
+ print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
543
+ else:
544
+ print(color('Scanning for any broadcast', 'cyan'))
545
+ broadcast = await find_broadcast_by_name(device, broadcast_name)
546
+
547
+ if broadcast.broadcast_audio_announcement is None:
548
+ print(color('No broadcast audio announcement found', 'red'))
549
+ return
550
+
551
+ if (
552
+ broadcast.basic_audio_announcement is None
553
+ or not broadcast.basic_audio_announcement.subgroups
554
+ ):
555
+ print(color('No subgroups found', 'red'))
556
+ return
557
+
558
+ # Modify the source
559
+ print(
560
+ color('Modifying source:', 'blue'),
561
+ source_id,
562
+ )
563
+ await bass.modify_source(
564
+ source_id,
565
+ bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
566
+ 0xFFFF,
567
+ [
568
+ bumble.profiles.bass.SubgroupInfo(
569
+ bumble.profiles.bass.SubgroupInfo.ANY_BIS,
570
+ bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
571
+ )
572
+ ],
573
+ )
574
+ await peer.sustain()
575
+ return
576
+
577
+ if command == 'remove-source':
578
+ if source_id is None:
579
+ print(color('!!! remove-source requires --source-id'))
580
+ return
581
+
582
+ # Remove the source
583
+ print(color('Removing source:', 'blue'), source_id)
584
+ await bass.remove_source(source_id)
585
+ await peer.sustain()
586
+ return
587
+
588
+ print(color(f'!!! invalid command {command}'))
589
+
590
+
591
+ async def run_pair(transport: str, address: str) -> None:
592
+ async with create_device(transport) as device:
593
+
594
+ # Connect to the server
595
+ print(f'=== Connecting to {address}...')
596
+ async with device.connect_as_gatt(address) as peer:
597
+ print(f'=== Connected to {peer}')
598
+
599
+ print("+++ Initiating pairing...")
600
+ await peer.connection.pair()
601
+ print("+++ Paired")
602
+
603
+
604
+ def run_async(async_command: Coroutine) -> None:
605
+ try:
606
+ asyncio.run(async_command)
607
+ except bumble.core.ProtocolError as error:
608
+ if error.error_namespace == 'att' and error.error_code in list(
609
+ bumble.profiles.bass.ApplicationError
610
+ ):
611
+ message = bumble.profiles.bass.ApplicationError(error.error_code).name
612
+ else:
613
+ message = str(error)
614
+
615
+ print(
616
+ color('!!! An error occurred while executing the command:', 'red'), message
617
+ )
618
+
619
+
620
+ # -----------------------------------------------------------------------------
621
+ # Main
622
+ # -----------------------------------------------------------------------------
623
+ @click.group()
624
+ @click.pass_context
625
+ def auracast(
626
+ ctx,
627
+ ):
628
+ ctx.ensure_object(dict)
629
+
630
+
631
+ @auracast.command('scan')
632
+ @click.option(
633
+ '--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
634
+ )
635
+ @click.option(
636
+ '--sync-timeout',
637
+ metavar='SYNC_TIMEOUT',
638
+ type=float,
639
+ default=AURACAST_DEFAULT_SYNC_TIMEOUT,
640
+ help='Sync timeout (in seconds)',
641
+ )
642
+ @click.argument('transport')
643
+ @click.pass_context
644
+ def scan(ctx, filter_duplicates, sync_timeout, transport):
645
+ """Scan for public broadcasts"""
646
+ run_async(run_scan(filter_duplicates, sync_timeout, transport))
647
+
648
+
649
+ @auracast.command('assist')
650
+ @click.option(
651
+ '--broadcast-name',
652
+ metavar='BROADCAST_NAME',
653
+ help='Broadcast Name to tune to',
654
+ )
655
+ @click.option(
656
+ '--source-id',
657
+ metavar='SOURCE_ID',
658
+ type=int,
659
+ help='Source ID (for remove-source command)',
660
+ )
661
+ @click.option(
662
+ '--command',
663
+ type=click.Choice(
664
+ ['monitor-state', 'add-source', 'modify-source', 'remove-source']
665
+ ),
666
+ required=True,
667
+ )
668
+ @click.argument('transport')
669
+ @click.argument('address')
670
+ @click.pass_context
671
+ def assist(ctx, broadcast_name, source_id, command, transport, address):
672
+ """Scan for broadcasts on behalf of a audio server"""
673
+ run_async(run_assist(broadcast_name, source_id, command, transport, address))
674
+
675
+
676
+ @auracast.command('pair')
677
+ @click.argument('transport')
678
+ @click.argument('address')
679
+ @click.pass_context
680
+ def pair(ctx, transport, address):
681
+ """Pair with an audio server"""
682
+ run_async(run_pair(transport, address))
683
+
684
+
685
+ def main():
686
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
687
+ auracast()
688
+
689
+
690
+ # -----------------------------------------------------------------------------
691
+ if __name__ == "__main__":
692
+ main() # pylint: disable=no-value-for-parameter