add support for multiple concurrent broadcasts

This commit is contained in:
Gilles Boccon-Gibod
2025-12-05 10:54:23 -05:00
parent b4261548e8
commit 32bb7cdaf3
10 changed files with 868 additions and 512 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -35,8 +35,6 @@ from bumble.hci import (
HCI_READ_BUFFER_SIZE_COMMAND,
HCI_READ_LOCAL_NAME_COMMAND,
HCI_SUCCESS,
HCI_VERSION_NAMES,
LMP_VERSION_NAMES,
CodecID,
HCI_Command,
HCI_Command_Complete_Event,
@@ -54,6 +52,7 @@ from bumble.hci import (
HCI_Read_Local_Supported_Codecs_V2_Command,
HCI_Read_Local_Version_Information_Command,
LeFeature,
SpecificationVersion,
map_null_terminated_utf8_string,
)
from bumble.host import Host
@@ -289,14 +288,20 @@ async def async_main(
)
print(
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(
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
await get_classic_info(host)

View File

@@ -546,5 +546,6 @@ class SoundDeviceAudioInput(ThreadedAudioInput):
return bytes(pcm_buffer)
def _close(self):
self._stream.stop()
self._stream = None
if self._stream:
self._stream.stop()
self._stream = None

View File

@@ -864,8 +864,8 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
EVENT_STATE_CHANGE = "state_change"
EVENT_ESTABLISHMENT = "establishment"
EVENT_ESTABLISHMENT_ERROR = "establishment_error"
EVENT_CANCELLATION = "cancellation"
EVENT_ERROR = "error"
EVENT_LOSS = "loss"
EVENT_PERIODIC_ADVERTISEMENT = "periodic_advertisement"
EVENT_BIGINFO_ADVERTISEMENT = "biginfo_advertisement"
@@ -998,7 +998,7 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
return
self.state = self.State.ERROR
self.emit(self.EVENT_ERROR)
self.emit(self.EVENT_ESTABLISHMENT_ERROR)
def on_loss(self):
self.state = self.State.LOST

View File

@@ -207,22 +207,44 @@ def metadata(
HCI_VENDOR_OGF = 0x3F
# HCI Version
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
HCI_VERSION_BLUETOOTH_CORE_1_2 = 2
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = 3
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = 4
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = 5
HCI_VERSION_BLUETOOTH_CORE_4_0 = 6
HCI_VERSION_BLUETOOTH_CORE_4_1 = 7
HCI_VERSION_BLUETOOTH_CORE_4_2 = 8
HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
HCI_VERSION_BLUETOOTH_CORE_6_0 = 14
# Specification Version
class SpecificationVersion(utils.OpenIntEnum):
BLUETOOTH_CORE_1_0B = 0
BLUETOOTH_CORE_1_1 = 1
BLUETOOTH_CORE_1_2 = 2
BLUETOOTH_CORE_2_0_EDR = 3
BLUETOOTH_CORE_2_1_EDR = 4
BLUETOOTH_CORE_3_0_HS = 5
BLUETOOTH_CORE_4_0 = 6
BLUETOOTH_CORE_4_1 = 7
BLUETOOTH_CORE_4_2 = 8
BLUETOOTH_CORE_5_0 = 9
BLUETOOTH_CORE_5_1 = 10
BLUETOOTH_CORE_5_2 = 11
BLUETOOTH_CORE_5_3 = 12
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_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_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
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
# HCI Packet types

View File

@@ -338,7 +338,12 @@ class BroadcastAudioScanService(gatt.TemplateService):
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(
self, connection: device.Connection, value: bytes

View File

@@ -22,6 +22,7 @@ import enum
from typing_extensions import Self
from bumble import core, data_types, gatt
from bumble.profiles import le_audio
@@ -46,3 +47,18 @@ class PublicBroadcastAnnouncement:
return cls(
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

View File

@@ -104,7 +104,8 @@ The `--output` option specifies where to send the decoded audio samples.
The following outputs are supported:
### 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.
When invoked with `--output "device:?"`, a list of available devices and
their IDs is printed out.
@@ -115,17 +116,24 @@ standard output (currently always as float32 PCM samples)
### 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
With `--output <filename>` or `--output file:<filename>`, the decoded audio
samples are written to a file (currently always as float32 PCM)
## `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
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:
### 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
samples in raw PCM files. `<FORMAT>` is expressed as:
`<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 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 following transmitters:
* Android's "Audio Sharing"
* JBL GO 4
* Flairmesh FlooGoo FMA120
* 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.
The manufacturer ID for JBL is 87.
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work (tested on the
JBL GO 4. The `dffd` value at the end of the payload may be different on other models?).
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work
(tested on the JBL GO 4.
The `dffd` value at the end of the payload may be different on other models?).
### Others
* Android
* Nexum Audio VOCE and USB dongle

View 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"

View File

@@ -32,6 +32,7 @@ dependencies = [
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'",
"pyusb >= 1.2; platform_system!='Emscripten'",
"tomli ~= 2.2.1; platform_system!='Emscripten'",
"websockets >= 15.0.1; platform_system!='Emscripten'",
]