mirror of
https://github.com/google/bumble.git
synced 2026-04-18 00:45:32 +00:00
add support for multiple concurrent broadcasts
This commit is contained in:
584
apps/auracast.py
584
apps/auracast.py
@@ -23,10 +23,14 @@ import contextlib
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator, Coroutine
|
import secrets
|
||||||
from typing import Any
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
)
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import tomli
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import lc3 # type: ignore # pylint: disable=E0401
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
@@ -58,8 +62,11 @@ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
|
|||||||
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
|
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
|
||||||
AURACAST_DEFAULT_ATT_MTU = 256
|
AURACAST_DEFAULT_ATT_MTU = 256
|
||||||
AURACAST_DEFAULT_FRAME_DURATION = 10000
|
AURACAST_DEFAULT_FRAME_DURATION = 10000
|
||||||
AURACAST_DEFAULT_SAMPLE_RATE = 48000
|
|
||||||
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
|
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
|
||||||
|
AURACAST_DEFAULT_BROADCAST_ID = 123456
|
||||||
|
AURACAST_DEFAULT_BROADCAST_NAME = 'Bumble Auracast'
|
||||||
|
AURACAST_DEFAULT_LANGUAGE = 'en'
|
||||||
|
AURACAST_DEFAULT_PROGRAM_INFO = 'Disco'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -103,6 +110,95 @@ def broadcast_code_bytes(broadcast_code: str) -> bytes:
|
|||||||
return broadcast_code_utf8 + padding
|
return broadcast_code_utf8 + padding
|
||||||
|
|
||||||
|
|
||||||
|
def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
|
||||||
|
broadcasts: list[Broadcast] = []
|
||||||
|
|
||||||
|
with open(filename, "rb") as config_file:
|
||||||
|
config = tomli.load(config_file)
|
||||||
|
for broadcast in config.get("broadcasts", []):
|
||||||
|
sources = []
|
||||||
|
for source in broadcast.get("sources", []):
|
||||||
|
sources.append(
|
||||||
|
BroadcastSource(
|
||||||
|
input=source["input"],
|
||||||
|
input_format=source.get("format", "auto"),
|
||||||
|
bitrate=source.get(
|
||||||
|
"bitrate", AURACAST_DEFAULT_TRANSMIT_BITRATE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
manufacturer_data = broadcast.get("manufacturer_data")
|
||||||
|
if manufacturer_data is not None:
|
||||||
|
manufacturer_data = (
|
||||||
|
manufacturer_data.get("company_id"),
|
||||||
|
bytes.fromhex(manufacturer_data["data"]),
|
||||||
|
)
|
||||||
|
broadcasts.append(
|
||||||
|
Broadcast(
|
||||||
|
sources=sources,
|
||||||
|
public=broadcast.get("public", True),
|
||||||
|
broadcast_id=broadcast.get("id", AURACAST_DEFAULT_BROADCAST_ID),
|
||||||
|
broadcast_name=broadcast["name"],
|
||||||
|
broadcast_code=broadcast.get("code"),
|
||||||
|
manufacturer_data=broadcast.get("manufacturer_data"),
|
||||||
|
language=broadcast.get("language"),
|
||||||
|
program_info=broadcast.get("program_info"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return broadcasts
|
||||||
|
|
||||||
|
|
||||||
|
def assign_broadcast_ids(broadcasts: Sequence[Broadcast]) -> None:
|
||||||
|
broadcast_ids = set()
|
||||||
|
for broadcast in broadcasts:
|
||||||
|
if broadcast.broadcast_id:
|
||||||
|
if broadcast.broadcast_id in broadcast_ids:
|
||||||
|
raise ValueError(f'duplicate broadcast ID {broadcast.broadcast_id}')
|
||||||
|
broadcast_ids.add(broadcast.broadcast_id)
|
||||||
|
else:
|
||||||
|
while True:
|
||||||
|
broadcast.broadcast_id = 1 + secrets.randbelow(0xFFFFFF)
|
||||||
|
if broadcast.broadcast_id not in broadcast_ids:
|
||||||
|
broadcast_ids.add(broadcast.broadcast_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Broadcast:
|
||||||
|
sources: list[BroadcastSource]
|
||||||
|
public: bool
|
||||||
|
broadcast_id: int # 0 means unassigned
|
||||||
|
broadcast_name: str
|
||||||
|
broadcast_code: str | None
|
||||||
|
manufacturer_data: tuple[int, bytes] | None = None
|
||||||
|
language: str | None = None
|
||||||
|
program_info: str | None = None
|
||||||
|
audio_sources: list[AudioSource] = dataclasses.field(default_factory=list)
|
||||||
|
iso_queues: list[bumble.device.IsoPacketStream] = dataclasses.field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class BroadcastSource:
|
||||||
|
input: str
|
||||||
|
input_format: str
|
||||||
|
bitrate: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AudioSource:
|
||||||
|
audio_input: audio_io.AudioInput
|
||||||
|
pcm_format: audio_io.PcmFormat
|
||||||
|
pcm_bit_depth: int | None
|
||||||
|
lc3_encoder: lc3.Encoder
|
||||||
|
lc3_frame_samples: int
|
||||||
|
lc3_frame_size: int
|
||||||
|
audio_frames: AsyncGenerator
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Scan For Broadcasts
|
# Scan For Broadcasts
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -119,6 +215,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
appearance: core.Appearance | None = None
|
appearance: core.Appearance | None = None
|
||||||
biginfo: bumble.device.BigInfoAdvertisement | None = None
|
biginfo: bumble.device.BigInfoAdvertisement | None = None
|
||||||
manufacturer_data: tuple[str, bytes] | None = None
|
manufacturer_data: tuple[str, bytes] | None = None
|
||||||
|
device_name: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -146,6 +243,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
self.device_name = advertisement.data.get(
|
||||||
|
core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
|
||||||
|
)
|
||||||
|
|
||||||
self.appearance = advertisement.data.get(
|
self.appearance = advertisement.data.get(
|
||||||
core.AdvertisingData.Type.APPEARANCE
|
core.AdvertisingData.Type.APPEARANCE
|
||||||
)
|
)
|
||||||
@@ -170,7 +271,9 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
color(self.sync.state.name, 'green'),
|
color(self.sync.state.name, 'green'),
|
||||||
)
|
)
|
||||||
if self.name is not None:
|
if self.name is not None:
|
||||||
print(f' {color("Name", "cyan")}: {self.name}')
|
print(f' {color("Broadcast Name", "cyan")}: {self.name}')
|
||||||
|
if self.device_name:
|
||||||
|
print(f' {color("Device Name", "cyan")}: {self.device_name}')
|
||||||
if self.appearance:
|
if self.appearance:
|
||||||
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
||||||
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
||||||
@@ -193,8 +296,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
f' {color("Features", "cyan")}: '
|
f' {color("Features", "cyan")}: '
|
||||||
f'{self.public_broadcast_announcement.features.name}'
|
f'{self.public_broadcast_announcement.features.name}'
|
||||||
)
|
)
|
||||||
|
if self.public_broadcast_announcement.metadata.entries:
|
||||||
print(f' {color("Metadata", "cyan")}: ')
|
print(f' {color("Metadata", "cyan")}: ')
|
||||||
print(self.public_broadcast_announcement.metadata.pretty_print(' '))
|
print(
|
||||||
|
self.public_broadcast_announcement.metadata.pretty_print(
|
||||||
|
' '
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.basic_audio_announcement:
|
if self.basic_audio_announcement:
|
||||||
print(color(' Audio:', 'cyan'))
|
print(color(' Audio:', 'cyan'))
|
||||||
@@ -210,6 +318,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
color(' Coding Format: ', 'green'),
|
color(' Coding Format: ', 'green'),
|
||||||
subgroup.codec_id.codec_id.name,
|
subgroup.codec_id.codec_id.name,
|
||||||
)
|
)
|
||||||
|
if subgroup.codec_id.company_id:
|
||||||
print(
|
print(
|
||||||
color(' Company ID: ', 'green'),
|
color(' Company ID: ', 'green'),
|
||||||
subgroup.codec_id.company_id,
|
subgroup.codec_id.company_id,
|
||||||
@@ -224,6 +333,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
subgroup.codec_specific_configuration, ' '
|
subgroup.codec_specific_configuration, ' '
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if subgroup.metadata.entries:
|
||||||
print(color(' Metadata: ', 'yellow'))
|
print(color(' Metadata: ', 'yellow'))
|
||||||
print(subgroup.metadata.pretty_print(' '))
|
print(subgroup.metadata.pretty_print(' '))
|
||||||
|
|
||||||
@@ -292,7 +402,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
self.device = device
|
self.device = device
|
||||||
self.filter_duplicates = filter_duplicates
|
self.filter_duplicates = filter_duplicates
|
||||||
self.sync_timeout = sync_timeout
|
self.sync_timeout = sync_timeout
|
||||||
self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
|
self.broadcasts = dict[tuple[hci.Address, int], BroadcastScanner.Broadcast]()
|
||||||
device.on('advertisement', self.on_advertisement)
|
device.on('advertisement', self.on_advertisement)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -310,7 +420,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
||||||
)
|
)
|
||||||
) or not (
|
) or not (
|
||||||
broadcast_audio_announcement := next(
|
broadcast_audio_announcement_ad := next(
|
||||||
(
|
(
|
||||||
ad
|
ad
|
||||||
for ad in ads
|
for ad in ads
|
||||||
@@ -325,7 +435,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
core.AdvertisingData.Type.BROADCAST_NAME
|
core.AdvertisingData.Type.BROADCAST_NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
if broadcast := self.broadcasts.get(advertisement.address):
|
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement.from_bytes(
|
||||||
|
broadcast_audio_announcement_ad[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
if broadcast := self.broadcasts.get(
|
||||||
|
(advertisement.address, broadcast_audio_announcement.broadcast_id)
|
||||||
|
):
|
||||||
broadcast.update(advertisement)
|
broadcast.update(advertisement)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -333,9 +449,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
self.on_new_broadcast(
|
self.on_new_broadcast(
|
||||||
broadcast_name[0] if broadcast_name else None,
|
broadcast_name[0] if broadcast_name else None,
|
||||||
advertisement,
|
advertisement,
|
||||||
bap.BroadcastAudioAnnouncement.from_bytes(
|
broadcast_audio_announcement.broadcast_id,
|
||||||
broadcast_audio_announcement[1]
|
|
||||||
).broadcast_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -353,12 +467,12 @@ class BroadcastScanner(bumble.utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
|
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
|
||||||
broadcast.update(advertisement)
|
broadcast.update(advertisement)
|
||||||
self.broadcasts[advertisement.address] = broadcast
|
self.broadcasts[(advertisement.address, broadcast_id)] = broadcast
|
||||||
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
||||||
self.emit('new_broadcast', broadcast)
|
self.emit('new_broadcast', broadcast)
|
||||||
|
|
||||||
def on_broadcast_loss(self, broadcast: Broadcast) -> None:
|
def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
|
||||||
del self.broadcasts[broadcast.sync.advertiser_address]
|
del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
|
||||||
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
|
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
|
||||||
self.emit('broadcast_loss', broadcast)
|
self.emit('broadcast_loss', broadcast)
|
||||||
|
|
||||||
@@ -462,9 +576,8 @@ async def find_broadcast_by_name(
|
|||||||
|
|
||||||
|
|
||||||
async def run_scan(
|
async def run_scan(
|
||||||
filter_duplicates: bool, sync_timeout: float, transport: str
|
device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
|
||||||
) -> None:
|
) -> None:
|
||||||
async with create_device(transport) as device:
|
|
||||||
if not device.supports_le_periodic_advertising:
|
if not device.supports_le_periodic_advertising:
|
||||||
print(color('Periodic advertising not supported', 'red'))
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
return
|
return
|
||||||
@@ -475,13 +588,12 @@ async def run_scan(
|
|||||||
|
|
||||||
|
|
||||||
async def run_assist(
|
async def run_assist(
|
||||||
|
device: bumble.device.Device,
|
||||||
broadcast_name: str | None,
|
broadcast_name: str | None,
|
||||||
source_id: int | None,
|
source_id: int | None,
|
||||||
command: str,
|
command: str,
|
||||||
transport: str,
|
|
||||||
address: str,
|
address: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with create_device(transport) as device:
|
|
||||||
if not device.supports_le_periodic_advertising:
|
if not device.supports_le_periodic_advertising:
|
||||||
print(color('Periodic advertising not supported', 'red'))
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
return
|
return
|
||||||
@@ -513,15 +625,14 @@ async def run_assist(
|
|||||||
|
|
||||||
# Subscribe to and read the broadcast receive state characteristics
|
# Subscribe to and read the broadcast receive state characteristics
|
||||||
def on_broadcast_receive_state_update(
|
def on_broadcast_receive_state_update(
|
||||||
value: bass.BroadcastReceiveState, index: int
|
value: bass.BroadcastReceiveState | None, index: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if value is not None:
|
||||||
print(
|
print(
|
||||||
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
|
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, broadcast_receive_state in enumerate(
|
for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
|
||||||
bass_client.broadcast_receive_states
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
await broadcast_receive_state.subscribe(
|
await broadcast_receive_state.subscribe(
|
||||||
functools.partial(on_broadcast_receive_state_update, index=i)
|
functools.partial(on_broadcast_receive_state_update, index=i)
|
||||||
@@ -535,9 +646,7 @@ async def run_assist(
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
value = await broadcast_receive_state.read_value()
|
value = await broadcast_receive_state.read_value()
|
||||||
print(
|
print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')
|
||||||
f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if command == 'monitor-state':
|
if command == 'monitor-state':
|
||||||
await peer.sustain()
|
await peer.sustain()
|
||||||
@@ -645,8 +754,7 @@ async def run_assist(
|
|||||||
print(color(f'!!! invalid command {command}'))
|
print(color(f'!!! invalid command {command}'))
|
||||||
|
|
||||||
|
|
||||||
async def run_pair(transport: str, address: str) -> None:
|
async def run_pair(device: bumble.device.Device, address: str) -> None:
|
||||||
async with create_device(transport) as device:
|
|
||||||
# Connect to the server
|
# Connect to the server
|
||||||
print(f'=== Connecting to {address}...')
|
print(f'=== Connecting to {address}...')
|
||||||
async with device.connect_as_gatt(address) as peer:
|
async with device.connect_as_gatt(address) as peer:
|
||||||
@@ -658,7 +766,7 @@ async def run_pair(transport: str, address: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_receive(
|
async def run_receive(
|
||||||
transport: str,
|
device: bumble.device.Device,
|
||||||
broadcast_id: int | None,
|
broadcast_id: int | None,
|
||||||
output: str,
|
output: str,
|
||||||
broadcast_code: str | None,
|
broadcast_code: str | None,
|
||||||
@@ -673,7 +781,6 @@ async def run_receive(
|
|||||||
print(error)
|
print(error)
|
||||||
return
|
return
|
||||||
|
|
||||||
async with create_device(transport) as device:
|
|
||||||
if not device.supports_le_periodic_advertising:
|
if not device.supports_le_periodic_advertising:
|
||||||
print(color('Periodic advertising not supported', 'red'))
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
return
|
return
|
||||||
@@ -741,9 +848,10 @@ async def run_receive(
|
|||||||
]
|
]
|
||||||
packet_stats = [0, 0]
|
packet_stats = [0, 0]
|
||||||
|
|
||||||
async with contextlib.AsyncExitStack() as stack:
|
|
||||||
audio_output = await audio_io.create_audio_output(output)
|
audio_output = await audio_io.create_audio_output(output)
|
||||||
stack.push_async_callback(audio_output.aclose)
|
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
||||||
|
# longer needed.
|
||||||
|
try:
|
||||||
await audio_output.open(
|
await audio_output.open(
|
||||||
audio_io.PcmFormat(
|
audio_io.PcmFormat(
|
||||||
audio_io.PcmFormat.Endianness.LITTLE,
|
audio_io.PcmFormat.Endianness.LITTLE,
|
||||||
@@ -791,52 +899,167 @@ async def run_receive(
|
|||||||
terminated = asyncio.Event()
|
terminated = asyncio.Event()
|
||||||
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
|
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
|
||||||
await terminated.wait()
|
await terminated.wait()
|
||||||
|
finally:
|
||||||
|
await audio_output.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def run_transmit(
|
async def run_transmit(
|
||||||
transport: str,
|
device: bumble.device.Device, broadcasts: Iterable[Broadcast]
|
||||||
broadcast_id: int,
|
|
||||||
broadcast_code: str | None,
|
|
||||||
broadcast_name: str,
|
|
||||||
bitrate: int,
|
|
||||||
manufacturer_data: tuple[int, bytes] | None,
|
|
||||||
input: str,
|
|
||||||
input_format: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Run a pre-flight check for the input.
|
# Run a pre-flight check for the input(s).
|
||||||
try:
|
try:
|
||||||
if not audio_io.check_audio_input(input):
|
for broadcast in broadcasts:
|
||||||
|
for source in broadcast.sources:
|
||||||
|
if not audio_io.check_audio_input(source.input):
|
||||||
return
|
return
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
print(error)
|
print(error)
|
||||||
return
|
return
|
||||||
|
|
||||||
async with create_device(transport) as device:
|
|
||||||
if not device.supports_le_periodic_advertising:
|
if not device.supports_le_periodic_advertising:
|
||||||
print(color('Periodic advertising not supported', 'red'))
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def on_flow():
|
||||||
|
pending = []
|
||||||
|
queued = []
|
||||||
|
completed = []
|
||||||
|
for broadcast in broadcasts:
|
||||||
|
for iso_queue in broadcast.iso_queues:
|
||||||
|
data_packet_queue = iso_queue.data_packet_queue
|
||||||
|
pending.append(str(data_packet_queue.pending))
|
||||||
|
queued.append(str(data_packet_queue.queued))
|
||||||
|
completed.append(str(data_packet_queue.completed))
|
||||||
|
print(
|
||||||
|
f'\rPACKETS: '
|
||||||
|
f'pending={",".join(pending)} | '
|
||||||
|
f'queued={",".join(queued)} | '
|
||||||
|
f'completed={",".join(completed)}',
|
||||||
|
end='',
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_inputs: list[audio_io.AudioInput] = []
|
||||||
|
try:
|
||||||
|
# Setup audio sources
|
||||||
|
for broadcast_index, broadcast in enumerate(broadcasts):
|
||||||
|
channel_count = 0
|
||||||
|
max_lc3_frame_size = 0
|
||||||
|
max_sample_rate = 0
|
||||||
|
for source in broadcast.sources:
|
||||||
|
print(f'Setting up audio input: {source.input}')
|
||||||
|
|
||||||
|
# Open the audio input
|
||||||
|
audio_input = await audio_io.create_audio_input(
|
||||||
|
source.input, source.input_format
|
||||||
|
)
|
||||||
|
pcm_format = await audio_input.open()
|
||||||
|
audio_inputs.append(audio_input)
|
||||||
|
|
||||||
|
# Check that the number of channels is supported
|
||||||
|
if pcm_format.channels not in (1, 2):
|
||||||
|
print("Only 1 and 2 channels PCM configurations are supported")
|
||||||
|
return
|
||||||
|
channel_count += pcm_format.channels
|
||||||
|
|
||||||
|
# Check that the sample type is supported
|
||||||
|
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
||||||
|
pcm_bit_depth = 16
|
||||||
|
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
||||||
|
pcm_bit_depth = None
|
||||||
|
else:
|
||||||
|
print("Only INT16 and FLOAT32 sample types are supported")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check that the sample rate is supported
|
||||||
|
if pcm_format.sample_rate not in (16000, 24000, 48000):
|
||||||
|
print(f'Sample rate {pcm_format.sample_rate} not supported')
|
||||||
|
return
|
||||||
|
max_sample_rate = max(max_sample_rate, pcm_format.sample_rate)
|
||||||
|
|
||||||
|
# Compute LC3 parameters and create and encoder
|
||||||
|
encoder = lc3.Encoder(
|
||||||
|
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
|
||||||
|
sample_rate_hz=pcm_format.sample_rate,
|
||||||
|
num_channels=pcm_format.channels,
|
||||||
|
input_sample_rate_hz=pcm_format.sample_rate,
|
||||||
|
)
|
||||||
|
lc3_frame_samples = encoder.get_frame_samples()
|
||||||
|
lc3_frame_size = encoder.get_frame_bytes(source.bitrate)
|
||||||
|
max_lc3_frame_size = max(max_lc3_frame_size, lc3_frame_size)
|
||||||
|
print(
|
||||||
|
f'Encoding {source.input} with {lc3_frame_samples} '
|
||||||
|
f'PCM samples per {lc3_frame_size} byte frame'
|
||||||
|
)
|
||||||
|
|
||||||
|
broadcast.audio_sources.append(
|
||||||
|
AudioSource(
|
||||||
|
audio_input=audio_input,
|
||||||
|
pcm_format=pcm_format,
|
||||||
|
pcm_bit_depth=pcm_bit_depth,
|
||||||
|
lc3_encoder=encoder,
|
||||||
|
lc3_frame_samples=lc3_frame_samples,
|
||||||
|
lc3_frame_size=lc3_frame_size,
|
||||||
|
audio_frames=audio_input.frames(lc3_frame_samples),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup advertising and BIGs
|
||||||
|
metadata = le_audio.Metadata()
|
||||||
|
if broadcast.language is not None:
|
||||||
|
metadata.entries.append(
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.LANGUAGE,
|
||||||
|
data=broadcast.language.encode('utf-8'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if broadcast.program_info is not None:
|
||||||
|
metadata.entries.append(
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.PROGRAM_INFO,
|
||||||
|
data=broadcast.program_info.encode('utf-8'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if broadcast.public:
|
||||||
|
# Infer features from sources
|
||||||
|
features = pbp.PublicBroadcastAnnouncement.Features(0)
|
||||||
|
if broadcast.broadcast_code is not None:
|
||||||
|
features |= pbp.PublicBroadcastAnnouncement.Features.ENCRYPTED
|
||||||
|
for audio_source in broadcast.audio_sources:
|
||||||
|
if audio_source.pcm_format.sample_rate == 48000:
|
||||||
|
features |= (
|
||||||
|
pbp.PublicBroadcastAnnouncement.Features.HIGH_QUALITY_CONFIGURATION
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
features |= (
|
||||||
|
pbp.PublicBroadcastAnnouncement.Features.STANDARD_QUALITY_CONFIGURATION
|
||||||
|
)
|
||||||
|
|
||||||
|
public_broadcast_announcement = pbp.PublicBroadcastAnnouncement(
|
||||||
|
features=features, metadata=metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
public_broadcast_announcement = None
|
||||||
|
|
||||||
|
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(
|
||||||
|
broadcast.broadcast_id
|
||||||
|
)
|
||||||
|
|
||||||
basic_audio_announcement = bap.BasicAudioAnnouncement(
|
basic_audio_announcement = bap.BasicAudioAnnouncement(
|
||||||
presentation_delay=40000,
|
presentation_delay=40000,
|
||||||
subgroups=[
|
subgroups=[
|
||||||
bap.BasicAudioAnnouncement.Subgroup(
|
bap.BasicAudioAnnouncement.Subgroup(
|
||||||
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
||||||
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
sampling_frequency=bap.SamplingFrequency.FREQ_48000,
|
sampling_frequency=bap.SamplingFrequency.from_hz(
|
||||||
|
audio_source.pcm_format.sample_rate
|
||||||
|
),
|
||||||
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
||||||
octets_per_codec_frame=100,
|
octets_per_codec_frame=audio_source.lc3_frame_size,
|
||||||
),
|
),
|
||||||
metadata=le_audio.Metadata(
|
metadata=le_audio.Metadata(),
|
||||||
|
bis=(
|
||||||
[
|
[
|
||||||
le_audio.Metadata.Entry(
|
|
||||||
tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
|
|
||||||
),
|
|
||||||
le_audio.Metadata.Entry(
|
|
||||||
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
bis=[
|
|
||||||
bap.BasicAudioAnnouncement.BIS(
|
bap.BasicAudioAnnouncement.BIS(
|
||||||
index=1,
|
index=1,
|
||||||
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
@@ -849,124 +1072,134 @@ async def run_transmit(
|
|||||||
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
|
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
if audio_source.pcm_format.channels == 2
|
||||||
|
else [
|
||||||
|
bap.BasicAudioAnnouncement.BIS(
|
||||||
|
index=1,
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for audio_source in broadcast.audio_sources
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
|
||||||
)
|
|
||||||
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
|
|
||||||
|
|
||||||
advertising_data_types: list[core.DataType] = [
|
advertising_data_types: list[core.DataType] = [
|
||||||
data_types.BroadcastName(broadcast_name)
|
data_types.CompleteLocalName(AURACAST_DEFAULT_DEVICE_NAME),
|
||||||
|
data_types.Appearance(
|
||||||
|
core.Appearance.Category.AUDIO_SOURCE,
|
||||||
|
core.Appearance.AudioSourceSubcategory.BROADCASTING_DEVICE,
|
||||||
|
),
|
||||||
|
data_types.BroadcastName(broadcast.broadcast_name),
|
||||||
]
|
]
|
||||||
if manufacturer_data is not None:
|
|
||||||
|
if broadcast.manufacturer_data is not None:
|
||||||
advertising_data_types.append(
|
advertising_data_types.append(
|
||||||
data_types.ManufacturerSpecificData(*manufacturer_data)
|
data_types.ManufacturerSpecificData(*broadcast.manufacturer_data)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
advertising_data = bytes(core.AdvertisingData(advertising_data_types))
|
||||||
|
if public_broadcast_announcement:
|
||||||
|
advertising_data += bytes(
|
||||||
|
public_broadcast_announcement.get_advertising_data()
|
||||||
|
)
|
||||||
|
if broadcast_audio_announcement:
|
||||||
|
advertising_data += broadcast_audio_announcement.get_advertising_data()
|
||||||
|
|
||||||
|
print('Starting Periodic Advertising:')
|
||||||
|
print(f" Extended Advertising data size: {len(advertising_data)}")
|
||||||
|
print(
|
||||||
|
f" Periodic Advertising data size: {len(basic_audio_announcement.get_advertising_data())}"
|
||||||
|
)
|
||||||
advertising_set = await device.create_advertising_set(
|
advertising_set = await device.create_advertising_set(
|
||||||
advertising_parameters=bumble.device.AdvertisingParameters(
|
advertising_parameters=bumble.device.AdvertisingParameters(
|
||||||
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
||||||
is_connectable=False
|
is_connectable=False
|
||||||
),
|
),
|
||||||
primary_advertising_interval_min=100,
|
primary_advertising_interval_min=100,
|
||||||
primary_advertising_interval_max=200,
|
primary_advertising_interval_max=1000,
|
||||||
),
|
advertising_sid=broadcast_index,
|
||||||
advertising_data=(
|
|
||||||
broadcast_audio_announcement.get_advertising_data()
|
|
||||||
+ bytes(core.AdvertisingData(advertising_data_types))
|
|
||||||
),
|
),
|
||||||
|
advertising_data=advertising_data,
|
||||||
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
||||||
periodic_advertising_interval_min=80,
|
periodic_advertising_interval_min=100,
|
||||||
periodic_advertising_interval_max=160,
|
periodic_advertising_interval_max=1000,
|
||||||
),
|
),
|
||||||
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
||||||
auto_restart=True,
|
auto_restart=True,
|
||||||
auto_start=True,
|
auto_start=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
print('Start Periodic Advertising')
|
|
||||||
await advertising_set.start_periodic()
|
await advertising_set.start_periodic()
|
||||||
|
|
||||||
async with contextlib.AsyncExitStack() as stack:
|
print('Setting up BIG')
|
||||||
audio_input = await audio_io.create_audio_input(input, input_format)
|
|
||||||
pcm_format = await audio_input.open()
|
|
||||||
stack.push_async_callback(audio_input.aclose)
|
|
||||||
if pcm_format.channels != 2:
|
|
||||||
print("Only 2 channels PCM configurations are supported")
|
|
||||||
return
|
|
||||||
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
|
||||||
pcm_bit_depth = 16
|
|
||||||
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
|
||||||
pcm_bit_depth = None
|
|
||||||
else:
|
|
||||||
print("Only INT16 and FLOAT32 sample types are supported")
|
|
||||||
return
|
|
||||||
|
|
||||||
encoder = lc3.Encoder(
|
|
||||||
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
|
|
||||||
sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
|
|
||||||
num_channels=pcm_format.channels,
|
|
||||||
input_sample_rate_hz=pcm_format.sample_rate,
|
|
||||||
)
|
|
||||||
lc3_frame_samples = encoder.get_frame_samples()
|
|
||||||
lc3_frame_size = encoder.get_frame_bytes(bitrate)
|
|
||||||
print(
|
|
||||||
f'Encoding with {lc3_frame_samples} '
|
|
||||||
f'PCM samples per {lc3_frame_size} byte frame'
|
|
||||||
)
|
|
||||||
|
|
||||||
print('Setup BIG')
|
|
||||||
big = await device.create_big(
|
big = await device.create_big(
|
||||||
advertising_set,
|
advertising_set,
|
||||||
parameters=bumble.device.BigParameters(
|
parameters=bumble.device.BigParameters(
|
||||||
num_bis=pcm_format.channels,
|
num_bis=channel_count,
|
||||||
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
|
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
|
||||||
max_sdu=lc3_frame_size,
|
max_sdu=max_lc3_frame_size,
|
||||||
max_transport_latency=65,
|
max_transport_latency=65,
|
||||||
rtn=4,
|
rtn=4,
|
||||||
broadcast_code=(
|
broadcast_code=(
|
||||||
broadcast_code_bytes(broadcast_code) if broadcast_code else None
|
broadcast_code_bytes(broadcast.broadcast_code)
|
||||||
|
if broadcast.broadcast_code
|
||||||
|
else None
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for bis_link in big.bis_links:
|
|
||||||
|
for i, bis_link in enumerate(big.bis_links):
|
||||||
print(f'Setup ISO for BIS {bis_link.handle}')
|
print(f'Setup ISO for BIS {bis_link.handle}')
|
||||||
await bis_link.setup_data_path(
|
await bis_link.setup_data_path(
|
||||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||||
)
|
)
|
||||||
|
iso_queue = bumble.device.IsoPacketStream(bis_link, 64)
|
||||||
|
iso_queue.data_packet_queue.on('flow', on_flow)
|
||||||
|
broadcast.iso_queues.append(iso_queue)
|
||||||
|
|
||||||
iso_queues = [
|
print('Transmitting audio from source(s)')
|
||||||
bumble.device.IsoPacketStream(bis_link, 64)
|
while True:
|
||||||
for bis_link in big.bis_links
|
for broadcast in broadcasts:
|
||||||
|
iso_queue_index = 0
|
||||||
|
for audio_source in broadcast.audio_sources:
|
||||||
|
pcm_frame = await anext(audio_source.audio_frames)
|
||||||
|
lc3_frame = audio_source.lc3_encoder.encode(
|
||||||
|
pcm_frame,
|
||||||
|
num_bytes=audio_source.pcm_format.channels
|
||||||
|
* audio_source.lc3_frame_size,
|
||||||
|
bit_depth=audio_source.pcm_bit_depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
for lc3_chunk_start in range(
|
||||||
|
0, len(lc3_frame), audio_source.lc3_frame_size
|
||||||
|
):
|
||||||
|
await broadcast.iso_queues[iso_queue_index].write(
|
||||||
|
lc3_frame[
|
||||||
|
lc3_chunk_start : lc3_chunk_start
|
||||||
|
+ audio_source.lc3_frame_size
|
||||||
]
|
]
|
||||||
|
|
||||||
def on_flow():
|
|
||||||
data_packet_queue = iso_queues[0].data_packet_queue
|
|
||||||
print(
|
|
||||||
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
|
||||||
f'queued={data_packet_queue.queued}, '
|
|
||||||
f'completed={data_packet_queue.completed}',
|
|
||||||
end='',
|
|
||||||
)
|
)
|
||||||
|
iso_queue_index += 1
|
||||||
iso_queues[0].data_packet_queue.on('flow', on_flow)
|
finally:
|
||||||
|
for audio_input in audio_inputs:
|
||||||
frame_count = 0
|
await audio_input.aclose()
|
||||||
async for pcm_frame in audio_input.frames(lc3_frame_samples):
|
|
||||||
lc3_frame = encoder.encode(
|
|
||||||
pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
|
|
||||||
)
|
|
||||||
|
|
||||||
mid = len(lc3_frame) // 2
|
|
||||||
await iso_queues[0].write(lc3_frame[:mid])
|
|
||||||
await iso_queues[1].write(lc3_frame[mid:])
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
|
|
||||||
|
|
||||||
def run_async(async_command: Coroutine) -> None:
|
def run_async(
|
||||||
|
async_command: Callable[..., Awaitable[Any]],
|
||||||
|
transport: str,
|
||||||
|
*args,
|
||||||
|
) -> None:
|
||||||
|
async def run_with_transport():
|
||||||
|
async with create_device(transport) as device:
|
||||||
|
await async_command(device, *args)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(async_command)
|
asyncio.run(run_with_transport())
|
||||||
except core.ProtocolError as error:
|
except core.ProtocolError as error:
|
||||||
if error.error_namespace == 'att' and error.error_code in list(
|
if error.error_namespace == 'att' and error.error_code in list(
|
||||||
bass.ApplicationError
|
bass.ApplicationError
|
||||||
@@ -1004,7 +1237,7 @@ def auracast(ctx):
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def scan(ctx, filter_duplicates, sync_timeout, transport):
|
def scan(ctx, filter_duplicates, sync_timeout, transport):
|
||||||
"""Scan for public broadcasts"""
|
"""Scan for public broadcasts"""
|
||||||
run_async(run_scan(filter_duplicates, sync_timeout, transport))
|
run_async(run_scan, transport, filter_duplicates, sync_timeout)
|
||||||
|
|
||||||
|
|
||||||
@auracast.command('assist')
|
@auracast.command('assist')
|
||||||
@@ -1031,7 +1264,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def assist(ctx, broadcast_name, source_id, command, transport, address):
|
def assist(ctx, broadcast_name, source_id, command, transport, address):
|
||||||
"""Scan for broadcasts on behalf of an audio server"""
|
"""Scan for broadcasts on behalf of an audio server"""
|
||||||
run_async(run_assist(broadcast_name, source_id, command, transport, address))
|
run_async(run_assist, transport, broadcast_name, source_id, command, address)
|
||||||
|
|
||||||
|
|
||||||
@auracast.command('pair')
|
@auracast.command('pair')
|
||||||
@@ -1040,7 +1273,7 @@ def assist(ctx, broadcast_name, source_id, command, transport, address):
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def pair(ctx, transport, address):
|
def pair(ctx, transport, address):
|
||||||
"""Pair with an audio server"""
|
"""Pair with an audio server"""
|
||||||
run_async(run_pair(transport, address))
|
run_async(run_pair, transport, address)
|
||||||
|
|
||||||
|
|
||||||
@auracast.command('receive')
|
@auracast.command('receive')
|
||||||
@@ -1095,7 +1328,7 @@ def receive(
|
|||||||
):
|
):
|
||||||
"""Receive a broadcast source"""
|
"""Receive a broadcast source"""
|
||||||
run_async(
|
run_async(
|
||||||
run_receive(
|
run_receive,
|
||||||
transport,
|
transport,
|
||||||
broadcast_id,
|
broadcast_id,
|
||||||
output,
|
output,
|
||||||
@@ -1103,14 +1336,21 @@ def receive(
|
|||||||
sync_timeout,
|
sync_timeout,
|
||||||
subgroup,
|
subgroup,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@auracast.command('transmit')
|
@auracast.command('transmit')
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
|
@click.option(
|
||||||
|
'--broadcast-list',
|
||||||
|
metavar='BROADCAST_LIST',
|
||||||
|
help=(
|
||||||
|
'Filename of a TOML broadcast list with specification(s) for one or more '
|
||||||
|
'broadcast sources. When used, single-source options, including --input, '
|
||||||
|
'--input-format and others, are ignored.'
|
||||||
|
),
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--input',
|
'--input',
|
||||||
required=True,
|
|
||||||
help=(
|
help=(
|
||||||
"Audio input. "
|
"Audio input. "
|
||||||
"'device' -> use the host's default sound input device, "
|
"'device' -> use the host's default sound input device, "
|
||||||
@@ -1139,18 +1379,18 @@ def receive(
|
|||||||
'--broadcast-id',
|
'--broadcast-id',
|
||||||
metavar='BROADCAST_ID',
|
metavar='BROADCAST_ID',
|
||||||
type=int,
|
type=int,
|
||||||
default=123456,
|
default=AURACAST_DEFAULT_BROADCAST_ID,
|
||||||
help='Broadcast ID',
|
help='Broadcast ID',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--broadcast-code',
|
'--broadcast-code',
|
||||||
metavar='BROADCAST_CODE',
|
metavar='BROADCAST_CODE',
|
||||||
help='Broadcast encryption code in hex format',
|
help='Broadcast encryption code in hex format or as a string',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--broadcast-name',
|
'--broadcast-name',
|
||||||
metavar='BROADCAST_NAME',
|
metavar='BROADCAST_NAME',
|
||||||
default='Bumble Auracast',
|
default=AURACAST_DEFAULT_BROADCAST_NAME,
|
||||||
help='Broadcast name',
|
help='Broadcast name',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -1168,15 +1408,43 @@ def receive(
|
|||||||
def transmit(
|
def transmit(
|
||||||
ctx,
|
ctx,
|
||||||
transport,
|
transport,
|
||||||
broadcast_id,
|
broadcast_list,
|
||||||
broadcast_code,
|
|
||||||
manufacturer_data,
|
|
||||||
broadcast_name,
|
|
||||||
bitrate,
|
|
||||||
input,
|
input,
|
||||||
input_format,
|
input_format,
|
||||||
|
broadcast_id,
|
||||||
|
broadcast_code,
|
||||||
|
broadcast_name,
|
||||||
|
bitrate,
|
||||||
|
manufacturer_data,
|
||||||
):
|
):
|
||||||
"""Transmit a broadcast source"""
|
"""Transmit a broadcast source"""
|
||||||
|
if broadcast_list:
|
||||||
|
broadcasts = parse_broadcast_list(broadcast_list)
|
||||||
|
if not broadcasts:
|
||||||
|
print(color('!!! Broadcast list is empty or invalid', 'red'))
|
||||||
|
return
|
||||||
|
for broadcast in broadcasts:
|
||||||
|
if not broadcast.sources:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'!!! Broadcast "{broadcast.broadcast_name}" has no sources',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if input is None and broadcast_list is None:
|
||||||
|
print(
|
||||||
|
color('!!! --input is required if --broadcast-list is not used', 'red')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
input == 'device' or input.startswith('device:')
|
||||||
|
) and input_format == 'auto':
|
||||||
|
# Use a default format for device inputs
|
||||||
|
input_format = 'int16le,48000,1'
|
||||||
|
|
||||||
if manufacturer_data:
|
if manufacturer_data:
|
||||||
vendor_id_str, data_hex = manufacturer_data.split(':')
|
vendor_id_str, data_hex = manufacturer_data.split(':')
|
||||||
vendor_id = int(vendor_id_str)
|
vendor_id = int(vendor_id_str)
|
||||||
@@ -1185,22 +1453,28 @@ def transmit(
|
|||||||
else:
|
else:
|
||||||
manufacturer_data_tuple = None
|
manufacturer_data_tuple = None
|
||||||
|
|
||||||
if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
|
broadcasts = [
|
||||||
# Use a default format for device inputs
|
Broadcast(
|
||||||
input_format = 'int16le,48000,1'
|
sources=[
|
||||||
|
BroadcastSource(
|
||||||
run_async(
|
|
||||||
run_transmit(
|
|
||||||
transport=transport,
|
|
||||||
broadcast_id=broadcast_id,
|
|
||||||
broadcast_code=broadcast_code,
|
|
||||||
broadcast_name=broadcast_name,
|
|
||||||
bitrate=bitrate,
|
|
||||||
manufacturer_data=manufacturer_data_tuple,
|
|
||||||
input=input,
|
input=input,
|
||||||
input_format=input_format,
|
input_format=input_format,
|
||||||
|
bitrate=bitrate,
|
||||||
)
|
)
|
||||||
|
],
|
||||||
|
public=True,
|
||||||
|
broadcast_id=broadcast_id,
|
||||||
|
broadcast_name=broadcast_name,
|
||||||
|
broadcast_code=broadcast_code,
|
||||||
|
manufacturer_data=manufacturer_data_tuple,
|
||||||
|
language=AURACAST_DEFAULT_LANGUAGE,
|
||||||
|
program_info=AURACAST_DEFAULT_PROGRAM_INFO,
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assign_broadcast_ids(broadcasts)
|
||||||
|
|
||||||
|
run_async(run_transmit, transport, broadcasts)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ from bumble.hci import (
|
|||||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_VERSION_NAMES,
|
|
||||||
LMP_VERSION_NAMES,
|
|
||||||
CodecID,
|
CodecID,
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Command_Complete_Event,
|
HCI_Command_Complete_Event,
|
||||||
@@ -54,6 +52,7 @@ from bumble.hci import (
|
|||||||
HCI_Read_Local_Supported_Codecs_V2_Command,
|
HCI_Read_Local_Supported_Codecs_V2_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
LeFeature,
|
LeFeature,
|
||||||
|
SpecificationVersion,
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
@@ -289,14 +288,20 @@ async def async_main(
|
|||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color(' HCI Version: ', 'green'),
|
color(' HCI Version: ', 'green'),
|
||||||
name_or_number(HCI_VERSION_NAMES, host.local_version.hci_version),
|
SpecificationVersion(host.local_version.hci_version).name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' HCI Subversion:', 'green'),
|
||||||
|
f'0x{host.local_version.hci_subversion:04x}',
|
||||||
)
|
)
|
||||||
print(color(' HCI Subversion:', 'green'), host.local_version.hci_subversion)
|
|
||||||
print(
|
print(
|
||||||
color(' LMP Version: ', 'green'),
|
color(' LMP Version: ', 'green'),
|
||||||
name_or_number(LMP_VERSION_NAMES, host.local_version.lmp_version),
|
SpecificationVersion(host.local_version.lmp_version).name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' LMP Subversion:', 'green'),
|
||||||
|
f'0x{host.local_version.lmp_subversion:04x}',
|
||||||
)
|
)
|
||||||
print(color(' LMP Subversion:', 'green'), host.local_version.lmp_subversion)
|
|
||||||
|
|
||||||
# Get the Classic info
|
# Get the Classic info
|
||||||
await get_classic_info(host)
|
await get_classic_info(host)
|
||||||
|
|||||||
@@ -546,5 +546,6 @@ class SoundDeviceAudioInput(ThreadedAudioInput):
|
|||||||
return bytes(pcm_buffer)
|
return bytes(pcm_buffer)
|
||||||
|
|
||||||
def _close(self):
|
def _close(self):
|
||||||
|
if self._stream:
|
||||||
self._stream.stop()
|
self._stream.stop()
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
|||||||
@@ -864,8 +864,8 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
|
|||||||
|
|
||||||
EVENT_STATE_CHANGE = "state_change"
|
EVENT_STATE_CHANGE = "state_change"
|
||||||
EVENT_ESTABLISHMENT = "establishment"
|
EVENT_ESTABLISHMENT = "establishment"
|
||||||
|
EVENT_ESTABLISHMENT_ERROR = "establishment_error"
|
||||||
EVENT_CANCELLATION = "cancellation"
|
EVENT_CANCELLATION = "cancellation"
|
||||||
EVENT_ERROR = "error"
|
|
||||||
EVENT_LOSS = "loss"
|
EVENT_LOSS = "loss"
|
||||||
EVENT_PERIODIC_ADVERTISEMENT = "periodic_advertisement"
|
EVENT_PERIODIC_ADVERTISEMENT = "periodic_advertisement"
|
||||||
EVENT_BIGINFO_ADVERTISEMENT = "biginfo_advertisement"
|
EVENT_BIGINFO_ADVERTISEMENT = "biginfo_advertisement"
|
||||||
@@ -998,7 +998,7 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.state = self.State.ERROR
|
self.state = self.State.ERROR
|
||||||
self.emit(self.EVENT_ERROR)
|
self.emit(self.EVENT_ESTABLISHMENT_ERROR)
|
||||||
|
|
||||||
def on_loss(self):
|
def on_loss(self):
|
||||||
self.state = self.State.LOST
|
self.state = self.State.LOST
|
||||||
|
|||||||
@@ -207,22 +207,44 @@ def metadata(
|
|||||||
|
|
||||||
HCI_VENDOR_OGF = 0x3F
|
HCI_VENDOR_OGF = 0x3F
|
||||||
|
|
||||||
# HCI Version
|
# Specification Version
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
|
class SpecificationVersion(utils.OpenIntEnum):
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
|
BLUETOOTH_CORE_1_0B = 0
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_2 = 2
|
BLUETOOTH_CORE_1_1 = 1
|
||||||
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = 3
|
BLUETOOTH_CORE_1_2 = 2
|
||||||
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = 4
|
BLUETOOTH_CORE_2_0_EDR = 3
|
||||||
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = 5
|
BLUETOOTH_CORE_2_1_EDR = 4
|
||||||
HCI_VERSION_BLUETOOTH_CORE_4_0 = 6
|
BLUETOOTH_CORE_3_0_HS = 5
|
||||||
HCI_VERSION_BLUETOOTH_CORE_4_1 = 7
|
BLUETOOTH_CORE_4_0 = 6
|
||||||
HCI_VERSION_BLUETOOTH_CORE_4_2 = 8
|
BLUETOOTH_CORE_4_1 = 7
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
|
BLUETOOTH_CORE_4_2 = 8
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
|
BLUETOOTH_CORE_5_0 = 9
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
|
BLUETOOTH_CORE_5_1 = 10
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
|
BLUETOOTH_CORE_5_2 = 11
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
|
BLUETOOTH_CORE_5_3 = 12
|
||||||
HCI_VERSION_BLUETOOTH_CORE_6_0 = 14
|
BLUETOOTH_CORE_5_4 = 13
|
||||||
|
BLUETOOTH_CORE_6_0 = 14
|
||||||
|
BLUETOOTH_CORE_6_1 = 15
|
||||||
|
BLUETOOTH_CORE_6_2 = 16
|
||||||
|
|
||||||
|
# For backwards compatibility only
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_1_0B = SpecificationVersion.BLUETOOTH_CORE_1_0B
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_1_1 = SpecificationVersion.BLUETOOTH_CORE_1_1
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_1_2 = SpecificationVersion.BLUETOOTH_CORE_1_2
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = SpecificationVersion.BLUETOOTH_CORE_2_0_EDR
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = SpecificationVersion.BLUETOOTH_CORE_2_1_EDR
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = SpecificationVersion.BLUETOOTH_CORE_3_0_HS
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_4_0 = SpecificationVersion.BLUETOOTH_CORE_4_0
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_4_1 = SpecificationVersion.BLUETOOTH_CORE_4_1
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_4_2 = SpecificationVersion.BLUETOOTH_CORE_4_2
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_0 = SpecificationVersion.BLUETOOTH_CORE_5_0
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_1 = SpecificationVersion.BLUETOOTH_CORE_5_1
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_2 = SpecificationVersion.BLUETOOTH_CORE_5_2
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_3 = SpecificationVersion.BLUETOOTH_CORE_5_3
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_5_4 = SpecificationVersion.BLUETOOTH_CORE_5_4
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_6_0 = SpecificationVersion.BLUETOOTH_CORE_6_0
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_6_1 = SpecificationVersion.BLUETOOTH_CORE_6_1
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_6_2 = SpecificationVersion.BLUETOOTH_CORE_6_2
|
||||||
|
|
||||||
HCI_VERSION_NAMES = {
|
HCI_VERSION_NAMES = {
|
||||||
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
|
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
|
||||||
@@ -240,9 +262,10 @@ HCI_VERSION_NAMES = {
|
|||||||
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
|
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
|
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
|
||||||
HCI_VERSION_BLUETOOTH_CORE_6_0: 'HCI_VERSION_BLUETOOTH_CORE_6_0',
|
HCI_VERSION_BLUETOOTH_CORE_6_0: 'HCI_VERSION_BLUETOOTH_CORE_6_0',
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_6_1: 'HCI_VERSION_BLUETOOTH_CORE_6_1',
|
||||||
|
HCI_VERSION_BLUETOOTH_CORE_6_2: 'HCI_VERSION_BLUETOOTH_CORE_6_2',
|
||||||
}
|
}
|
||||||
|
|
||||||
# LMP Version
|
|
||||||
LMP_VERSION_NAMES = HCI_VERSION_NAMES
|
LMP_VERSION_NAMES = HCI_VERSION_NAMES
|
||||||
|
|
||||||
# HCI Packet types
|
# HCI Packet types
|
||||||
|
|||||||
@@ -338,7 +338,12 @@ class BroadcastAudioScanService(gatt.TemplateService):
|
|||||||
b"12", # TEST
|
b"12", # TEST
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__([self.battery_level_characteristic])
|
super().__init__(
|
||||||
|
[
|
||||||
|
self.broadcast_audio_scan_control_point_characteristic,
|
||||||
|
self.broadcast_receive_state_characteristic,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def on_broadcast_audio_scan_control_point_write(
|
def on_broadcast_audio_scan_control_point_write(
|
||||||
self, connection: device.Connection, value: bytes
|
self, connection: device.Connection, value: bytes
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import enum
|
|||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import core, data_types, gatt
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
|
|
||||||
|
|
||||||
@@ -46,3 +47,18 @@ class PublicBroadcastAnnouncement:
|
|||||||
return cls(
|
return cls(
|
||||||
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
|
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_advertising_data(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
data_types.ServiceData16BitUUID(
|
||||||
|
gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE, bytes(self)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
metadata_bytes = bytes(self.metadata)
|
||||||
|
return bytes([self.features, len(metadata_bytes)]) + metadata_bytes
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ The `--output` option specifies where to send the decoded audio samples.
|
|||||||
The following outputs are supported:
|
The following outputs are supported:
|
||||||
|
|
||||||
### Sound Device
|
### Sound Device
|
||||||
The `--output` argument is either `device`, to send the audio to the hosts's default sound device, or `device:<DEVICE_ID>` where `<DEVICE_ID>`
|
The `--output` argument is either `device`, to send the audio to the hosts's
|
||||||
|
default sound device, or `device:<DEVICE_ID>` where `<DEVICE_ID>`
|
||||||
is the integer ID of one of the available sound devices.
|
is the integer ID of one of the available sound devices.
|
||||||
When invoked with `--output "device:?"`, a list of available devices and
|
When invoked with `--output "device:?"`, a list of available devices and
|
||||||
their IDs is printed out.
|
their IDs is printed out.
|
||||||
@@ -115,17 +116,24 @@ standard output (currently always as float32 PCM samples)
|
|||||||
|
|
||||||
### FFPlay
|
### FFPlay
|
||||||
With `--output ffplay`, the decoded audio samples are piped to `ffplay`
|
With `--output ffplay`, the decoded audio samples are piped to `ffplay`
|
||||||
in a child process. This option is only available if `ffplay` is a command that is available on the host.
|
in a child process. This option is only available if `ffplay` is a command
|
||||||
|
that is available on the host.
|
||||||
|
|
||||||
### File
|
### File
|
||||||
With `--output <filename>` or `--output file:<filename>`, the decoded audio
|
With `--output <filename>` or `--output file:<filename>`, the decoded audio
|
||||||
samples are written to a file (currently always as float32 PCM)
|
samples are written to a file (currently always as float32 PCM)
|
||||||
|
|
||||||
## `transmit`
|
## `transmit`
|
||||||
Broadcast an audio source as a transmitter.
|
Broadcast one or more audio sources as a transmitter.
|
||||||
|
|
||||||
The `--input` and `--input-format` options specify what audio input
|
The `--input` and `--input-format` options specify what audio input
|
||||||
source to transmit.
|
source to transmit.
|
||||||
|
|
||||||
|
Optionally, you can use the `--broadcast-list` option,
|
||||||
|
specifying a TOML configuration file, as a convenient way to specify
|
||||||
|
audio source configurations for one or more audio sources.
|
||||||
|
See `examples/auracast_broadcasts.toml` for an example.
|
||||||
|
|
||||||
The following inputs are supported:
|
The following inputs are supported:
|
||||||
|
|
||||||
### Sound Device
|
### Sound Device
|
||||||
@@ -146,7 +154,8 @@ are read from a .wav or raw PCM file.
|
|||||||
Use the `--input-format <FORMAT>` option to specify the format of the audio
|
Use the `--input-format <FORMAT>` option to specify the format of the audio
|
||||||
samples in raw PCM files. `<FORMAT>` is expressed as:
|
samples in raw PCM files. `<FORMAT>` is expressed as:
|
||||||
`<sample-type>,<sample-rate>,<channels>`
|
`<sample-type>,<sample-rate>,<channels>`
|
||||||
(the only supported <sample-type> currently is 'int16le' for 16 bit signed integers with little-endian byte order)
|
(the only supported <sample-type> currently is 'int16le' for 16 bit signed
|
||||||
|
integers with little-endian byte order)
|
||||||
|
|
||||||
## `scan`
|
## `scan`
|
||||||
Scan for public broadcasts.
|
Scan for public broadcasts.
|
||||||
@@ -164,6 +173,7 @@ be shared to allow better compatibiity with certain products.
|
|||||||
The `receive` command has been tested to successfully receive broadcasts from
|
The `receive` command has been tested to successfully receive broadcasts from
|
||||||
the following transmitters:
|
the following transmitters:
|
||||||
|
|
||||||
|
* Android's "Audio Sharing"
|
||||||
* JBL GO 4
|
* JBL GO 4
|
||||||
* Flairmesh FlooGoo FMA120
|
* Flairmesh FlooGoo FMA120
|
||||||
* Eppfun AK3040Pro Max
|
* Eppfun AK3040Pro Max
|
||||||
@@ -193,10 +203,12 @@ Use the `--manufacturer-data` option of the `transmit` command in order to inclu
|
|||||||
that will let the speaker recognize the broadcast as a compatible source.
|
that will let the speaker recognize the broadcast as a compatible source.
|
||||||
|
|
||||||
The manufacturer ID for JBL is 87.
|
The manufacturer ID for JBL is 87.
|
||||||
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work (tested on the
|
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work
|
||||||
JBL GO 4. The `dffd` value at the end of the payload may be different on other models?).
|
(tested on the JBL GO 4.
|
||||||
|
The `dffd` value at the end of the payload may be different on other models?).
|
||||||
|
|
||||||
|
|
||||||
### Others
|
### Others
|
||||||
|
|
||||||
|
* Android
|
||||||
* Nexum Audio VOCE and USB dongle
|
* Nexum Audio VOCE and USB dongle
|
||||||
19
examples/auracast_broadcasts.toml
Normal file
19
examples/auracast_broadcasts.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[[broadcasts]]
|
||||||
|
name = "Broadcast 1"
|
||||||
|
id = 1234
|
||||||
|
language="en"
|
||||||
|
program_info="Jazz"
|
||||||
|
[[broadcasts.sources]]
|
||||||
|
input = "file:audio_1_48k.wav"
|
||||||
|
bitrate = 80000
|
||||||
|
|
||||||
|
[[broadcasts]]
|
||||||
|
name = "Broadcast 2"
|
||||||
|
id = 5678
|
||||||
|
language="fr"
|
||||||
|
program_info="Classical"
|
||||||
|
[[broadcasts.sources]]
|
||||||
|
input = "file:audio_2.wav"
|
||||||
|
[broadcasts.sources.manufacturer_data]
|
||||||
|
company_id = 87
|
||||||
|
data = "00000000000000000000000000000000dffd"
|
||||||
@@ -32,6 +32,7 @@ dependencies = [
|
|||||||
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
|
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
|
||||||
"pyserial >= 3.5; platform_system!='Emscripten'",
|
"pyserial >= 3.5; platform_system!='Emscripten'",
|
||||||
"pyusb >= 1.2; platform_system!='Emscripten'",
|
"pyusb >= 1.2; platform_system!='Emscripten'",
|
||||||
|
"tomli ~= 2.2.1; platform_system!='Emscripten'",
|
||||||
"websockets >= 15.0.1; platform_system!='Emscripten'",
|
"websockets >= 15.0.1; platform_system!='Emscripten'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user