bumble 0.0.154__py3-none-any.whl → 0.0.156__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.
@@ -0,0 +1,747 @@
1
+ # Copyright 2021-2023 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # -----------------------------------------------------------------------------
16
+ # Imports
17
+ # -----------------------------------------------------------------------------
18
+ from __future__ import annotations
19
+ import asyncio
20
+ import asyncio.subprocess
21
+ from importlib import resources
22
+ import enum
23
+ import json
24
+ import os
25
+ import logging
26
+ import pathlib
27
+ import subprocess
28
+ from typing import Dict, List, Optional
29
+ import weakref
30
+
31
+ import click
32
+ import aiohttp
33
+ from aiohttp import web
34
+
35
+ import bumble
36
+ from bumble.colors import color
37
+ from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
38
+ from bumble.device import Connection, Device, DeviceConfiguration
39
+ from bumble.hci import HCI_StatusError
40
+ from bumble.pairing import PairingConfig
41
+ from bumble.sdp import ServiceAttribute
42
+ from bumble.transport import open_transport
43
+ from bumble.avdtp import (
44
+ AVDTP_AUDIO_MEDIA_TYPE,
45
+ Listener,
46
+ MediaCodecCapabilities,
47
+ MediaPacket,
48
+ Protocol,
49
+ )
50
+ from bumble.a2dp import (
51
+ MPEG_2_AAC_LC_OBJECT_TYPE,
52
+ make_audio_sink_service_sdp_records,
53
+ A2DP_SBC_CODEC_TYPE,
54
+ A2DP_MPEG_2_4_AAC_CODEC_TYPE,
55
+ SBC_MONO_CHANNEL_MODE,
56
+ SBC_DUAL_CHANNEL_MODE,
57
+ SBC_SNR_ALLOCATION_METHOD,
58
+ SBC_LOUDNESS_ALLOCATION_METHOD,
59
+ SBC_STEREO_CHANNEL_MODE,
60
+ SBC_JOINT_STEREO_CHANNEL_MODE,
61
+ SbcMediaCodecInformation,
62
+ AacMediaCodecInformation,
63
+ )
64
+ from bumble.utils import AsyncRunner
65
+ from bumble.codecs import AacAudioRtpPacket
66
+
67
+
68
+ # -----------------------------------------------------------------------------
69
+ # Logging
70
+ # -----------------------------------------------------------------------------
71
+ logger = logging.getLogger(__name__)
72
+
73
+
74
+ # -----------------------------------------------------------------------------
75
+ # Constants
76
+ # -----------------------------------------------------------------------------
77
+ DEFAULT_UI_PORT = 7654
78
+
79
+ # -----------------------------------------------------------------------------
80
+ class AudioExtractor:
81
+ @staticmethod
82
+ def create(codec: str):
83
+ if codec == 'aac':
84
+ return AacAudioExtractor()
85
+ if codec == 'sbc':
86
+ return SbcAudioExtractor()
87
+
88
+ def extract_audio(self, packet: MediaPacket) -> bytes:
89
+ raise NotImplementedError()
90
+
91
+
92
+ # -----------------------------------------------------------------------------
93
+ class AacAudioExtractor:
94
+ def extract_audio(self, packet: MediaPacket) -> bytes:
95
+ return AacAudioRtpPacket(packet.payload).to_adts()
96
+
97
+
98
+ # -----------------------------------------------------------------------------
99
+ class SbcAudioExtractor:
100
+ def extract_audio(self, packet: MediaPacket) -> bytes:
101
+ # header = packet.payload[0]
102
+ # fragmented = header >> 7
103
+ # start = (header >> 6) & 0x01
104
+ # last = (header >> 5) & 0x01
105
+ # number_of_frames = header & 0x0F
106
+
107
+ # TODO: support fragmented payloads
108
+ return packet.payload[1:]
109
+
110
+
111
+ # -----------------------------------------------------------------------------
112
+ class Output:
113
+ async def start(self) -> None:
114
+ pass
115
+
116
+ async def stop(self) -> None:
117
+ pass
118
+
119
+ async def suspend(self) -> None:
120
+ pass
121
+
122
+ async def on_connection(self, connection: Connection) -> None:
123
+ pass
124
+
125
+ async def on_disconnection(self, reason: int) -> None:
126
+ pass
127
+
128
+ def on_rtp_packet(self, packet: MediaPacket) -> None:
129
+ pass
130
+
131
+
132
+ # -----------------------------------------------------------------------------
133
+ class FileOutput(Output):
134
+ filename: str
135
+ codec: str
136
+ extractor: AudioExtractor
137
+
138
+ def __init__(self, filename, codec):
139
+ self.filename = filename
140
+ self.codec = codec
141
+ self.file = open(filename, 'wb')
142
+ self.extractor = AudioExtractor.create(codec)
143
+
144
+ def on_rtp_packet(self, packet: MediaPacket) -> None:
145
+ self.file.write(self.extractor.extract_audio(packet))
146
+
147
+
148
+ # -----------------------------------------------------------------------------
149
+ class QueuedOutput(Output):
150
+ MAX_QUEUE_SIZE = 32768
151
+
152
+ packets: asyncio.Queue
153
+ extractor: AudioExtractor
154
+ packet_pump_task: Optional[asyncio.Task]
155
+ started: bool
156
+
157
+ def __init__(self, extractor):
158
+ self.extractor = extractor
159
+ self.packets = asyncio.Queue()
160
+ self.packet_pump_task = None
161
+ self.started = False
162
+
163
+ async def start(self):
164
+ if self.started:
165
+ return
166
+
167
+ self.packet_pump_task = asyncio.create_task(self.pump_packets())
168
+
169
+ async def pump_packets(self):
170
+ while True:
171
+ packet = await self.packets.get()
172
+ await self.on_audio_packet(packet)
173
+
174
+ async def on_audio_packet(self, packet: bytes) -> None:
175
+ pass
176
+
177
+ def on_rtp_packet(self, packet: MediaPacket) -> None:
178
+ if self.packets.qsize() > self.MAX_QUEUE_SIZE:
179
+ logger.debug("queue full, dropping")
180
+ return
181
+
182
+ self.packets.put_nowait(self.extractor.extract_audio(packet))
183
+
184
+
185
+ # -----------------------------------------------------------------------------
186
+ class WebSocketOutput(QueuedOutput):
187
+ def __init__(self, codec, send_audio, send_message):
188
+ super().__init__(AudioExtractor.create(codec))
189
+ self.send_audio = send_audio
190
+ self.send_message = send_message
191
+
192
+ async def on_connection(self, connection: Connection) -> None:
193
+ try:
194
+ await connection.request_remote_name()
195
+ except HCI_StatusError:
196
+ pass
197
+ peer_name = '' if connection.peer_name is None else connection.peer_name
198
+ peer_address = str(connection.peer_address).replace('/P', '')
199
+ await self.send_message(
200
+ 'connection',
201
+ peer_address=peer_address,
202
+ peer_name=peer_name,
203
+ )
204
+
205
+ async def on_disconnection(self, reason) -> None:
206
+ await self.send_message('disconnection')
207
+
208
+ async def on_audio_packet(self, packet: bytes) -> None:
209
+ await self.send_audio(packet)
210
+
211
+ async def start(self):
212
+ await super().start()
213
+ await self.send_message('start')
214
+
215
+ async def stop(self):
216
+ await super().stop()
217
+ await self.send_message('stop')
218
+
219
+ async def suspend(self):
220
+ await super().suspend()
221
+ await self.send_message('suspend')
222
+
223
+
224
+ # -----------------------------------------------------------------------------
225
+ class FfplayOutput(QueuedOutput):
226
+ MAX_QUEUE_SIZE = 32768
227
+
228
+ subprocess: Optional[asyncio.subprocess.Process]
229
+ ffplay_task: Optional[asyncio.Task]
230
+
231
+ def __init__(self) -> None:
232
+ super().__init__(AacAudioExtractor())
233
+ self.subprocess = None
234
+ self.ffplay_task = None
235
+
236
+ async def start(self):
237
+ if self.started:
238
+ return
239
+
240
+ await super().start()
241
+
242
+ self.subprocess = await asyncio.create_subprocess_shell(
243
+ 'ffplay -acodec aac pipe:0',
244
+ stdin=asyncio.subprocess.PIPE,
245
+ stdout=asyncio.subprocess.PIPE,
246
+ stderr=asyncio.subprocess.PIPE,
247
+ )
248
+
249
+ self.ffplay_task = asyncio.create_task(self.monitor_ffplay())
250
+
251
+ async def stop(self):
252
+ # TODO
253
+ pass
254
+
255
+ async def suspend(self):
256
+ # TODO
257
+ pass
258
+
259
+ async def monitor_ffplay(self):
260
+ async def read_stream(name, stream):
261
+ while True:
262
+ data = await stream.read()
263
+ logger.debug(f'{name}:', data)
264
+
265
+ await asyncio.wait(
266
+ [
267
+ asyncio.create_task(
268
+ read_stream('[ffplay stdout]', self.subprocess.stdout)
269
+ ),
270
+ asyncio.create_task(
271
+ read_stream('[ffplay stderr]', self.subprocess.stderr)
272
+ ),
273
+ asyncio.create_task(self.subprocess.wait()),
274
+ ]
275
+ )
276
+ logger.debug("FFPLAY done")
277
+
278
+ async def on_audio_packet(self, packet):
279
+ try:
280
+ self.subprocess.stdin.write(packet)
281
+ except Exception:
282
+ logger.warning('!!!! exception while sending audio to ffplay pipe')
283
+
284
+
285
+ # -----------------------------------------------------------------------------
286
+ class UiServer:
287
+ speaker: weakref.ReferenceType[Speaker]
288
+ port: int
289
+
290
+ def __init__(self, speaker: Speaker, port: int) -> None:
291
+ self.speaker = weakref.ref(speaker)
292
+ self.port = port
293
+ self.channel_socket = None
294
+
295
+ async def start_http(self) -> None:
296
+ """Start the UI HTTP server."""
297
+
298
+ app = web.Application()
299
+ app.add_routes(
300
+ [
301
+ web.get('/', self.get_static),
302
+ web.get('/speaker.html', self.get_static),
303
+ web.get('/speaker.js', self.get_static),
304
+ web.get('/speaker.css', self.get_static),
305
+ web.get('/logo.svg', self.get_static),
306
+ web.get('/channel', self.get_channel),
307
+ ]
308
+ )
309
+
310
+ runner = web.AppRunner(app)
311
+ await runner.setup()
312
+ site = web.TCPSite(runner, 'localhost', self.port)
313
+ print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
314
+ await site.start()
315
+
316
+ async def get_static(self, request):
317
+ path = request.path
318
+ if path == '/':
319
+ path = '/speaker.html'
320
+ if path.endswith('.html'):
321
+ content_type = 'text/html'
322
+ elif path.endswith('.js'):
323
+ content_type = 'text/javascript'
324
+ elif path.endswith('.css'):
325
+ content_type = 'text/css'
326
+ elif path.endswith('.svg'):
327
+ content_type = 'image/svg+xml'
328
+ else:
329
+ content_type = 'text/plain'
330
+ text = (
331
+ resources.files("bumble.apps.speaker")
332
+ .joinpath(pathlib.Path(path).relative_to('/'))
333
+ .read_text(encoding="utf-8")
334
+ )
335
+ return aiohttp.web.Response(text=text, content_type=content_type)
336
+
337
+ async def get_channel(self, request):
338
+ ws = web.WebSocketResponse()
339
+ await ws.prepare(request)
340
+
341
+ # Process messages until the socket is closed.
342
+ self.channel_socket = ws
343
+ async for message in ws:
344
+ if message.type == aiohttp.WSMsgType.TEXT:
345
+ logger.debug(f'<<< received message: {message.data}')
346
+ await self.on_message(message.data)
347
+ elif message.type == aiohttp.WSMsgType.ERROR:
348
+ logger.debug(
349
+ f'channel connection closed with exception {ws.exception()}'
350
+ )
351
+
352
+ self.channel_socket = None
353
+ logger.debug('--- channel connection closed')
354
+
355
+ return ws
356
+
357
+ async def on_message(self, message_str: str):
358
+ # Parse the message as JSON
359
+ message = json.loads(message_str)
360
+
361
+ # Dispatch the message
362
+ message_type = message['type']
363
+ message_params = message.get('params', {})
364
+ handler = getattr(self, f'on_{message_type}_message')
365
+ if handler:
366
+ await handler(**message_params)
367
+
368
+ async def on_hello_message(self):
369
+ await self.send_message(
370
+ 'hello',
371
+ bumble_version=bumble.__version__,
372
+ codec=self.speaker().codec,
373
+ streamState=self.speaker().stream_state.name,
374
+ )
375
+ if connection := self.speaker().connection:
376
+ await self.send_message(
377
+ 'connection',
378
+ peer_address=str(connection.peer_address).replace('/P', ''),
379
+ peer_name=connection.peer_name,
380
+ )
381
+
382
+ async def send_message(self, message_type: str, **kwargs) -> None:
383
+ if self.channel_socket is None:
384
+ return
385
+
386
+ message = {'type': message_type, 'params': kwargs}
387
+ await self.channel_socket.send_json(message)
388
+
389
+ async def send_audio(self, data: bytes) -> None:
390
+ if self.channel_socket is None:
391
+ return
392
+
393
+ try:
394
+ await self.channel_socket.send_bytes(data)
395
+ except Exception as error:
396
+ logger.warning(f'exception while sending audio packet: {error}')
397
+
398
+
399
+ # -----------------------------------------------------------------------------
400
+ class Speaker:
401
+ class StreamState(enum.Enum):
402
+ IDLE = 0
403
+ STOPPED = 1
404
+ STARTED = 2
405
+ SUSPENDED = 3
406
+
407
+ def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
408
+ self.device_config = device_config
409
+ self.transport = transport
410
+ self.codec = codec
411
+ self.discover = discover
412
+ self.ui_port = ui_port
413
+ self.device = None
414
+ self.connection = None
415
+ self.listener = None
416
+ self.packets_received = 0
417
+ self.bytes_received = 0
418
+ self.stream_state = Speaker.StreamState.IDLE
419
+ self.outputs = []
420
+ for output in outputs:
421
+ if output == '@ffplay':
422
+ self.outputs.append(FfplayOutput())
423
+ continue
424
+
425
+ # Default to FileOutput
426
+ self.outputs.append(FileOutput(output, codec))
427
+
428
+ # Create an HTTP server for the UI
429
+ self.ui_server = UiServer(speaker=self, port=ui_port)
430
+
431
+ def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
432
+ service_record_handle = 0x00010001
433
+ return {
434
+ service_record_handle: make_audio_sink_service_sdp_records(
435
+ service_record_handle
436
+ )
437
+ }
438
+
439
+ def codec_capabilities(self) -> MediaCodecCapabilities:
440
+ if self.codec == 'aac':
441
+ return self.aac_codec_capabilities()
442
+
443
+ if self.codec == 'sbc':
444
+ return self.sbc_codec_capabilities()
445
+
446
+ raise RuntimeError('unsupported codec')
447
+
448
+ def aac_codec_capabilities(self) -> MediaCodecCapabilities:
449
+ return MediaCodecCapabilities(
450
+ media_type=AVDTP_AUDIO_MEDIA_TYPE,
451
+ media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
452
+ media_codec_information=AacMediaCodecInformation.from_lists(
453
+ object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
454
+ sampling_frequencies=[48000, 44100],
455
+ channels=[1, 2],
456
+ vbr=1,
457
+ bitrate=256000,
458
+ ),
459
+ )
460
+
461
+ def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
462
+ return MediaCodecCapabilities(
463
+ media_type=AVDTP_AUDIO_MEDIA_TYPE,
464
+ media_codec_type=A2DP_SBC_CODEC_TYPE,
465
+ media_codec_information=SbcMediaCodecInformation.from_lists(
466
+ sampling_frequencies=[48000, 44100, 32000, 16000],
467
+ channel_modes=[
468
+ SBC_MONO_CHANNEL_MODE,
469
+ SBC_DUAL_CHANNEL_MODE,
470
+ SBC_STEREO_CHANNEL_MODE,
471
+ SBC_JOINT_STEREO_CHANNEL_MODE,
472
+ ],
473
+ block_lengths=[4, 8, 12, 16],
474
+ subbands=[4, 8],
475
+ allocation_methods=[
476
+ SBC_LOUDNESS_ALLOCATION_METHOD,
477
+ SBC_SNR_ALLOCATION_METHOD,
478
+ ],
479
+ minimum_bitpool_value=2,
480
+ maximum_bitpool_value=53,
481
+ ),
482
+ )
483
+
484
+ async def dispatch_to_outputs(self, function):
485
+ for output in self.outputs:
486
+ await function(output)
487
+
488
+ def on_bluetooth_connection(self, connection):
489
+ print(f'Connection: {connection}')
490
+ self.connection = connection
491
+ connection.on('disconnection', self.on_bluetooth_disconnection)
492
+ AsyncRunner.spawn(
493
+ self.dispatch_to_outputs(lambda output: output.on_connection(connection))
494
+ )
495
+
496
+ def on_bluetooth_disconnection(self, reason):
497
+ print(f'Disconnection ({reason})')
498
+ self.connection = None
499
+ AsyncRunner.spawn(self.advertise())
500
+ AsyncRunner.spawn(
501
+ self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
502
+ )
503
+
504
+ def on_avdtp_connection(self, protocol):
505
+ print('Audio Stream Open')
506
+
507
+ # Add a sink endpoint to the server
508
+ sink = protocol.add_sink(self.codec_capabilities())
509
+ sink.on('start', self.on_sink_start)
510
+ sink.on('stop', self.on_sink_stop)
511
+ sink.on('suspend', self.on_sink_suspend)
512
+ sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
513
+ sink.on('rtp_packet', self.on_rtp_packet)
514
+ sink.on('rtp_channel_open', self.on_rtp_channel_open)
515
+ sink.on('rtp_channel_close', self.on_rtp_channel_close)
516
+
517
+ # Listen for close events
518
+ protocol.on('close', self.on_avdtp_close)
519
+
520
+ # Discover all endpoints on the remote device is requested
521
+ if self.discover:
522
+ AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
523
+
524
+ def on_avdtp_close(self):
525
+ print("Audio Stream Closed")
526
+
527
+ def on_sink_start(self):
528
+ print("Sink Started\u001b[0K")
529
+ self.stream_state = self.StreamState.STARTED
530
+ AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
531
+
532
+ def on_sink_stop(self):
533
+ print("Sink Stopped\u001b[0K")
534
+ self.stream_state = self.StreamState.STOPPED
535
+ AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
536
+
537
+ def on_sink_suspend(self):
538
+ print("Sink Suspended\u001b[0K")
539
+ self.stream_state = self.StreamState.SUSPENDED
540
+ AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
541
+
542
+ def on_sink_configuration(self, config):
543
+ print("Sink Configuration:")
544
+ print('\n'.join([" " + str(capability) for capability in config]))
545
+
546
+ def on_rtp_channel_open(self):
547
+ print("RTP Channel Open")
548
+
549
+ def on_rtp_channel_close(self):
550
+ print("RTP Channel Closed")
551
+ self.stream_state = self.StreamState.IDLE
552
+
553
+ def on_rtp_packet(self, packet):
554
+ self.packets_received += 1
555
+ self.bytes_received += len(packet.payload)
556
+ print(
557
+ f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}',
558
+ end='\r',
559
+ )
560
+
561
+ for output in self.outputs:
562
+ output.on_rtp_packet(packet)
563
+
564
+ async def advertise(self):
565
+ await self.device.set_discoverable(True)
566
+ await self.device.set_connectable(True)
567
+
568
+ async def connect(self, address):
569
+ # Connect to the source
570
+ print(f'=== Connecting to {address}...')
571
+ connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
572
+ print(f'=== Connected to {connection.peer_address}')
573
+
574
+ # Request authentication
575
+ print('*** Authenticating...')
576
+ await connection.authenticate()
577
+ print('*** Authenticated')
578
+
579
+ # Enable encryption
580
+ print('*** Enabling encryption...')
581
+ await connection.encrypt()
582
+ print('*** Encryption on')
583
+
584
+ protocol = await Protocol.connect(connection)
585
+ self.listener.set_server(connection, protocol)
586
+ self.on_avdtp_connection(protocol)
587
+
588
+ async def discover_remote_endpoints(self, protocol):
589
+ endpoints = await protocol.discover_remote_endpoints()
590
+ print(f'@@@ Found {len(endpoints)} endpoints')
591
+ for endpoint in endpoints:
592
+ print('@@@', endpoint)
593
+
594
+ async def run(self, connect_address):
595
+ await self.ui_server.start_http()
596
+ self.outputs.append(
597
+ WebSocketOutput(
598
+ self.codec, self.ui_server.send_audio, self.ui_server.send_message
599
+ )
600
+ )
601
+
602
+ async with await open_transport(self.transport) as (hci_source, hci_sink):
603
+ # Create a device
604
+ device_config = DeviceConfiguration()
605
+ if self.device_config:
606
+ device_config.load_from_file(self.device_config)
607
+ else:
608
+ device_config.name = "Bumble Speaker"
609
+ device_config.class_of_device = 0x240414
610
+ device_config.keystore = "JsonKeyStore"
611
+
612
+ device_config.classic_enabled = True
613
+ device_config.le_enabled = False
614
+ self.device = Device.from_config_with_hci(
615
+ device_config, hci_source, hci_sink
616
+ )
617
+
618
+ # Setup the SDP to expose the sink service
619
+ self.device.sdp_service_records = self.sdp_records()
620
+
621
+ # Don't require MITM when pairing.
622
+ self.device.pairing_config_factory = lambda connection: PairingConfig(
623
+ mitm=False
624
+ )
625
+
626
+ # Start the controller
627
+ await self.device.power_on()
628
+
629
+ # Print some of the config/properties
630
+ print("Speaker Name:", color(device_config.name, 'yellow'))
631
+ print(
632
+ "Speaker Bluetooth Address:",
633
+ color(
634
+ self.device.public_address.to_string(with_type_qualifier=False),
635
+ 'yellow',
636
+ ),
637
+ )
638
+
639
+ # Listen for Bluetooth connections
640
+ self.device.on('connection', self.on_bluetooth_connection)
641
+
642
+ # Create a listener to wait for AVDTP connections
643
+ self.listener = Listener(Listener.create_registrar(self.device))
644
+ self.listener.on('connection', self.on_avdtp_connection)
645
+
646
+ print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
647
+
648
+ if connect_address:
649
+ # Connect to the source
650
+ try:
651
+ await self.connect(connect_address)
652
+ except CommandTimeoutError:
653
+ print(color("Connection timed out", "red"))
654
+ return
655
+ else:
656
+ # Start being discoverable and connectable
657
+ print("Waiting for connection...")
658
+ await self.advertise()
659
+
660
+ await hci_source.wait_for_termination()
661
+
662
+ for output in self.outputs:
663
+ await output.stop()
664
+
665
+
666
+ # -----------------------------------------------------------------------------
667
+ @click.group()
668
+ @click.pass_context
669
+ def speaker_cli(ctx, device_config):
670
+ ctx.ensure_object(dict)
671
+ ctx.obj['device_config'] = device_config
672
+
673
+
674
+ @click.command()
675
+ @click.option(
676
+ '--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
677
+ )
678
+ @click.option(
679
+ '--discover', is_flag=True, help='Discover remote endpoints once connected'
680
+ )
681
+ @click.option(
682
+ '--output',
683
+ multiple=True,
684
+ metavar='NAME',
685
+ help=(
686
+ 'Send audio to this named output '
687
+ '(may be used more than once for multiple outputs)'
688
+ ),
689
+ )
690
+ @click.option(
691
+ '--ui-port',
692
+ 'ui_port',
693
+ metavar='HTTP_PORT',
694
+ default=DEFAULT_UI_PORT,
695
+ show_default=True,
696
+ help='HTTP port for the UI server',
697
+ )
698
+ @click.option(
699
+ '--connect',
700
+ 'connect_address',
701
+ metavar='ADDRESS_OR_NAME',
702
+ help='Address or name to connect to',
703
+ )
704
+ @click.option('--device-config', metavar='FILENAME', help='Device configuration file')
705
+ @click.argument('transport')
706
+ def speaker(
707
+ transport, codec, connect_address, discover, output, ui_port, device_config
708
+ ):
709
+ """Run the speaker."""
710
+
711
+ # ffplay only works with AAC for now
712
+ if codec != 'aac' and '@ffplay' in output:
713
+ print(
714
+ color(
715
+ f'{codec} not supported with @ffplay output, '
716
+ '@ffplay output will be skipped',
717
+ 'yellow',
718
+ )
719
+ )
720
+ output = list(filter(lambda x: x != '@ffplay', output))
721
+
722
+ if '@ffplay' in output:
723
+ # Check if ffplay is installed
724
+ try:
725
+ subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
726
+ except FileNotFoundError:
727
+ print(
728
+ color('ffplay not installed, @ffplay output will be disabled', 'yellow')
729
+ )
730
+ output = list(filter(lambda x: x != '@ffplay', output))
731
+
732
+ asyncio.run(
733
+ Speaker(device_config, transport, codec, discover, output, ui_port).run(
734
+ connect_address
735
+ )
736
+ )
737
+
738
+
739
+ # -----------------------------------------------------------------------------
740
+ def main():
741
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
742
+ speaker()
743
+
744
+
745
+ # -----------------------------------------------------------------------------
746
+ if __name__ == "__main__":
747
+ main() # pylint: disable=no-value-for-parameter