feat(ble_audio): Add CAP Handover (U2B & B2U) example

This commit is contained in:
Liu Linyan
2026-08-26 11:32:16 +08:00
parent 130e4a07bb
commit ed16327b81
47 changed files with 3749 additions and 120 deletions

View File

@@ -102,7 +102,17 @@ esp_err_t esp_ble_audio_gattc_disc_start(uint16_t conn_handle);
#define ESP_BLE_AUDIO_GAP_EVENT_ACL_DISCONNECT BT_LE_GAP_APP_EVENT_ACL_DISCONNECT
/*!< Audio GAP Security Change event */
#define ESP_BLE_AUDIO_GAP_EVENT_SECURITY_CHANGE BT_LE_GAP_APP_EVENT_SECURITY_CHANGE
/** Audio GAP application event structure */
/**
* @brief Audio GAP application event structure
*
* @note Addresses carried by these events are in the **active host's own byte
* order**: on-air/LSB-first under NimBLE, MSB-first under Bluedroid
* (the order every `esp_ble_gap_*` API takes). Feeding one straight back
* to a host API is therefore always correct; comparing one against an
* address the audio layer holds, such as a Broadcast Receive State,
* needs a reversal under Bluedroid. See `struct bt_le_addr` in
* common/app/gap.h for the full convention.
*/
typedef struct bt_le_gap_app_event esp_ble_audio_gap_app_event_t;
/*!< Audio GATT MTU exchange complete event */

View File

@@ -185,7 +185,7 @@ static const uint16_t ext_structs[] = {
sizeof(struct bt_bond_info),
};
#define LEA_VERSION (0x20260824)
#define LEA_VERSION (0x20260828)
struct lib_ext_cfgs {
/* BLE */

View File

@@ -1976,7 +1976,8 @@ struct bt_bap_unicast_client_cb {
* @param dir The type of remote endpoints and capabilities discovered.
* @param codec_cap Remote capabilities.
*
* If discovery procedure has complete both @p codec and @p ep are set to NULL.
* Called once per record; the end of the procedure is reported by the
* discover callback below, not by a NULL @p codec_cap.
*/
void (*pac_record)(struct bt_conn *conn, enum bt_audio_dir dir,
const struct bt_audio_codec_cap *codec_cap);
@@ -1990,21 +1991,21 @@ struct bt_bap_unicast_client_cb {
* @param dir The type of remote endpoints and capabilities discovered.
* @param ep Remote endpoint.
*
* If discovery procedure has complete both @p codec and @p ep are set to NULL.
* Called once per endpoint; the end of the procedure is reported by the
* discover callback below, not by a NULL @p ep.
*/
void (*endpoint)(struct bt_conn *conn, enum bt_audio_dir dir, struct bt_bap_ep *ep);
/**
* @brief BAP discovery callback function.
*
* If discovery procedure has completed @p ep is set to NULL and @p err is 0.
* Called once the discovery procedure has completed, for the direction it
* covered.
*
* @param conn Connection to the remote unicast server.
* @param err Error value. 0 on success, GATT error on positive value or errno on
* negative value.
* @param dir The type of remote endpoints and capabilities discovered.
*
* If discovery procedure has complete both @p codec and @p ep are set to NULL.
*/
void (*discover)(struct bt_conn *conn, int err, enum bt_audio_dir dir);

View File

@@ -996,19 +996,21 @@ struct bt_cap_handover_broadcast_to_unicast_param {
/** @brief Broadcast ID of the @p broadcast_source
*
* Ignored if @p reception_stop_param is not NULL.
* Always required: receive state notifications are matched against the
* {broadcast_id, adv_sid, adv_type} triple to decide that reception stopped,
* whether or not @p reception_stop_param is given.
*/
uint32_t broadcast_id;
/** @brief Advertising set ID of the @p broadcast_source
*
* Ignored if @p reception_stop_param is not NULL.
* Always required, see @p broadcast_id.
*/
uint8_t adv_sid;
/** @brief Advertising type of the advertising address of @p broadcast_source
*
* Ignored if @p reception_stop_param is not NULL.
* Always required, see @p broadcast_id.
*/
uint8_t adv_type;

View File

@@ -571,7 +571,17 @@ esp_err_t esp_ble_iso_chan_send_ts(esp_ble_iso_chan_t *chan,
#define ESP_BLE_ISO_GAP_EVENT_SECURITY_CHANGE BT_LE_GAP_APP_EVENT_SECURITY_CHANGE
/*!< ISO GAP BIGInfo Adv Report event */
#define ESP_BLE_ISO_GAP_EVENT_BIGINFO_RECV BT_LE_GAP_APP_EVENT_BIGINFO_RECV
/** ISO GAP application event structure */
/**
* @brief ISO GAP application event structure
*
* @note Addresses carried by these events are in the **active host's own byte
* order**: on-air/LSB-first under NimBLE, MSB-first under Bluedroid
* (the order every `esp_ble_gap_*` API takes). Feeding one straight back
* to a host API is therefore always correct; comparing one against an
* address the audio layer holds, such as a Broadcast Receive State,
* needs a reversal under Bluedroid. See `struct bt_le_addr` in
* common/app/gap.h for the full convention.
*/
typedef struct bt_le_gap_app_event esp_ble_iso_gap_app_event_t;
/** ISO initialization information structure */

View File

@@ -1881,7 +1881,7 @@ static void handle_gattc_notify_event(struct bt_le_gattc_notify_rx_event *event)
* tearing down a core subscription like the ASCS control point
* over one bad PDU would drop every later notification. Tolerate
* the bad PDU and keep the subscription. */
params->notify(conn, params, event->value, event->len);
params->notify(conn, params, NOTIFY_VALUE(event), event->len);
}
}
}

View File

@@ -221,7 +221,7 @@ static void handle_gattc_notify_rx_event_safe(struct bt_le_gattc_notify_rx_event
* tearing down a core subscription like the ASCS control point
* over one bad PDU would drop every later notification. Tolerate
* the bad PDU and keep the subscription. */
params->notify(conn, params, event->value, event->len);
params->notify(conn, params, NOTIFY_VALUE(event), event->len);
}
}
}

View File

@@ -27,8 +27,7 @@ static struct bt_le_ext_adv *ext_adv_find(uint8_t adv_handle)
struct bt_le_ext_adv *adv = NULL;
for (size_t i = 0; i < ARRAY_SIZE(ext_adv_pool); i++) {
if (atomic_test_bit(ext_adv_pool[i].flags,
BT_PER_ADV_PARAMS_SET) &&
if (atomic_test_bit(ext_adv_pool[i].flags, BT_PER_ADV_PARAMS_SET) &&
ext_adv_pool[i].handle == adv_handle) {
LOG_DBG("ExtAdvFound[%u][%u]", i, adv_handle);
adv = &ext_adv_pool[i];
@@ -44,8 +43,7 @@ static struct bt_le_ext_adv *ext_adv_new(void)
struct bt_le_ext_adv *adv = NULL;
for (size_t i = 0; i < ARRAY_SIZE(ext_adv_pool); i++) {
if (atomic_test_bit(ext_adv_pool[i].flags,
BT_PER_ADV_PARAMS_SET) == false) {
if (atomic_test_bit(ext_adv_pool[i].flags, BT_PER_ADV_PARAMS_SET) == false) {
adv = &ext_adv_pool[i];
memset(adv, 0, sizeof(*adv));
@@ -160,3 +158,23 @@ int bt_le_ext_adv_get_info(const struct bt_le_ext_adv *adv,
return 0;
}
_LIB_ONLY
struct bt_le_ext_adv *bt_le_ext_adv_lookup_addr(const bt_addr_le_t *adv_addr, uint8_t sid)
{
struct bt_le_ext_adv *adv = NULL;
BT_LE_ASSERT(adv_addr);
for (size_t i = 0; i < ARRAY_SIZE(ext_adv_pool); i++) {
if (atomic_test_bit(ext_adv_pool[i].flags, BT_PER_ADV_PARAMS_SET) &&
bt_addr_le_eq(&ext_adv_pool[i].addr, adv_addr) &&
ext_adv_pool[i].sid == sid) {
LOG_INF("ExtAdvLookupAddrFound[%u][%u]", i, sid);
adv = &ext_adv_pool[i];
break;
}
}
return adv;
}

View File

@@ -21,6 +21,30 @@
extern "C" {
#endif
/* Byte order of `val` is the active host's, NOT a single fixed convention:
*
* NimBLE on-air / LSB-first, val[0] is the least significant octet.
* Bluedroid MSB-first, val[0] is the most significant octet - the order
* esp_bd_addr_t uses and the order every esp_ble_gap_* API expects.
*
* That is deliberate: an address delivered by an event is normally handed
* straight back to the same host's API (create sync, connect, disconnect), so
* leaving it untouched keeps those paths correct on both hosts.
*
* The cost is that `bt_addr_le_t` in this port does not mean one thing either.
* On Bluedroid its contents follow whichever path filled it:
*
* conn->le.dst MSB - stored verbatim from the connect event and
* passed back to BTA_GATTC_* unchanged.
* bt_bond_info.addr MSB - read from the Bluedroid bond store as is.
* per_adv_sync->addr LSB - bt_le_per_adv_sync_new() reverses it, because
* it is compared against addresses off the air.
* BASS recv_state->addr LSB - arrives over ATT, where BASS defines LSB-first.
*
* So an application only has to convert where the two meet: comparing an event
* address against one the audio layer holds (a Broadcast Receive State, say)
* needs a reversal under CONFIG_BT_BLUEDROID_ENABLED and none under NimBLE.
*/
struct bt_le_addr {
uint8_t type;
uint8_t val[6];

View File

@@ -72,6 +72,15 @@ struct bt_le_gattc_notify_rx_event {
uint8_t *value;
};
/* Neither adapter allocates a buffer for a zero-length notification, but NULL data is
* how gatt.c completes an unsubscribe: a lib notify handler that sees it drops its
* subscription. A zero-length notification is a real PDU (BASS sends one for an emptied
* Broadcast Receive State), so keep the pointer non-NULL when handing it to the lib.
*/
#define NOTIFY_VALUE(_event) \
((const void *)((_event)->value != NULL ? (const uint8_t *)(_event)->value \
: (const uint8_t *)""))
struct bt_le_gatts_notify_tx_event {
bool is_notify;
uint16_t conn_handle;

View File

@@ -406,6 +406,16 @@ int bt_le_per_adv_sync_get_info(struct bt_le_per_adv_sync *per_adv_sync,
struct bt_le_per_adv_sync *bt_le_per_adv_sync_lookup_addr(const bt_addr_le_t *adv_addr,
uint8_t sid);
/**
* @brief Look up a local extended advertising set by advertiser address.
*
* @param adv_addr Advertiser address.
* @param sid The advertising set ID.
*
* @return Extended advertising set object or NULL if not found.
*/
struct bt_le_ext_adv *bt_le_ext_adv_lookup_addr(const bt_addr_le_t *adv_addr, uint8_t sid);
/**
* @brief Register periodic advertising sync callbacks.
*

View File

@@ -51,6 +51,7 @@ Application Examples
* :example:`bluetooth/esp_ble_audio/cap/acceptor` demonstrates how to act as a CAP Acceptor for unicast and broadcast flows.
* :example:`bluetooth/esp_ble_audio/cap/initiator` demonstrates how to act as a CAP Initiator for unicast and broadcast flows.
* :example:`bluetooth/esp_ble_audio/cap/handover` demonstrates how to perform CAP handovers between unicast and broadcast flows.
* **TMAP (Telephony and Media Audio Profile)**

View File

@@ -298,7 +298,7 @@ I (xxx) CAP_ACC: Scanning for broadcast source...
## Peer Pairing
Run the [initiator](../initiator/) on a second board. The initiator and acceptor must be configured for the **same sub-mode** — both `EXAMPLE_UNICAST`, or both `EXAMPLE_BROADCAST` — otherwise they will not pair.
Run the [initiator](../initiator/) on a second board with the matching role — both `EXAMPLE_UNICAST`, or both `EXAMPLE_BROADCAST`. For a CAP handover, run the [handover](../handover/) example instead, which needs both roles on this acceptor.
### Unicast
@@ -312,5 +312,60 @@ Run the [initiator](../initiator/) on a second board. The initiator and acceptor
1. Flash the initiator with `EXAMPLE_BROADCAST`; it advertises as `CAP Broadcast Source` (broadcast ID `0x123456`) and starts the BIG.
2. Flash this acceptor with `EXAMPLE_BROADCAST`. Either enable `EXAMPLE_SCAN_SELF` to self-scan for the source by name (broadcast code `1234`), or leave it disabled and use a separate Broadcast Assistant that connects via BASS to drive PA / BIS sync.
3. The acceptor PA-syncs, receives BASE and BIGInfo, syncs the first BIS, and `[SNK #0] Stream started`.
3. The acceptor PA-syncs, receives BASE and BIGInfo, and syncs one BIS per sink stream it has (`CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT`), up to what the first subgroup of the BASE offers: `[SNK #0] Stream started`, `[SNK #1] Stream started`.
4. On PA sync loss the acceptor cleans up; in self-scan mode it restarts scanning.
### Handover
1. Build this acceptor with both `EXAMPLE_UNICAST` and `EXAMPLE_BROADCAST` (the defaults),
so it exposes ASCS and BASS at the same time. No code change is needed.
2. Flash the [handover](../handover/) example on the other board. It connects, discovers
CAS, the ASEs and BASS, starts unicast audio, then alternates between unicast and broadcast.
3. On a unicast-to-broadcast handover the acceptor sees its sink ASEs released, then a
BASS Add Source from the collocated Commander, and PA/BIS-syncs to the new source.
4. On the way back the receive state is cleared and the sink ASEs are configured again.
Only the sink direction moves; a broadcast Audio Stream has no return path, so the source
ASE is not part of the procedure.
#### The sink stream pool is shared
Unicast and broadcast draw sink streams from the **same** pool: `stream_alloc(SINK)` hands out
`peer.sink_streams[]` entries whose endpoint is unbound. That is what lets a handover reuse the
objects, and it also means that while every sink stream carries a BIS there is none left to accept
a unicast Config.
The acceptor says so rather than letting the Initiator discover the shortage through a `NO_MEM`:
`sink_availability_update()` sets the **PACS Available Audio Contexts** for the sink direction to
`NONE` while receiving, and restores them when reception stops. CAP §7.3.1.8 / §7.3.1.9 describe
exactly this ("Start of reception **can** affect an Acceptor's availability for unicast Audio
Streams. In this case, the Acceptor **will** update its Available Audio Contexts characteristic").
It is driven off **BIS_Sync** in the Broadcast Receive State rather than off a local
"broadcasting" flag, because BIS_Sync is the field an Assistant clears *first* when it stops our
reception — which puts the restore comfortably ahead of the unicast Config that follows in a
broadcast-to-unicast handover.
#### PAST is expected, self-scan is not
The handover example is a collocated broadcaster and hands its periodic advertising train over
with **Set Info Transfer**. This acceptor must therefore be built **without** `EXAMPLE_SCAN_SELF`
(its Kconfig already makes that mutually exclusive with `EXAMPLE_UNICAST`). It reports
`PA_Sync_State = 1` (*SyncInfo Request*) and waits for the transfer; the Source ID arrives in the
**high octet** of the transfer's service data.
#### Source IDs are ours to assign, and they are reused
BASS Table 3.9: the Source_ID is *assigned by the server* and only has to be unique among the
receive states **currently exposed**. `next_src_id()` is a byte counter that skips values held by
active receive states, so a number becomes available again as soon as its receive state is
removed, and wraps after 256 allocations.
Two consequences worth knowing when reading logs:
* A receive state **outlives the Initiator's reboot** — it lives here. That is why the handover
example sweeps and clears leftovers when it connects; without that, its first Add Source after a
reflash is rejected with `0xFC` for duplicating the {address, SID, Broadcast ID} triple
(BAP §6.5.4).
* Source IDs keep climbing across the peer's restarts and only restart from 0 when **this** board
reboots. A jump back to 0 in the log means the acceptor restarted, not that a counter wrapped.

View File

@@ -13,7 +13,6 @@ menu "Example: CAP Acceptor"
config EXAMPLE_BROADCAST
bool "Broadcast"
default y if !EXAMPLE_UNICAST
select BT_NIMBLE_PERIODIC_ADV_SYNC_TRANSFER
help
If set, advertise as a Broadcast acceptor (BASS Scan Delegator)
for syncable broadcast audio. Can coexist with EXAMPLE_UNICAST

View File

@@ -45,9 +45,23 @@
ESP_BLE_AUDIO_CONTEXT_TYPE_MEDIA | \
ESP_BLE_AUDIO_CONTEXT_TYPE_GAME)
/* Number of stream objects the application backs per direction.
*
* Sink: one per exposed ASE, so a stereo Initiator can configure two independent
* sink streams. The CAP handover procedures hand over all streaming sink streams
* at once (CAP 7.3.1.10 applies to every CIS carrying Initiator-to-Acceptor audio),
* so the sink pool has to match the number of ASEs that can be configured.
*
* Source: one. Only the bidirectional (call) configuration uses it, and the TX
* pump in cap_acceptor_unicast.c drives a single stream. A Config request beyond
* these counts is answered with NO_MEM by stream_alloc().
*/
#define ACCEPTOR_SINK_STREAM_COUNT CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT
#define ACCEPTOR_SOURCE_STREAM_COUNT 1
struct peer_config {
esp_ble_audio_cap_stream_t source_stream;
esp_ble_audio_cap_stream_t sink_stream;
esp_ble_audio_cap_stream_t source_streams[ACCEPTOR_SOURCE_STREAM_COUNT];
esp_ble_audio_cap_stream_t sink_streams[ACCEPTOR_SINK_STREAM_COUNT];
uint16_t conn_handle;
/* Assistant's BD addr — used by Bluedroid pa_sync_with_past to set per-peer
* PAST receive params. Populated at ACL_CONNECT, cleared at ACL_DISCONNECT. */

View File

@@ -63,6 +63,21 @@ static inline bool flag_test_and_set(uint8_t bit)
return was;
}
/* The audio stack keeps addresses on-air (LSB-first) in bt_addr_le_t, Bluedroid takes
* and reports them MSB-first, NimBLE on-air. Convert whenever one meets the other; the
* reversal is its own inverse, so this serves both directions.
*/
static void addr_order_copy(uint8_t dst[6], const uint8_t src[6])
{
#if CONFIG_BT_BLUEDROID_ENABLED
for (size_t i = 0; i < 6; i++) {
dst[i] = src[5 - i];
}
#else
memcpy(dst, src, 6);
#endif
}
static struct broadcast_sink {
esp_ble_audio_cap_stream_t cap_streams[CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT];
const esp_ble_audio_bap_scan_delegator_recv_state_t *recv_state;
@@ -188,8 +203,15 @@ void broadcast_scan_recv(esp_ble_audio_gap_app_event_t *event)
}
#endif /* CONFIG_EXAMPLE_SCAN_SELF */
static void sink_availability_update(bool receiving);
static void broadcast_sink_reset(void)
{
/* Reception is over however we got here (Remove Source, PA loss, ACL drop),
* so the sink pool is free again even if no receive state said so.
*/
sink_availability_update(false);
broadcast_sink.recv_state = NULL;
broadcast_sink.sink = NULL;
broadcast_sink.requested_bis_sync = 0;
@@ -214,6 +236,29 @@ static void broadcast_sink_reset(void)
#endif /* CONFIG_EXAMPLE_SCAN_SELF */
}
struct base_subgroup_pick {
uint32_t bis_indexes;
bool found;
};
static bool base_pick_subgroup_cb(const esp_ble_audio_bap_base_subgroup_t *subgroup,
void *user_data)
{
struct base_subgroup_pick *pick = user_data;
/* Keep the first subgroup only. BIS of different subgroups carry different
* codec configurations, so a sink synchronizes within one subgroup.
*/
if (pick->found == false &&
esp_ble_audio_bap_base_subgroup_get_bis_indexes(subgroup,
&pick->bis_indexes) == ESP_OK) {
pick->found = true;
}
/* Always continue: stopping early makes the iterator return -ECANCELED. */
return true;
}
static void check_sync_broadcast(void)
{
esp_ble_audio_bap_stream_t *streams[CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT];
@@ -258,22 +303,33 @@ static void check_sync_broadcast(void)
}
if (broadcast_sink.requested_bis_sync == ESP_BLE_AUDIO_BAP_BIS_SYNC_NO_PREF) {
struct base_subgroup_pick pick = {0};
uint32_t base_bis;
/* Get the first BIS index from the BASE */
err = esp_ble_audio_bap_base_get_bis_indexes(
(esp_ble_audio_bap_base_t *)broadcast_sink.received_base, &base_bis);
if (err) {
/* Get the BIS indexes offered by the first subgroup of the BASE */
err = esp_ble_audio_bap_base_foreach_subgroup(
(esp_ble_audio_bap_base_t *)broadcast_sink.received_base,
base_pick_subgroup_cb, &pick);
if (err || pick.found == false) {
ESP_LOGE(TAG, "Failed to get BIS indexes from BASE, err %d", err);
return;
}
base_bis = pick.bis_indexes;
sync_bitfield = 0;
/* No Broadcast Assistant told us what to sync to, so take as many BIS as
* this sink has streams for: a stereo source advertises one BIS per
* channel and syncing to only the first would drop the other channel.
*/
for (uint8_t i = ESP_BLE_ISO_BIS_INDEX_MIN; i <= ESP_BLE_ISO_BIS_INDEX_MAX; i++) {
if (base_bis & ESP_BLE_ISO_BIS_INDEX_BIT(i)) {
sync_bitfield = ESP_BLE_ISO_BIS_INDEX_BIT(i);
break;
sync_bitfield |= ESP_BLE_ISO_BIS_INDEX_BIT(i);
if (__builtin_popcount(sync_bitfield) >=
CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT) {
break;
}
}
}
@@ -327,7 +383,9 @@ static void broadcast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
ESP_LOGI(TAG, "[SNK #%u] Stream started", idx);
example_audio_rx_metrics_reset(&rx_metrics[idx]);
if (idx < ARRAY_SIZE(rx_metrics)) {
example_audio_rx_metrics_reset(&rx_metrics[idx]);
}
broadcast_sink.active_streams++;
flag_clear(FLAG_BROADCAST_SYNCING);
@@ -411,6 +469,10 @@ static void broadcast_stream_recv_cb(esp_ble_audio_bap_stream_t *stream,
uint8_t idx = broadcast_stream_idx(stream);
char obj_name[10];
if (idx >= ARRAY_SIZE(rx_metrics)) {
return;
}
rx_metrics[idx].last_sdu_len = len;
snprintf(obj_name, sizeof(obj_name), "SNK #%u", idx);
example_audio_rx_metrics_on_recv(info, &rx_metrics[idx], TAG, obj_name);
@@ -460,16 +522,64 @@ static void syncable_cb(esp_ble_audio_bap_broadcast_sink_t *sink,
}
}
/* CAP 7.3.1.8 / 7.3.1.9: "Start of reception can affect an Acceptor's
* availability for unicast Audio Streams. In this case, the Acceptor will
* update its Available Audio Contexts characteristic."
*
* It does affect us: stream_alloc() hands unicast and broadcast the same sink
* pool, so while every sink stream carries a BIS there is none left to accept a
* unicast Config. Saying so keeps the Initiator's view honest instead of
* letting it discover the shortage through a NO_MEM.
*
* Driven off BIS_Sync rather than off a local "broadcasting" flag because that
* is the field the Assistant clears first when it stops our reception - which
* puts the restore well ahead of the unicast Config that follows in a
* broadcast-to-unicast handover.
*/
static void sink_availability_update(bool receiving)
{
static bool reported_unavailable;
esp_ble_audio_context_t context;
esp_err_t err;
if (receiving == reported_unavailable) {
return;
}
context = receiving ? ESP_BLE_AUDIO_CONTEXT_TYPE_NONE : SINK_CONTEXT;
err = esp_ble_audio_pacs_set_available_contexts(ESP_BLE_AUDIO_DIR_SINK, context);
if (err) {
ESP_LOGE(TAG, "Failed to update sink available contexts, err %d", err);
return;
}
reported_unavailable = receiving;
ESP_LOGI(TAG, "Sink available contexts now 0x%04x (%s broadcast)",
context, receiving ? "receiving" : "not receiving");
}
static void recv_state_updated_cb(esp_ble_conn_t *conn,
const esp_ble_audio_bap_scan_delegator_recv_state_t *recv_state)
{
bool receiving = false;
ESP_LOGI(TAG, "Receive state updated, pa_sync 0x%02x encrypt 0x%02x",
recv_state->pa_sync_state, recv_state->encrypt_state);
for (uint8_t i = 0; i < recv_state->num_subgroups; i++) {
ESP_LOGI(TAG, "subgroup %d bis_sync 0x%08x", i, recv_state->subgroups[i].bis_sync);
/* BIS_SYNC_FAILED is non-zero but carries no BIS (BASS 3.1.1.5). */
if (recv_state->subgroups[i].bis_sync != 0 &&
recv_state->subgroups[i].bis_sync != ESP_BLE_AUDIO_BAP_BIS_SYNC_FAILED) {
receiving = true;
}
}
sink_availability_update(receiving);
if (recv_state->pa_sync_state == ESP_BLE_AUDIO_BAP_PA_STATE_SYNCED) {
broadcast_sink.recv_state = recv_state;
}
@@ -516,8 +626,11 @@ static int pa_sync_req_cb(esp_ble_conn_t *conn,
ESP_LOGI(TAG, "Waiting for PAST...");
} else {
err = pa_sync_create(recv_state->addr.type, recv_state->addr.a.val,
recv_state->adv_sid);
uint8_t addr[6];
addr_order_copy(addr, recv_state->addr.a.val);
err = pa_sync_create(recv_state->addr.type, addr, recv_state->adv_sid);
if (err) {
return err;
}
@@ -662,7 +775,7 @@ void broadcast_pa_synced(esp_ble_audio_gap_app_event_t *event)
int err;
addr.type = event->pa_sync.addr.type;
memcpy(addr.a.val, event->pa_sync.addr.val, sizeof(addr.a.val));
addr_order_copy(addr.a.val, event->pa_sync.addr.val);
if (broadcast_sink.sync_handle == PA_SYNC_HANDLE_INIT ||
(broadcast_sink.recv_state &&

View File

@@ -25,11 +25,20 @@ static const esp_ble_audio_bap_qos_cfg_pref_t qos_pref =
20000, /* Preferred Minimum Presentation Delay (usec) */
40000); /* Preferred Maximum Presentation Delay (usec) */
static example_audio_rx_metrics_t rx_metrics;
/* One set of metrics per sink stream: only the sink direction receives, and each
* stream has to be counted separately or a stereo setup reports the sum of both
* channels against whichever stream happened to cross the reporting threshold.
*/
static example_audio_rx_metrics_t rx_metrics[ACCEPTOR_SINK_STREAM_COUNT];
static example_audio_tx_scheduler_t tx_scheduler;
static uint16_t tx_seq_num;
static uint8_t *iso_data;
/* Source stream currently driven by the TX pump. Set when a source stream starts
* and cleared when it stops: stream_alloc() hands out *free* streams, so it can
* no longer be used to look this one up once it is attached.
*/
static esp_ble_audio_cap_stream_t *tx_cap_stream;
static const struct peer_config *s_peer;
@@ -53,11 +62,15 @@ static const char *stream_dir_str(const esp_ble_audio_bap_stream_t *stream)
*/
if (stream->ep == NULL) {
if (s_peer != NULL) {
if (stream == &s_peer->sink_stream.bap_stream) {
return "SNK";
for (size_t i = 0; i < ARRAY_SIZE(s_peer->sink_streams); i++) {
if (stream == &s_peer->sink_streams[i].bap_stream) {
return "SNK";
}
}
if (stream == &s_peer->source_stream.bap_stream) {
return "SRC";
for (size_t i = 0; i < ARRAY_SIZE(s_peer->source_streams); i++) {
if (stream == &s_peer->source_streams[i].bap_stream) {
return "SRC";
}
}
}
return "???";
@@ -72,9 +85,26 @@ static const char *stream_dir_str(const esp_ble_audio_bap_stream_t *stream)
static int stream_index(const esp_ble_audio_bap_stream_t *stream)
{
/* Only one sink and one source per peer in this example. */
(void)stream;
return 0;
if (s_peer == NULL || stream == NULL) {
return -1;
}
/* Index within the pool of its own direction, so logs read as "SNK #0" /
* "SNK #1" for the two sink streams of a stereo Initiator.
*/
for (size_t i = 0; i < ARRAY_SIZE(s_peer->sink_streams); i++) {
if (stream == &s_peer->sink_streams[i].bap_stream) {
return (int)i;
}
}
for (size_t i = 0; i < ARRAY_SIZE(s_peer->source_streams); i++) {
if (stream == &s_peer->source_streams[i].bap_stream) {
return (int)i;
}
}
return -1;
}
static void tx_scheduler_cb(void *arg)
@@ -289,14 +319,20 @@ static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
ESP_LOGI(TAG, "[%s #%d] Stream started",
stream_dir_str(stream), stream_index(stream));
example_audio_rx_metrics_reset(&rx_metrics);
err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info);
if (err) {
ESP_LOGE(TAG, "Failed to get ep info, err %d", err);
return;
}
if (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) {
const int idx = stream_index(stream);
if (idx >= 0 && idx < (int)ARRAY_SIZE(rx_metrics)) {
example_audio_rx_metrics_reset(&rx_metrics[idx]);
}
}
if (ep_info.dir == ESP_BLE_AUDIO_DIR_SOURCE) {
if (stream->qos == NULL || stream->qos->sdu == 0) {
ESP_LOGE(TAG, "Invalid stream qos");
@@ -311,12 +347,14 @@ static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
}
}
tx_cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
tx_seq_num = 0;
example_audio_tx_scheduler_reset(&tx_scheduler);
err = example_audio_tx_scheduler_start(&tx_scheduler, stream->qos->interval);
if (err) {
ESP_LOGE(TAG, "Failed to start tx scheduler, err %d", err);
tx_cap_stream = NULL;
return;
}
@@ -356,6 +394,8 @@ static void unicast_stream_stopped_cb(esp_ble_audio_bap_stream_t *stream, uint8_
ESP_LOGE(TAG, "Failed to stop tx scheduler, err %d", err);
}
tx_cap_stream = NULL;
if (iso_data != NULL) {
free(iso_data);
iso_data = NULL;
@@ -383,6 +423,8 @@ static void unicast_stream_disconnected_cb(esp_ble_audio_bap_stream_t *stream, u
ESP_LOGE(TAG, "Failed to stop tx scheduler, err %d", err);
}
tx_cap_stream = NULL;
if (iso_data != NULL) {
free(iso_data);
iso_data = NULL;
@@ -406,12 +448,17 @@ static void unicast_stream_recv_cb(esp_ble_audio_bap_stream_t *stream,
const esp_ble_iso_recv_info_t *info,
const uint8_t *data, uint16_t len)
{
const int idx = stream_index(stream);
char name[24];
snprintf(name, sizeof(name), "%s #%d",
stream_dir_str(stream), stream_index(stream));
rx_metrics.last_sdu_len = len;
example_audio_rx_metrics_on_recv(info, &rx_metrics, TAG, name);
/* Only sink streams receive, so the index is always within the sink pool. */
if (idx < 0 || idx >= (int)ARRAY_SIZE(rx_metrics)) {
return;
}
snprintf(name, sizeof(name), "%s #%d", stream_dir_str(stream), idx);
rx_metrics[idx].last_sdu_len = len;
example_audio_rx_metrics_on_recv(info, &rx_metrics[idx], TAG, name);
}
static void unicast_stream_sent_cb(esp_ble_audio_bap_stream_t *stream, void *user_data)
@@ -444,8 +491,10 @@ static void unicast_server_tx(void)
esp_ble_audio_bap_stream_t *bap_stream;
esp_err_t err;
cap_stream = stream_alloc(ESP_BLE_AUDIO_DIR_SOURCE);
assert(cap_stream);
cap_stream = tx_cap_stream;
if (cap_stream == NULL) {
return;
}
bap_stream = &cap_stream->bap_stream;
if (bap_stream->ep == NULL) {
@@ -512,16 +561,20 @@ int cap_acceptor_unicast_init(struct peer_config *peer)
cbs_registered = true;
}
err = esp_ble_audio_cap_stream_ops_register(&peer->source_stream, &unicast_stream_ops);
if (err) {
ESP_LOGE(TAG, "Failed to register source stream ops, err %d", err);
return -1;
for (size_t i = 0; i < ARRAY_SIZE(peer->source_streams); i++) {
err = esp_ble_audio_cap_stream_ops_register(&peer->source_streams[i], &unicast_stream_ops);
if (err) {
ESP_LOGE(TAG, "Failed to register source stream ops [%zu], err %d", i, err);
return -1;
}
}
err = esp_ble_audio_cap_stream_ops_register(&peer->sink_stream, &unicast_stream_ops);
if (err) {
ESP_LOGE(TAG, "Failed to register sink stream ops, err %d", err);
return -1;
for (size_t i = 0; i < ARRAY_SIZE(peer->sink_streams); i++) {
err = esp_ble_audio_cap_stream_ops_register(&peer->sink_streams[i], &unicast_stream_ops);
if (err) {
ESP_LOGE(TAG, "Failed to register sink stream ops [%zu], err %d", i, err);
return -1;
}
}
err = example_audio_tx_scheduler_init(&tx_scheduler,

View File

@@ -16,7 +16,11 @@ static uint8_t codec_data[] =
ESP_BLE_AUDIO_CODEC_CAP_FREQ_ANY, /* Sampling frequency Any */
ESP_BLE_AUDIO_CODEC_CAP_DURATION_7_5 | \
ESP_BLE_AUDIO_CODEC_CAP_DURATION_10, /* Frame duration 7.5ms/10ms */
ESP_BLE_AUDIO_CODEC_CAP_CHAN_COUNT_SUPPORT(2), /* Supported channels 2 */
/* Bitfield, not a maximum: SUPPORT(1, 2) advertises both 1- and
* 2-channel configurations. A stereo Initiator may either configure two
* 1-channel streams (one per ASE) or a single 2-channel stream.
*/
ESP_BLE_AUDIO_CODEC_CAP_CHAN_COUNT_SUPPORT(1, 2),
30, /* Minimum 30 octets per frame */
155, /* Maximum 155 octets per frame */
2); /* Maximum 2 codec frames per SDU */
@@ -86,12 +90,27 @@ static uint8_t ext_adv_data[] = {
esp_ble_audio_cap_stream_t *stream_alloc(esp_ble_audio_dir_t dir)
{
esp_ble_audio_cap_stream_t *pool;
size_t count;
if (dir == ESP_BLE_AUDIO_DIR_SINK) {
return &peer.sink_stream;
pool = peer.sink_streams;
count = ARRAY_SIZE(peer.sink_streams);
} else if (dir == ESP_BLE_AUDIO_DIR_SOURCE) {
pool = peer.source_streams;
count = ARRAY_SIZE(peer.source_streams);
} else {
return NULL;
}
if (dir == ESP_BLE_AUDIO_DIR_SOURCE) {
return &peer.source_stream;
/* A stream is free while no endpoint is attached to it: the stack attaches
* the endpoint right after the Config callback returns and detaches it on
* release, so the pool needs no separate in-use flag.
*/
for (size_t i = 0; i < count; i++) {
if (pool[i].bap_stream.ep == NULL) {
return &pool[i];
}
}
return NULL;
@@ -99,10 +118,21 @@ esp_ble_audio_cap_stream_t *stream_alloc(esp_ble_audio_dir_t dir)
void stream_released(const esp_ble_audio_cap_stream_t *cap_stream)
{
if (cap_stream == &peer.source_stream) {
ESP_LOGI(TAG, "Source stream released");
} else if (cap_stream == &peer.sink_stream) {
ESP_LOGI(TAG, "Sink stream released");
/* Releasing detached the endpoint, which already returned the object to the
* pool (see stream_alloc), so there is nothing to book-keep here.
*/
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (cap_stream == &peer.sink_streams[i]) {
ESP_LOGI(TAG, "Sink stream #%zu released", i);
return;
}
}
for (size_t i = 0; i < ARRAY_SIZE(peer.source_streams); i++) {
if (cap_stream == &peer.source_streams[i]) {
ESP_LOGI(TAG, "Source stream #%zu released", i);
return;
}
}
}

View File

@@ -14,7 +14,7 @@ CONFIG_BT_GATTC_NOTIF_REG_MAX=20
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_BLE_FEAT_PERIODIC_ADV_SYNC_TRANSFER=y
CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_BT_ISO_MAX_CHAN=4
CONFIG_BT_CAP_ACCEPTOR=y
CONFIG_BT_BAP_UNICAST_SERVER=y
@@ -33,6 +33,7 @@ CONFIG_BT_AUDIO_CODEC_CFG_MAX_METADATA_SIZE=60
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_EXAMPLE_UNICAST=y
CONFIG_EXAMPLE_BROADCAST=y
CONFIG_FREERTOS_HZ=1000

View File

@@ -0,0 +1,8 @@
# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
idf_build_set_property(MINIMAL_BUILD ON)
project(cap_handover)

View File

@@ -0,0 +1,243 @@
| Supported Targets | ESP32-H4 | ESP32-S31 |
| ----------------- | -------- | --------- |
# CAP Handover Example
(See the README.md file in the upper level `examples` directory for more information about examples.)
## Overview
This example implements a device with a **collocated CAP Initiator and CAP Commander** and moves
the audio back and forth between unicast and broadcast using the **CAP Handover procedures**
(CAP v1.0.1 §7.3.1.10 Unicast to Broadcast, §7.3.1.11 Broadcast to Unicast).
It connects to a CAP Acceptor exactly like [`../initiator/`](../initiator/) does — scan, connect,
pair, exchange MTU, discover CAS / sink ASEs / source ASEs — and additionally discovers the
Acceptor's **Broadcast Audio Scan Service (BASS)**, which the Commander writes into to tell the
Acceptor about the broadcast source. Once unicast audio is streaming, a timer alternates between
the two procedures every `EXAMPLE_HANDOVER_PERIOD_S`.
**Only the sink direction is handed over.** A broadcast Audio Stream is one-directional, so the
return (Acceptor to Initiator) stream is not part of the procedure: §7.3.1.10 applies to "all CISes
within the CIG that is carrying an Audio Stream from the Initiator to the Acceptor", and leaves the
reverse direction implementation specific.
The very same `bt_cap_stream` objects carry the audio before and after a handover — that is a
requirement of the CAP handover API, not a shortcut: the procedure validates that every broadcast
stream is one of the unicast group's streaming sink streams, and that *all* of them are handed over.
Consequently there is **no standalone broadcast source** in this example: it is created by the
unicast-to-broadcast procedure and deleted by the reverse one.
## Requirements
* A board with BLE 5.2, ISO, and LE Audio support (e.g. ESP32-H4, ESP32-S31)
* A second board running [`../acceptor/`](../acceptor/) with **both** `EXAMPLE_UNICAST` and
`EXAMPLE_BROADCAST` enabled (its `sdkconfig.defaults` already does), so it exposes ASCS and BASS
at the same time
* The Acceptor must **not** be built with `EXAMPLE_SCAN_SELF` (its Kconfig already makes that
mutually exclusive with `EXAMPLE_UNICAST`) — see *Periodic advertising sync* below
## Periodic advertising sync: PAST only
This example is a **collocated broadcaster**: the same device is the Broadcast Source and the
Commander that tells the Acceptor about it. It therefore never scans for its own broadcast, and it
hands the periodic advertising train over with **PAST** rather than asking the Acceptor to find it.
* The Add Source operation is written with `PA_Sync = 0x01` (*synchronize, PAST available*).
* The Acceptor answers by putting its Broadcast Receive State into `PA_Sync_State = 1`
(*SyncInfo Request*).
* Seeing that state, this example sends **LE Periodic Advertising Set Info Transfer** — the
variant for handing over one of *our own* advertising sets. (The other variant, Sync Transfer,
applies when an Assistant relays a sync it holds to a third-party broadcaster; that is not this
case.) The Source ID goes in the **high octet** of the service data, which is where the Acceptor
reads it from.
There is deliberately **no self-scan fallback**: a real earbud is not asked to scan for a source
its phone already knows about. If the Acceptor were built to self-scan instead, it would sit at
"Waiting for BASE" forever.
## Configuration
```bash
idf.py menuconfig
```
Under **Example: CAP Handover**:
* `EXAMPLE_HANDOVER_PERIOD_S` (default 300) — seconds between switches. Set to 0 to stay on
unicast after the initial setup.
> **Size note.** The Broadcast Assistant (BASS *client*) is genuinely used — it is how the
> Commander writes Add Source / Modify Source / Remove Source. The Scan Delegator (BASS
> *server*) is **not**: it is pulled in only because every branch of `BT_CAP_COMMANDER`'s
> `depends on` that avoids it requires some other client role we do not need either
> (`BT_VCP_VOL_CTLR`, `BT_MICP_MIC_CTLR`, `BT_TBS_CLIENT`, `BT_MCC`). CAP §7.3.1.8 only requires
> a Commander to act as Scan Delegator when it is **not collocated** with the Initiator **and**
> the stream is encrypted — neither holds here — so this is an over-constraint in the Kconfig
> rather than a real dependency.
### Security & Pairing
Just-Works pairing (LE Secure Connections, no MITM) with bonding, inherited from
`../../common_components/example_init/ble_audio_example_init.c`.
## Build & Flash
```bash
idf.py set-target esp32h4
idf.py -p PORT flash monitor
```
For the **NimBLE** host, layer the overlay:
```bash
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32h4;sdkconfig.defaults.nimble" -p PORT flash monitor
```
(Exit serial monitor with `Ctrl-]`.)
## Example Flow
1. `app_main` brings up NVS, the controller, the LE Audio common layer, the unicast role, the
handover module and the TX pump, then starts scanning.
2. On finding CAS in a connectable advertisement: connect, pair, exchange MTU, GATT discovery.
3. Discover CAS, then the sink and source ASEs, then **BASS**.
4. **Sweep the Acceptor's Broadcast Receive States.** Discovery only subscribes to them, so the
example reads each one once and removes any left over from an earlier run of ours (matched by
Broadcast ID). Receive states live on the Acceptor and survive a reflash of *this* board, and
BAP §6.5.4 forbids an Add Source that would duplicate the
{address, SID, Broadcast ID} triple — without the sweep the first handover after a reboot
fails with `0xFC` (Write Request Rejected). The sweep then continues into
`cap_handover_unicast_setup_and_start()`.
5. A unicast group is created with one CIS per sink stream (the return direction shares the first
CIS) and the streams start.
6. **Unicast to broadcast**, every `EXAMPLE_HANDOVER_PERIOD_S`:
* the advertising set is brought up (extended advertising started, periodic advertising
configured but *stopped* — the BASE does not exist yet);
* the local public address and SID are registered with the audio stack, because the BASS Add
Source operation carries them;
* `esp_ble_audio_cap_handover_unicast_to_broadcast()` stops and releases the unicast group,
creates the broadcast source, and has the Commander write Add Source to the Acceptor;
* from the `created` callback the BASE is encoded and periodic advertising is started.
7. **Broadcast to unicast**, one period later: the Commander stops the reception on the Acceptor
(using the Source ID learned from the Broadcast Receive State), the broadcast source is stopped
and deleted, a new unicast group is created and the streams start again; advertising is stopped.
8. On ACL disconnect any broadcast source left over is deleted and scanning resumes.
## Expected Log
TAG: `CAP_HOV`.
```
I (xxx) CAP_HOV: CAP initiator unicast initialized
I (xxx) CAP_HOV: CAP initiator handover initialized (period 60 s)
I (xxx) CAP_HOV: Scanning for CAP Acceptor...
I (xxx) CAP_HOV: Found CAS in peer adv data!
I (xxx) CAP_HOV: Connected: handle ... role ... peer ...
I (xxx) CAP_HOV: Discover sources complete
I (xxx) CAP_HOV: Discovering BASS
I (xxx) CAP_HOV: BASS discovered (1 receive state(s))
I (xxx) CAP_HOV: Created unicast group
I (xxx) CAP_HOV: [SNK #0] Stream started
I (xxx) CAP_HOV: [SNK #1] Stream started
I (xxx) CAP_HOV: Unicast start completed
I (xxx) CAP_HOV: Handover: unicast -> broadcast (2 stream(s))
I (xxx) CAP_HOV: Advertising started, BASE pending (handle 0)
I (xxx) CAP_HOV: Periodic advertising started (handle 0)
I (xxx) CAP_HOV: Acceptor receive state: src_id 1 pa_sync 2
I (xxx) CAP_HOV: Handover to broadcast completed
I (xxx) CAP_HOV: Handover: broadcast -> unicast (2 stream(s))
I (xxx) CAP_HOV: Advertising stopped (handle 0)
I (xxx) CAP_HOV: Handover to unicast completed
```
Note the TX label follows the transport: `[SNK #0]` while the stream runs on a CIS, `[SRC #0]`
once the same stream object is bound to a broadcast source endpoint. Seeing `[SRC #0] TX: 6000
packets` during a broadcast period is the check that audio really is going out over the BIS.
On the acceptor side each switch shows the sink ASEs being released and a BASS Add Source arriving,
then the reverse.
### Timing
Each procedure is strictly sequential, so there is a silent gap at every switch. Measured on
ESP32-S31 with two sink streams, it is stable to within a few milliseconds:
| Direction | Duration |
|---|---|
| unicast to broadcast | ~1.69 s |
| broadcast to unicast | ~2.65 s |
## Diagnostics
Either side of every handover the example samples the ISO timing of each stream and logs it under
`[<phase>]`, where phase is `u2b-pre`, `u2b-post`, `b2u-pre` or `b2u-post`:
```
I (xxx) CAP_HOV: [u2b-pre][0] state 4 ts 66125612 us offset 0 us seq 6025
I (xxx) CAP_HOV: [u2b-pre][0] iso_interval 10000 us cig_sync_delay 2256 us cis_sync_delay 2256 us
I (xxx) CAP_HOV: [u2b-pre][0] c2p {ft 1 bn 1 latency 2256 us sdu_interval unknown (v1 event)}
I (xxx) CAP_HOV: [u2b-pre][0] p2c {ft 1 bn 0 latency 0 us sdu_interval unknown (v1 event)}
I (xxx) CAP_HOV: [u2b-pre][0] pd_pref [20000, 40000] us pd_range [20000, 40000] us
```
A BIS reports the same first line plus its own terms, and no QoS preference:
```
I (xxx) CAP_HOV: [u2b-post][0] state 4 (timestamp not read: transport just started)
I (xxx) CAP_HOV: [u2b-post][0] iso_interval 10000 us big_sync_delay 1974 us
I (xxx) CAP_HOV: [u2b-post][0] bis {pto 0 bn 1 latency 1974 us}
```
These are the terms CAP §7.3.1.10's rendering-point alignment would need. Reading them:
* `ts` is `TX_Time_Stamp`: the CIG reference point or BIG anchor point, taken from the
controller's free-running clock. Both transports use the same clock, so the two can be
subtracted directly.
* The **post** samples deliberately do not read `ts`. HCI answers *Command Disallowed* until a
stream has sent its first SDU, and a transport that has just come up has not; the timestamp for
the new transport shows up in the next **pre** sample instead.
* `sdu_interval` is reported as unknown because it is only carried by
`HCI_LE_CIS_Established_V2`; the controller sends the v1 event. Use the QoS value instead.
* `latency` is the transport latency the controller actually achieved, not the maximum the preset
asked for.
* `ft` is derived: the host stores `flush_timeout = FT x ISO_Interval`, so it is divided back out
here — the alignment arithmetic wants FT itself.
* Only a CIS prints `pd_pref` / `pd_range`. Those come from the peer ASE's Codec Configured
notification, and a broadcast source has no peer to state a preference, so the fields would
only ever read zero.
## Peer Pairing
1. Flash [`../acceptor/`](../acceptor/) on one board with both roles enabled (default).
2. Flash this example on the other board.
3. Audio starts on CISes, then alternates with BISes every period.
## Notes
* The handover procedures in this stack are **sequential**: the unicast streams are stopped before
the broadcast source is created (CAP allows this as the "Initiator does not have the resources to
concurrently run both" path). There is therefore an audible gap at each switch. CAP §7.3.1.10 also
recommends aligning the rendering points of the two streams, which only makes sense on the
concurrent path and is not implemented.
* `Streaming_Audio_Contexts` and `CCID_List` are required by the specification to be the same on
both sides of a handover. So is the **codec configuration**: a different sampling frequency,
frame duration or frame size would make the Acceptor's decoder reconfigure exactly at the
switching point. Both paths therefore take their configuration from one
`HANDOVER_LC3_PRESET_DEFINE()` in `cap_handover.h`, and `handover_audio_config_check()` rejects
the handover if they ever diverge. Only the channel allocation differs by design: unicast puts
one channel in each stream, broadcast puts it in a per-BIS LTV.
* The **Source ID is assigned by the Acceptor**, not chosen here — the Add Source operation has no
such field. It is learned from the Broadcast Receive State and then used for Modify/Remove
Source. It keeps counting across a reflash of this board and only restarts when the *Acceptor*
reboots, which is why the receive-state sweep in step 4 exists.
## Troubleshooting
| Symptom | Cause |
|---|---|
| First handover after reflashing this board fails with `err 252` | A receive state left on the Acceptor by the previous run. The sweep in step 4 clears it; if it was removed, this is what comes back. |
| Acceptor logs "Syncing without PAST", then waits for the BASE forever | The Add Source went out with `PA_Sync = 0x02`. Check that the advertising set is registered with `esp_ble_audio_bap_broadcast_adv_add()` before the handover — its address and SID are what make PAST available. |
| Broadcast period is silent, no `[SRC #x] TX:` line | The TX pump did not register for the broadcast streams. It decides with `bt_bap_ep_info.can_send`; deriving it from `dir` instead does not work, because a broadcast source endpoint reports `dir = SOURCE` while a unicast client endpoint reports the peer's direction. |

View File

@@ -0,0 +1,18 @@
set(srcs "main.c"
"cap_handover_proc.c"
"cap_handover_unicast.c"
"cap_handover_broadcast.c"
"cap_handover_tx.c"
"cap_handover_diag.c")
if(CONFIG_BT_BLUEDROID_ENABLED)
list(APPEND srcs "bluedroid/central.c"
"bluedroid/adv.c")
else()
list(APPEND srcs "nimble/central.c"
"nimble/adv.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)

View File

@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
menu "Example: CAP Handover"
config EXAMPLE_HANDOVER_PERIOD_S
int "Seconds between automatic handovers (0 to disable)"
default 300
help
Once unicast audio is streaming the sample alternates between unicast
and broadcast every this many seconds. Set to 0 to stay on unicast
after the initial setup.
endmenu

View File

@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "esp_bt_defs.h"
#include "esp_bt_device.h"
#include "esp_gap_ble_api.h"
#include "adv.h"
#include "cap_handover.h"
static SemaphoreHandle_t adv_sem;
static esp_bt_status_t adv_op_status;
#define WAIT_API(_call) EXAMPLE_WAIT_API_CHECK(_call, adv_sem, portMAX_DELAY, adv_op_status)
static esp_ble_gap_ext_adv_params_t ext_adv_params = {
.type = ESP_BLE_GAP_SET_EXT_ADV_PROP_NONCONN_NONSCANNABLE_UNDIRECTED,
.interval_min = ESP_BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS),
.interval_max = ESP_BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS),
.channel_map = ADV_CHNL_ALL,
.filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
.primary_phy = ESP_BLE_GAP_PHY_1M,
.max_skip = 0,
.secondary_phy = ESP_BLE_GAP_PHY_2M,
.sid = ADV_SID,
.scan_req_notif = false,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.tx_power = ADV_TX_POWER,
};
static esp_ble_gap_periodic_adv_params_t periodic_adv_params = {
.interval_min = ESP_BLE_GAP_PERIODIC_ADV_ITVL_MS(PER_ADV_INTERVAL_MS),
.interval_max = ESP_BLE_GAP_PERIODIC_ADV_ITVL_MS(PER_ADV_INTERVAL_MS),
.properties = 0,
};
static esp_ble_gap_ext_adv_t ext_adv_inst[1] = {
[0] = { ADV_HANDLE, 0, 0 },
};
void adv_gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_EXT_ADV_SET_PARAMS_COMPLETE_EVT:
adv_op_status = param->ext_adv_set_params.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_DATA_SET_COMPLETE_EVT:
adv_op_status = param->ext_adv_data_set.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_START_COMPLETE_EVT:
adv_op_status = param->ext_adv_start.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_STOP_COMPLETE_EVT:
adv_op_status = param->ext_adv_stop.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_SET_PARAMS_COMPLETE_EVT:
adv_op_status = param->peroid_adv_set_params.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_DATA_SET_COMPLETE_EVT:
adv_op_status = param->period_adv_data_set.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_START_COMPLETE_EVT:
adv_op_status = param->period_adv_start.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_STOP_COMPLETE_EVT:
adv_op_status = param->period_adv_stop.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_SET_INFO_TRANS_COMPLETE_EVT:
/* Not awaited: the Acceptor reports the result through its receive state. */
if (param->period_adv_set_info_trans.status != ESP_BT_STATUS_SUCCESS) {
ESP_LOGE(TAG, "PAST failed, status %d", param->period_adv_set_info_trans.status);
}
break;
default:
break;
}
}
int adv_host_init(void)
{
adv_sem = xSemaphoreCreateBinary();
if (adv_sem == NULL) {
ESP_LOGE(TAG, "Failed to create adv semaphore");
return -1;
}
return 0;
}
static int adv_set_configure(const uint8_t *ext_data, uint8_t ext_len)
{
WAIT_API(esp_ble_gap_ext_adv_set_params(ADV_HANDLE, &ext_adv_params));
WAIT_API(esp_ble_gap_config_ext_adv_data_raw(ADV_HANDLE, ext_len, ext_data));
WAIT_API(esp_ble_gap_periodic_adv_set_params(ADV_HANDLE, &periodic_adv_params));
return 0;
}
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len)
{
#if CONFIG_BT_BLE_FEAT_PERIODIC_ADV_ENH
WAIT_API(esp_ble_gap_config_periodic_adv_data_raw(ADV_HANDLE, per_len, per_data, false));
WAIT_API(esp_ble_gap_periodic_adv_start(ADV_HANDLE, true));
#else
WAIT_API(esp_ble_gap_config_periodic_adv_data_raw(ADV_HANDLE, per_len, per_data));
WAIT_API(esp_ble_gap_periodic_adv_start(ADV_HANDLE));
#endif
ESP_LOGI(TAG, "Periodic advertising started (handle %u)", ADV_HANDLE);
return 0;
}
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len)
{
int err;
err = adv_set_configure(ext_data, ext_len);
if (err) {
return err;
}
WAIT_API(esp_ble_gap_ext_adv_start(1, ext_adv_inst));
ESP_LOGI(TAG, "Advertising started, BASE pending (handle %u)", ADV_HANDLE);
return 0;
}
int adv_stop(void)
{
uint8_t instance = ADV_HANDLE;
WAIT_API(esp_ble_gap_periodic_adv_stop(ADV_HANDLE));
WAIT_API(esp_ble_gap_ext_adv_stop(1, &instance));
ESP_LOGI(TAG, "Advertising stopped (handle %u)", ADV_HANDLE);
return 0;
}
int local_public_addr_get(uint8_t addr[6])
{
const uint8_t *bda = esp_bt_dev_get_address();
if (bda == NULL) {
ESP_LOGE(TAG, "Local BD address unavailable");
return -1;
}
/* Bluedroid hands out addresses MSB-first; the caller feeds this to the audio
* stack, which stores a bt_addr_le_t and puts it on air LSB-first.
*/
for (size_t i = 0; i < 6; i++) {
addr[i] = bda[5 - i];
}
return 0;
}
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data)
{
esp_bd_addr_t addr;
(void)conn_handle;
memcpy(addr, peer_addr, sizeof(addr));
return esp_ble_gap_periodic_adv_set_info_trans(addr, service_data, ADV_HANDLE);
}

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_gap_ble_api.h"
void adv_gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
int adv_host_init(void);

View File

@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "esp_bt_defs.h"
#include "esp_gap_ble_api.h"
#include "esp_gattc_api.h"
#include "esp_ble_audio_common_api.h"
#include "adv.h"
#include "cap_handover.h"
static SemaphoreHandle_t scan_sem;
static esp_bt_status_t scan_op_status;
#define WAIT_API(_call) EXAMPLE_WAIT_API_CHECK(_call, scan_sem, portMAX_DELAY, scan_op_status)
static esp_bd_addr_t peer_bda;
static esp_ble_ext_scan_params_t ext_scan_params = {
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.filter_policy = BLE_SCAN_FILTER_ALLOW_ALL,
.scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE,
.cfg_mask = ESP_BLE_GAP_EXT_SCAN_CFG_UNCODE_MASK,
.uncoded_cfg = {
.scan_type = BLE_SCAN_TYPE_PASSIVE,
.scan_interval = SCAN_INTERVAL,
.scan_window = SCAN_WINDOW,
},
};
static void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_SET_EXT_SCAN_PARAMS_COMPLETE_EVT:
scan_op_status = param->set_ext_scan_params.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_EXT_SCAN_START_COMPLETE_EVT:
scan_op_status = param->ext_scan_start.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_EXT_SCAN_STOP_COMPLETE_EVT:
scan_op_status = param->ext_scan_stop.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_NC_REQ_EVT:
esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, true);
break;
case ESP_GAP_BLE_AUTH_CMPL_EVT:
esp_ble_audio_gap_app_post_event(event, param);
break;
default:
/* Bluedroid has one GAP callback slot; the advertising events belong
* to the other wrapper. The two event sets are disjoint. */
adv_gap_event_handler(event, param);
break;
}
}
int app_host_init(void)
{
esp_err_t err;
scan_sem = xSemaphoreCreateBinary();
if (scan_sem == NULL) {
ESP_LOGE(TAG, "Failed to create scan semaphore");
return -1;
}
err = adv_host_init();
if (err) {
vSemaphoreDelete(scan_sem);
return err;
}
err = esp_ble_gap_register_callback(gap_event_handler);
if (err) {
ESP_LOGE(TAG, "Failed to register GAP callback, err %d", err);
vSemaphoreDelete(scan_sem);
return err;
}
return 0;
}
int set_device_name(void)
{
return esp_ble_gap_set_device_name(LOCAL_DEVICE_NAME);
}
int ext_scan_start(void)
{
WAIT_API(esp_ble_gap_set_ext_scan_params(&ext_scan_params));
WAIT_API(esp_ble_gap_start_ext_scan(0, 0));
ESP_LOGI(TAG, "Scanning for CAP Acceptor...");
return 0;
}
int ext_scan_stop(void)
{
WAIT_API(esp_ble_gap_stop_ext_scan());
return 0;
}
int conn_create(uint8_t addr_type, const uint8_t addr[6])
{
const esp_ble_gap_conn_params_t conn_params = {
.scan_interval = INIT_SCAN_INTERVAL,
.scan_window = INIT_SCAN_WINDOW,
.interval_min = CONN_INTERVAL,
.interval_max = CONN_INTERVAL,
.latency = CONN_LATENCY,
.supervision_timeout = CONN_TIMEOUT,
.min_ce_len = CONN_MIN_CE_LEN,
.max_ce_len = CONN_MAX_CE_LEN,
};
esp_gatt_if_t gattc_if;
esp_err_t err;
memcpy(peer_bda, addr, sizeof(peer_bda));
err = esp_ble_gap_prefer_ext_connect_params_set(
peer_bda, ESP_BLE_GAP_PHY_1M_PREF_MASK, &conn_params, NULL, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to set ext conn params, err %d", err);
return err;
}
/* Use the audio engine's GATTC if — events route back to the engine
* which forwards them to the lib. Registering a separate example GATTC
* app would steer events to a handler the engine never sees.
*
* engine returns ESP_GATT_IF_NONE (0xFF) when GATTC is not yet
* registered; feeding that into aux_open silently no-ops in BTC and
* no acl_connect event ever fires, leaving the caller in a no-scan
* / no-conn dead state. */
gattc_if = esp_ble_audio_bluedroid_get_gattc_if();
if (gattc_if == ESP_GATT_IF_NONE) {
ESP_LOGE(TAG, "GATTC not registered");
return ESP_ERR_INVALID_STATE;
}
return esp_ble_gattc_aux_open(gattc_if, peer_bda,
(esp_ble_addr_type_t)addr_type, true);
}
int pairing_start(uint16_t conn_handle)
{
(void)conn_handle;
return esp_ble_set_encryption(peer_bda, ESP_BLE_SEC_ENCRYPT_NO_MITM);
}
int exchange_mtu(uint16_t conn_handle)
{
(void)conn_handle;
/* The Bluedroid GATTC adapter exchanges MTU automatically when aux_open
* brings up the ACL — the MTU_UPDATED event arrives without an explicit
* kick-off. NimBLE has no such auto-exchange, so this wrapper is a no-op
* on bluedroid and a real ble_gattc_exchange_mtu on nimble. */
return 0;
}
void security_failed_recover(uint16_t conn_handle, uint8_t status)
{
(void)conn_handle;
ESP_LOGE(TAG, "Security change failed, status %u, clearing local bond and reconnecting", status);
esp_ble_remove_bond_device(peer_bda);
esp_ble_gap_disconnect(peer_bda);
}

View File

@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "esp_log.h"
#include "sdkconfig.h"
#include "esp_ble_audio_lc3_defs.h"
#include "esp_ble_audio_bap_api.h"
#include "esp_ble_audio_cap_api.h"
#include "esp_ble_audio_pacs_api.h"
#include "esp_ble_audio_bap_lc3_preset_defs.h"
#include "ble_audio_example_init.h"
#include "ble_audio_example_utils.h"
#define TAG "CAP_HOV"
#define CONN_HANDLE_INIT 0xFFFF
#define SINK_STREAM_COUNT MIN(CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT, \
CONFIG_BT_BAP_BROADCAST_SRC_STREAM_COUNT)
#define LOCAL_DEVICE_NAME "CAP Handover"
#define SCAN_INTERVAL 160 /* 100ms */
#define SCAN_WINDOW 160 /* 100ms */
#define INIT_SCAN_INTERVAL 16 /* 10ms */
#define INIT_SCAN_WINDOW 16 /* 10ms */
#define CONN_INTERVAL 24 /* 30ms */
#define CONN_LATENCY 0
#define CONN_TIMEOUT 500 /* 5s */
#define CONN_MIN_CE_LEN 0xFFFF
#define CONN_MAX_CE_LEN 0xFFFF
#define ADV_HANDLE 0
#define ADV_SID 0
#define ADV_TX_POWER 127
#define ADV_INTERVAL_MS 200
#define PER_ADV_INTERVAL_MS 100
#define LOCAL_BROADCAST_ID 0x123456
#define HANDOVER_LC3_PRESET_DEFINE(_name, _loc) \
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE( \
_name, _loc, ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED)
int app_host_init(void);
int set_device_name(void);
int ext_scan_start(void);
int ext_scan_stop(void);
int conn_create(uint8_t addr_type, const uint8_t addr[6]);
int pairing_start(uint16_t conn_handle);
int exchange_mtu(uint16_t conn_handle);
void security_failed_recover(uint16_t conn_handle, uint8_t status);
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len);
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len);
int adv_stop(void);
int local_public_addr_get(uint8_t addr[6]);
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data);
struct tx_stream {
esp_ble_audio_cap_stream_t *stream;
uint16_t seq_num;
uint8_t *data;
example_audio_tx_scheduler_t scheduler;
bool is_broadcast;
};
struct peer_config {
esp_ble_audio_cap_stream_t sink_streams[SINK_STREAM_COUNT];
esp_ble_audio_bap_ep_t *sink_eps[SINK_STREAM_COUNT];
size_t sink_ep_count;
esp_ble_conn_t *conn;
uint16_t conn_handle;
uint8_t dst[6];
bool disc_completed;
bool mtu_exchanged;
};
extern struct peer_config peer;
esp_ble_audio_cap_unicast_group_t *unicast_group_get(void);
void unicast_group_set(esp_ble_audio_cap_unicast_group_t *group);
int unicast_group_delete(void);
esp_ble_audio_bap_lc3_preset_t *sink_preset_get(size_t idx);
int cap_handover_unicast_setup_and_start(void);
void cap_handover_unicast_gap_cb(esp_ble_audio_gap_app_event_t *event);
void cap_handover_unicast_gatt_cb(esp_ble_audio_gatt_app_event_t *event);
int cap_handover_unicast_start(void);
int cap_handover_unicast_init(void);
uint8_t *broadcast_ext_adv_data_get(uint8_t *data_len);
uint8_t *broadcast_base_data_get(esp_ble_audio_cap_broadcast_source_t *source,
uint8_t *data_len);
esp_ble_audio_bap_lc3_preset_t *broadcast_preset_get(void);
esp_ble_audio_location_t broadcast_bis_location_get(size_t idx);
int cap_handover_proc_init(void);
int cap_handover_proc_discover(uint16_t conn_handle);
void cap_handover_proc_unicast_started(void);
void cap_handover_proc_broadcast_stopped(esp_ble_audio_cap_broadcast_source_t *source);
void cap_handover_diag_sample(const char *phase, bool has_sent);
void cap_handover_proc_reset(void);
void cap_handover_tx_stream_sent(esp_ble_audio_bap_stream_t *stream, void *user_data);
int cap_handover_tx_register_stream(esp_ble_audio_cap_stream_t *cap_stream, bool is_broadcast);
int cap_handover_tx_unregister_stream(esp_ble_audio_cap_stream_t *cap_stream);
void cap_handover_tx_init(void);

View File

@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include <errno.h>
#include "cap_handover.h"
/* Shared subgroup configuration; the per-BIS channel allocation goes in a
* separate LTV, so the location here stays mono.
*/
HANDOVER_LC3_PRESET_DEFINE(broadcast_preset, ESP_BLE_AUDIO_LOCATION_MONO_AUDIO);
/* One BIS per channel; the handover module turns these into per-BIS LTVs. */
static const esp_ble_audio_location_t bis_locations[] = {
ESP_BLE_AUDIO_LOCATION_FRONT_LEFT,
ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT,
};
_Static_assert(ARRAY_SIZE(bis_locations) >= SINK_STREAM_COUNT,
"Need one channel allocation per broadcast stream");
uint8_t *broadcast_ext_adv_data_get(uint8_t *data_len)
{
uint32_t broadcast_id;
uint8_t *data;
broadcast_id = LOCAL_BROADCAST_ID;
/* - Broadcast Audio Announcement Service UUID (2 octets)
* - Broadcast ID (3 octets)
* - Complete Device Name
*/
*data_len = 7 + 2 + strlen(LOCAL_DEVICE_NAME);
data = calloc(1, *data_len);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to alloc ext adv data (%u octets)", *data_len);
return NULL;
}
data[0] = 0x06; /* 1 + 2 + 3 */
data[1] = EXAMPLE_AD_TYPE_SERVICE_DATA16;
data[2] = (ESP_BLE_AUDIO_UUID_BROADCAST_AUDIO_VAL & 0xFF);
data[3] = ((ESP_BLE_AUDIO_UUID_BROADCAST_AUDIO_VAL >> 8) & 0xFF);
data[4] = (broadcast_id & 0xFF);
data[5] = ((broadcast_id >> 8) & 0xFF);
data[6] = ((broadcast_id >> 16) & 0xFF);
data[7] = strlen(LOCAL_DEVICE_NAME) + 1;
data[8] = EXAMPLE_AD_TYPE_NAME_COMPLETE;
memcpy(data + 9, LOCAL_DEVICE_NAME, strlen(LOCAL_DEVICE_NAME));
return data;
}
uint8_t *broadcast_base_data_get(esp_ble_audio_cap_broadcast_source_t *source,
uint8_t *data_len)
{
NET_BUF_SIMPLE_DEFINE(base_buf, 128);
uint8_t *data;
esp_err_t err;
/* Broadcast Audio Announcement Service UUID (2 octets) and
* Broadcast Audio Source Endpoint (BASE)
*/
err = esp_ble_audio_cap_initiator_broadcast_get_base(source, &base_buf);
if (err) {
ESP_LOGE(TAG, "Failed to get encoded BASE, err %d", err);
return NULL;
}
*data_len = 2 + base_buf.len;
data = calloc(1, *data_len);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to alloc per adv data (%u octets)", *data_len);
return NULL;
}
/* base_buf.len has included the UUID length (2 octets) */
data[0] = 1 + base_buf.len;
data[1] = EXAMPLE_AD_TYPE_SERVICE_DATA16;
memcpy(data + 2, base_buf.data, base_buf.len);
return data;
}
esp_ble_audio_bap_lc3_preset_t *broadcast_preset_get(void)
{
return &broadcast_preset;
}
esp_ble_audio_location_t broadcast_bis_location_get(size_t idx)
{
return (idx < ARRAY_SIZE(bis_locations)) ? bis_locations[idx]
: ESP_BLE_AUDIO_LOCATION_MONO_AUDIO;
}

View File

@@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stddef.h>
#include "esp_ble_iso_common_api.h"
#include "cap_handover.h"
/* flush_timeout holds FT x ISO_Interval, so divide FT back out. sdu_interval
* only comes with HCI_LE_CIS_Established_V2; a v1 event leaves it UNKNOWN.
*/
static void diag_log_cis_dir(const char *phase, size_t idx, const char *dir,
uint16_t iso_interval,
const struct bt_iso_unicast_tx_info *info)
{
ESP_LOGI(TAG, "[%s][%zu] %s {ft %u bn %u latency %lu us sdu_interval %s}",
phase, idx, dir,
iso_interval != 0 ? (unsigned)(info->flush_timeout / iso_interval) : 0U,
info->bn, (unsigned long)info->latency,
info->sdu_interval == BT_ISO_SDU_INTERVAL_UNKNOWN ? "unknown (v1 event)"
: "see qos");
}
static void diag_sample_stream(const char *phase, size_t idx,
esp_ble_audio_cap_stream_t *stream, bool has_sent)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_ble_iso_tx_info_t tx_info = {0};
esp_ble_iso_info_t chan_info = {0};
esp_ble_audio_bap_ep_t *ep = stream->bap_stream.ep;
esp_err_t err;
if (ep == NULL) {
ESP_LOGI(TAG, "[%s][%zu] no endpoint", phase, idx);
return;
}
if (esp_ble_audio_bap_ep_get_info(ep, &ep_info) != ESP_OK) {
ESP_LOGW(TAG, "[%s][%zu] endpoint info unavailable", phase, idx);
return;
}
/* HCI answers Command Disallowed until the stream has sent an SDU, and the
* adapter logs that as an HCI error - so do not ask a transport that just
* came up. Its timestamp shows up in the next pre-handover sample.
*/
if (!has_sent) {
ESP_LOGI(TAG, "[%s][%zu] state %d (timestamp not read: transport just started)",
phase, idx, ep_info.state);
} else if ((err = esp_ble_audio_cap_stream_get_tx_sync(stream, &tx_info)) != ESP_OK) {
ESP_LOGW(TAG, "[%s][%zu] state %d tx_sync failed, err %d",
phase, idx, ep_info.state, err);
} else {
ESP_LOGI(TAG, "[%s][%zu] state %d ts %lu us offset %lu us seq %u",
phase, idx, ep_info.state,
(unsigned long)tx_info.ts, (unsigned long)tx_info.offset,
tx_info.seq_num);
}
if (ep_info.iso_chan == NULL ||
esp_ble_iso_chan_get_info(ep_info.iso_chan, &chan_info) != ESP_OK) {
ESP_LOGI(TAG, "[%s][%zu] no ISO channel info", phase, idx);
return;
}
/* iso_interval is in 1.25 ms units; print us like every other duration. The
* channel kind shows in the field names, so it is not printed separately.
*/
if (chan_info.type == BT_ISO_CHAN_TYPE_BROADCASTER) {
ESP_LOGI(TAG, "[%s][%zu] iso_interval %lu us big_sync_delay %lu us",
phase, idx,
(unsigned long)chan_info.iso_interval * 1250UL,
(unsigned long)chan_info.broadcaster.sync_delay);
ESP_LOGI(TAG, "[%s][%zu] bis {pto %lu bn %u latency %lu us}",
phase, idx,
(unsigned long)chan_info.broadcaster.pto,
chan_info.broadcaster.bn,
(unsigned long)chan_info.broadcaster.latency);
/* No qos_pref: it comes from a peer ASE's Codec Configured, and a
* broadcast source has no peer. Printing four zeros would mislead.
*/
return;
}
ESP_LOGI(TAG, "[%s][%zu] iso_interval %lu us cig_sync_delay %lu us cis_sync_delay %lu us",
phase, idx,
(unsigned long)chan_info.iso_interval * 1250UL,
(unsigned long)chan_info.unicast.cig_sync_delay,
(unsigned long)chan_info.unicast.cis_sync_delay);
diag_log_cis_dir(phase, idx, "c2p", chan_info.iso_interval, &chan_info.unicast.central);
diag_log_cis_dir(phase, idx, "p2c", chan_info.iso_interval, &chan_info.unicast.peripheral);
if (ep_info.qos_pref != NULL) {
ESP_LOGI(TAG, "[%s][%zu] pd_pref [%lu, %lu] us pd_range [%lu, %lu] us",
phase, idx,
(unsigned long)ep_info.qos_pref->pref_pd_min,
(unsigned long)ep_info.qos_pref->pref_pd_max,
(unsigned long)ep_info.qos_pref->pd_min,
(unsigned long)ep_info.qos_pref->pd_max);
}
}
void cap_handover_diag_sample(const char *phase, bool has_sent)
{
for (size_t i = 0; i < peer.sink_ep_count && i < ARRAY_SIZE(peer.sink_streams); i++) {
diag_sample_stream(phase, i, &peer.sink_streams[i], has_sent);
}
}

View File

@@ -0,0 +1,725 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "esp_timer.h"
#include "cap_handover.h"
/* 1.25 ms units. The real interval, not "unknown": the Acceptor's PA sync needs it. */
#define HANDOVER_PA_INTERVAL ((PER_ADV_INTERVAL_MS * 1000) / 1250)
/* Must stay valid until the procedure completes, so not on the stack. */
static esp_ble_audio_cap_initiator_broadcast_stream_param_t bcast_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_initiator_broadcast_subgroup_param_t bcast_subgroup_param;
static esp_ble_audio_cap_initiator_broadcast_create_param_t bcast_create_param;
static uint8_t bcast_bis_data[SINK_STREAM_COUNT][6];
static esp_ble_audio_cap_unicast_group_stream_param_t uni_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_stream_pair_param_t uni_pair_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_param_t uni_group_param;
static esp_ble_audio_cap_unicast_audio_start_stream_param_t uni_start_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_audio_start_param_t uni_start_param;
static esp_ble_audio_cap_commander_broadcast_reception_stop_member_param_t stop_member_param;
static esp_ble_audio_cap_commander_broadcast_reception_stop_param_t stop_param;
static esp_ble_audio_cap_broadcast_source_t *broadcast_source;
static esp_ble_audio_bap_broadcast_adv_info_t adv_info;
static esp_timer_handle_t switch_timer;
static esp_timer_handle_t start_timer;
static bool adv_registered;
static bool proc_active;
static bool on_broadcast;
/* Source ID of our broadcast on the Acceptor, needed to stop its reception. */
static uint8_t recv_state_src_id;
static bool recv_state_valid;
/* Sweep of the Acceptor's receive states, run once per connection. */
static uint8_t recv_state_cnt;
static uint8_t recv_state_next;
static bool recv_state_sweeping;
static void switch_timer_arm(void)
{
esp_err_t err;
if (CONFIG_EXAMPLE_HANDOVER_PERIOD_S == 0 || switch_timer == NULL) {
return;
}
(void)esp_timer_stop(switch_timer);
err = esp_timer_start_once(switch_timer,
(uint64_t)CONFIG_EXAMPLE_HANDOVER_PERIOD_S * 1000000);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to arm handover timer, err %d", err);
}
}
static void start_timer_cb(void *arg)
{
if (peer.conn == NULL) {
return;
}
(void)cap_handover_unicast_setup_and_start();
}
/* CAP holds one procedure at a time and the library shares our callback lists,
* so starting a procedure from inside a callback changes the active procedure
* under a library handler that has not run yet. Leave the dispatch first.
*/
static void unicast_start_defer(void)
{
esp_err_t err;
(void)esp_timer_stop(start_timer);
err = esp_timer_start_once(start_timer, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to schedule unicast start, err %d", err);
}
}
/* What the Acceptor's decoder must keep doing across the switch. Channel
* allocation is absent on purpose: it differs between the two paths by design.
*/
struct codec_shape {
esp_ble_audio_codec_cfg_freq_t freq;
esp_ble_audio_codec_cfg_frame_dur_t frame_dur;
uint16_t octets_per_frame;
uint8_t frame_blocks;
};
static int codec_shape_get(const esp_ble_audio_codec_cfg_t *codec_cfg,
struct codec_shape *out)
{
if (esp_ble_audio_codec_cfg_get_freq(codec_cfg, &out->freq) != ESP_OK ||
esp_ble_audio_codec_cfg_get_frame_dur(codec_cfg, &out->frame_dur) != ESP_OK ||
esp_ble_audio_codec_cfg_get_octets_per_frame(codec_cfg,
&out->octets_per_frame) != ESP_OK ||
esp_ble_audio_codec_cfg_get_frame_blocks_per_sdu(codec_cfg, &out->frame_blocks,
true) != ESP_OK) {
return -EINVAL;
}
return 0;
}
/* Never memcmp: the trailing padding is uninitialised stack. */
static bool codec_shape_eq(const struct codec_shape *a, const struct codec_shape *b)
{
return a->freq == b->freq &&
a->frame_dur == b->frame_dur &&
a->octets_per_frame == b->octets_per_frame &&
a->frame_blocks == b->frame_blocks;
}
/* A mismatch makes the Acceptor reconfigure its decoder at the switching point,
* which is audible. One macro feeds both paths, so this only fires if someone
* later points one of them elsewhere - better loudly than silently.
*/
static int handover_audio_config_check(void)
{
const esp_ble_audio_bap_lc3_preset_t *bcast = broadcast_preset_get();
struct codec_shape bcast_shape;
if (bcast == NULL || codec_shape_get(&bcast->codec_cfg, &bcast_shape) != 0) {
ESP_LOGE(TAG, "Cannot read broadcast codec configuration");
return -EINVAL;
}
for (size_t i = 0; i < SINK_STREAM_COUNT; i++) {
const esp_ble_audio_bap_lc3_preset_t *uni = sink_preset_get(i);
struct codec_shape uni_shape;
if (uni == NULL || codec_shape_get(&uni->codec_cfg, &uni_shape) != 0) {
ESP_LOGE(TAG, "Cannot read sink %u codec configuration", (unsigned)i);
return -EINVAL;
}
if (!codec_shape_eq(&uni_shape, &bcast_shape)) {
ESP_LOGE(TAG, "Codec config differs on sink %u: "
"unicast freq %d dur %d octets %u blocks %u vs "
"broadcast freq %d dur %d octets %u blocks %u",
(unsigned)i,
uni_shape.freq, uni_shape.frame_dur,
uni_shape.octets_per_frame, uni_shape.frame_blocks,
bcast_shape.freq, bcast_shape.frame_dur,
bcast_shape.octets_per_frame, bcast_shape.frame_blocks);
return -EINVAL;
}
/* Same SDU rate and framing, or the paths cannot carry the same frame
* sequence. ISO_Interval is the controller's and only observable once
* the streams run - cap_handover_diag_sample() prints it.
*/
if (uni->qos.interval != bcast->qos.interval ||
uni->qos.framing != bcast->qos.framing) {
ESP_LOGE(TAG, "QoS differs on sink %u: unicast interval %lu framing %d vs "
"broadcast interval %lu framing %d", (unsigned)i,
(unsigned long)uni->qos.interval, uni->qos.framing,
(unsigned long)bcast->qos.interval, bcast->qos.framing);
return -EINVAL;
}
/* PD may legitimately differ: it is how a rendering-point offset would
* be absorbed. Nothing uses that yet, so warn rather than fail.
*/
if (uni->qos.pd != bcast->qos.pd) {
ESP_LOGW(TAG, "Presentation delay differs on sink %u: %lu vs %lu us",
(unsigned)i, (unsigned long)uni->qos.pd, (unsigned long)bcast->qos.pd);
}
}
return 0;
}
static int handover_unicast_to_broadcast(void)
{
esp_ble_audio_cap_handover_unicast_to_broadcast_param_t param = {0};
uint8_t *ext_data = NULL;
uint8_t ext_len = 0;
size_t count = 0;
int err;
err = handover_audio_config_check();
if (err) {
return err;
}
/* All of them: CAP 7.3.1.10 applies to every CIS carrying Initiator-to-Acceptor audio. */
for (size_t i = 0; i < peer.sink_ep_count && count < ARRAY_SIZE(bcast_stream_params); i++) {
esp_ble_audio_cap_stream_t *stream = &peer.sink_streams[i];
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_ble_audio_location_t loc;
if (stream->bap_stream.ep == NULL ||
esp_ble_audio_bap_ep_get_info(stream->bap_stream.ep, &ep_info) != ESP_OK ||
ep_info.state != ESP_BLE_AUDIO_BAP_EP_STATE_STREAMING) {
continue;
}
/* Shared subgroup codec cfg, so the channel allocation goes per BIS. */
loc = broadcast_bis_location_get(count);
bcast_bis_data[count][0] = 5;
bcast_bis_data[count][1] = ESP_BLE_AUDIO_CODEC_CFG_CHAN_ALLOC;
bcast_bis_data[count][2] = (uint8_t)loc;
bcast_bis_data[count][3] = (uint8_t)(loc >> 8);
bcast_bis_data[count][4] = (uint8_t)(loc >> 16);
bcast_bis_data[count][5] = (uint8_t)(loc >> 24);
bcast_stream_params[count].stream = stream;
bcast_stream_params[count].data = bcast_bis_data[count];
bcast_stream_params[count].data_len = sizeof(bcast_bis_data[count]);
count++;
}
if (count == 0) {
ESP_LOGW(TAG, "No streaming sink stream to hand over");
return -ENODEV;
}
bcast_subgroup_param.stream_count = count;
bcast_subgroup_param.stream_params = bcast_stream_params;
bcast_subgroup_param.codec_cfg = &broadcast_preset_get()->codec_cfg;
bcast_create_param.subgroup_count = 1;
bcast_create_param.subgroup_params = &bcast_subgroup_param;
bcast_create_param.qos = &broadcast_preset_get()->qos;
/* PA stays stopped: the BASE only exists once the procedure created the source. */
ext_data = broadcast_ext_adv_data_get(&ext_len);
if (ext_data == NULL) {
ESP_LOGE(TAG, "No adv data, cannot hand over to broadcast");
return -ENOMEM;
}
err = ext_adv_start_without_base(ext_data, ext_len);
free(ext_data);
if (err) {
ESP_LOGE(TAG, "Failed to start advertising for handover, err %d", err);
return err;
}
if (adv_registered == false) {
adv_info.adv_handle = ADV_HANDLE;
adv_info.addr_type = 0; /* public */
adv_info.sid = ADV_SID;
if (local_public_addr_get(adv_info.addr) != 0) {
ESP_LOGE(TAG, "No local address for the BASS Add Source");
return -EIO;
}
err = esp_ble_audio_bap_broadcast_adv_add(&adv_info);
if (err) {
ESP_LOGE(TAG, "Failed to add adv for broadcast source, err %d", err);
return err;
}
adv_registered = true;
}
param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
param.unicast_group = unicast_group_get();
param.adv_handle = ADV_HANDLE;
param.pa_interval = HANDOVER_PA_INTERVAL;
param.broadcast_id = LOCAL_BROADCAST_ID;
param.broadcast_create_param = &bcast_create_param;
ESP_LOGI(TAG, "Handover: unicast -> broadcast (%u stream(s))", (unsigned)count);
cap_handover_diag_sample("u2b-pre", true);
err = esp_ble_audio_cap_handover_unicast_to_broadcast(&param);
if (err) {
ESP_LOGE(TAG, "Failed to hand over to broadcast, err %d", err);
return err;
}
return 0;
}
static int handover_broadcast_to_unicast(void)
{
esp_ble_audio_cap_handover_broadcast_to_unicast_param_t param = {0};
size_t count = 0;
int err;
if (recv_state_valid == false) {
ESP_LOGW(TAG, "Acceptor has no receive state for our broadcast");
return -ENODEV;
}
/* Must be the broadcast source's own streams. Sink only: a BIS has no return path. */
for (size_t i = 0; i < peer.sink_ep_count && count < ARRAY_SIZE(uni_stream_params); i++) {
esp_ble_audio_bap_lc3_preset_t *preset = sink_preset_get(i);
if (preset == NULL || peer.sink_eps[i] == NULL) {
continue;
}
uni_stream_params[count].qos_cfg = &preset->qos;
uni_stream_params[count].stream = &peer.sink_streams[i];
uni_pair_params[count].rx_param = NULL;
uni_pair_params[count].tx_param = &uni_stream_params[count];
uni_start_stream_params[count].member.member = peer.conn;
uni_start_stream_params[count].stream = &peer.sink_streams[i];
uni_start_stream_params[count].ep = peer.sink_eps[i];
uni_start_stream_params[count].codec_cfg = &preset->codec_cfg;
count++;
}
if (count == 0) {
ESP_LOGW(TAG, "No endpoint to hand back to unicast");
return -ENODEV;
}
uni_group_param.params_count = count;
uni_group_param.params = uni_pair_params;
uni_start_param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
uni_start_param.count = count;
uni_start_param.stream_params = uni_start_stream_params;
stop_member_param.member.member = peer.conn;
stop_member_param.src_id = recv_state_src_id;
stop_member_param.num_subgroups = bcast_create_param.subgroup_count;
stop_param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
stop_param.param = &stop_member_param;
stop_param.count = 1;
/* Documented as ignored once reception_stop_param is set, but the procedure
* still matches receive state notifications against this triple. Leaving it
* zero stalls the handover after the broadcast source stops.
*/
param.broadcast_id = LOCAL_BROADCAST_ID;
param.adv_sid = ADV_SID;
param.adv_type = 0; /* public */
param.reception_stop_param = &stop_param;
param.broadcast_source = broadcast_source;
param.unicast_group_param = &uni_group_param;
param.unicast_start_param = &uni_start_param;
ESP_LOGI(TAG, "Handover: broadcast -> unicast (%u stream(s))", (unsigned)count);
cap_handover_diag_sample("b2u-pre", true);
err = esp_ble_audio_cap_handover_broadcast_to_unicast(&param);
if (err) {
ESP_LOGE(TAG, "Failed to hand back to unicast, err %d", err);
return err;
}
/* Stop feeding the broadcast now, not from the stream callbacks: the ISO
* channel goes down with the BIG terminate, but the disconnected callback
* trails the controller's event by ~100 ms and every SDU in between is
* rejected. Unicast re-registers from its own started callback. The other
* direction does not need this - ASCS delivers CIS callbacks promptly.
*/
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
(void)cap_handover_tx_unregister_stream(&peer.sink_streams[i]);
}
return 0;
}
static void switch_timer_cb(void *arg)
{
int err;
if (proc_active) {
/* CAP runs one procedure at a time; retry on the next tick. */
switch_timer_arm();
return;
}
if (peer.conn == NULL) {
return;
}
proc_active = true;
err = on_broadcast ? handover_broadcast_to_unicast()
: handover_unicast_to_broadcast();
if (err) {
proc_active = false;
switch_timer_arm();
}
}
static void unicast_to_broadcast_created_cb(esp_ble_audio_cap_broadcast_source_t *source)
{
uint8_t *per_data;
uint8_t per_len = 0;
int err;
/* The source exists now, so publish its BASE before the Acceptor syncs. */
per_data = broadcast_base_data_get(source, &per_len);
if (per_data == NULL) {
ESP_LOGE(TAG, "Failed to build BASE for handover");
return;
}
err = per_adv_data_start(per_data, per_len);
free(per_data);
if (err) {
ESP_LOGE(TAG, "Failed to publish BASE, err %d", err);
}
}
static void unicast_to_broadcast_complete_cb(int err, esp_ble_conn_t *conn,
esp_ble_audio_cap_unicast_group_t *group,
esp_ble_audio_cap_broadcast_source_t *source)
{
/* On success the procedure deleted the unicast group. */
unicast_group_set(group);
broadcast_source = source;
proc_active = false;
if (err) {
ESP_LOGE(TAG, "Handover to broadcast failed, err %d", err);
/* The group is already gone, so a failure leaves us broadcasting to
* nobody. The stop callback deletes the source and restores unicast.
*/
if (source != NULL &&
esp_ble_audio_cap_initiator_broadcast_audio_stop(source) != 0) {
ESP_LOGE(TAG, "Failed to stop broadcast source");
}
} else {
on_broadcast = true;
ESP_LOGI(TAG, "Handover to broadcast completed");
/* BIG terms are readable now; its timestamp is not, until the first
* SDU. The next b2u-pre sample catches that side with real data.
*/
cap_handover_diag_sample("u2b-post", false);
}
switch_timer_arm();
}
static void broadcast_to_unicast_complete_cb(int err, esp_ble_conn_t *conn,
esp_ble_audio_cap_broadcast_source_t *source,
esp_ble_audio_cap_unicast_group_t *group)
{
/* On success the procedure deleted the broadcast source. */
unicast_group_set(group);
broadcast_source = source;
proc_active = false;
if (err) {
ESP_LOGE(TAG, "Handover to unicast failed, err %d", err);
} else {
on_broadcast = false;
/* Nothing left to announce: drop the announcement and the periodic
* advertising carrying its BASE before declaring the handover done.
*/
if (adv_stop() != 0) {
ESP_LOGW(TAG, "Failed to stop advertising after handover");
}
ESP_LOGI(TAG, "Handover to unicast completed");
/* Mirror of the above: CIS terms readable, timestamp not yet. */
cap_handover_diag_sample("b2u-post", false);
}
switch_timer_arm();
}
static esp_ble_audio_cap_handover_cb_t handover_cb = {
.unicast_to_broadcast_created = unicast_to_broadcast_created_cb,
.unicast_to_broadcast_complete = unicast_to_broadcast_complete_cb,
.broadcast_to_unicast_complete = broadcast_to_unicast_complete_cb,
};
/* Discovery only subscribes, so read the receive states once to see what the
* Acceptor already holds. They outlive our reboot, and BAP 6.5.4 rejects an Add
* Source duplicating the {address, SID, Broadcast_ID} triple - an entry left by
* an earlier run of ours would fail every handover.
*/
static void recv_state_sweep_next(void)
{
while (recv_state_next < recv_state_cnt) {
uint8_t idx = recv_state_next++;
int err;
err = esp_ble_audio_bap_broadcast_assistant_read_recv_state(peer.conn_handle, idx);
if (err == 0) {
return; /* Continues in assistant_recv_state_cb */
}
ESP_LOGW(TAG, "Failed to read receive state %u, err %d", idx, err);
}
recv_state_sweeping = false;
unicast_start_defer();
}
static void assistant_discover_cb(esp_ble_conn_t *conn, int err, uint8_t recv_state_count)
{
if (err) {
ESP_LOGE(TAG, "BASS discovery failed, err %d", err);
return;
}
ESP_LOGI(TAG, "BASS discovered (%u receive state(s))", recv_state_count);
recv_state_cnt = recv_state_count;
recv_state_next = 0;
recv_state_sweeping = true;
recv_state_sweep_next();
}
static void assistant_recv_state_cb(esp_ble_conn_t *conn, int err,
const esp_ble_audio_bap_scan_delegator_recv_state_t *state)
{
bool ours = (err == 0 && state != NULL && state->broadcast_id == LOCAL_BROADCAST_ID);
uint32_t bis_sync = 0;
if (recv_state_sweeping) {
if (ours) {
ESP_LOGW(TAG, "Removing leftover receive state (src_id %u)", state->src_id);
if (esp_ble_audio_bap_broadcast_assistant_rem_src(peer.conn_handle,
state->src_id) == 0) {
return; /* Continues in assistant_rem_src_cb */
}
ESP_LOGE(TAG, "Failed to remove leftover receive state");
}
recv_state_sweep_next();
return;
}
if (!ours) {
return;
}
recv_state_src_id = state->src_id;
recv_state_valid = true;
/* The Acceptor notifies per field change, so the same pa_sync arrives more
* than once (PA synced, then BIG synced). bis_sync tells them apart, and is
* also what says whether audio is flowing.
*/
for (uint8_t i = 0; i < state->num_subgroups; i++) {
bis_sync |= state->subgroups[i].bis_sync;
}
ESP_LOGI(TAG, "Acceptor receive state: src_id %u pa_sync %u bis_sync 0x%08lx",
state->src_id, state->pa_sync_state, (unsigned long)bis_sync);
/* The Acceptor asks for the periodic advertising train it was told about.
* We are the advertiser, so hand it over instead of making it scan.
*/
if (state->pa_sync_state == ESP_BLE_AUDIO_BAP_PA_STATE_INFO_REQ) {
/* The Source ID goes in the high octet of the service data; the Acceptor
* reads it from there to match the transfer to a receive state. */
err = pa_set_info_transfer(peer.conn_handle, peer.dst,
(uint16_t)state->src_id << 8);
if (err) {
ESP_LOGE(TAG, "Failed to transfer PA sync info, err %d", err);
} else {
ESP_LOGI(TAG, "PA sync info transferred (src_id %u)", state->src_id);
}
}
}
static void assistant_recv_state_removed_cb(esp_ble_conn_t *conn, uint8_t src_id)
{
if (recv_state_valid && src_id == recv_state_src_id) {
recv_state_valid = false;
}
}
static void assistant_rem_src_cb(esp_ble_conn_t *conn, int err)
{
if (err) {
ESP_LOGE(TAG, "Remove source failed, err %d", err);
}
if (recv_state_sweeping) {
recv_state_sweep_next();
}
}
static esp_ble_audio_bap_broadcast_assistant_cb_t assistant_cb = {
.discover = assistant_discover_cb,
.recv_state = assistant_recv_state_cb,
.recv_state_removed = assistant_recv_state_removed_cb,
.rem_src = assistant_rem_src_cb,
};
int cap_handover_proc_discover(uint16_t conn_handle)
{
int err;
err = esp_ble_audio_bap_broadcast_assistant_discover(conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to discover BASS, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering BASS");
return 0;
}
void cap_handover_proc_unicast_started(void)
{
if (on_broadcast) {
return;
}
switch_timer_arm();
}
void cap_handover_proc_broadcast_stopped(esp_ble_audio_cap_broadcast_source_t *source)
{
if (source != broadcast_source) {
return;
}
/* A source is only deletable once its BIG is down, so finish it here. */
if (esp_ble_audio_cap_initiator_broadcast_audio_delete(source) != 0) {
ESP_LOGE(TAG, "Failed to delete broadcast source");
return;
}
broadcast_source = NULL;
on_broadcast = false;
(void)adv_stop();
/* A failed handover left the ASEs released; restore unicast while connected. */
if (peer.conn != NULL) {
unicast_start_defer();
}
}
void cap_handover_proc_reset(void)
{
if (switch_timer != NULL) {
(void)esp_timer_stop(switch_timer);
}
if (start_timer != NULL) {
(void)esp_timer_stop(start_timer);
}
if (broadcast_source != NULL) {
/* Deleted by cap_handover_proc_broadcast_stopped once the BIG is down. */
if (esp_ble_audio_cap_initiator_broadcast_audio_stop(broadcast_source) != 0) {
ESP_LOGE(TAG, "Failed to stop broadcast source");
}
}
proc_active = false;
on_broadcast = false;
recv_state_valid = false;
recv_state_sweeping = false;
}
int cap_handover_proc_init(void)
{
const esp_timer_create_args_t timer_args = {
.callback = switch_timer_cb,
.name = "cap_handover",
};
const esp_timer_create_args_t start_timer_args = {
.callback = start_timer_cb,
.name = "cap_uni_start",
};
int err;
err = esp_ble_audio_cap_handover_register_cb(&handover_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register handover callbacks, err %d", err);
return err;
}
err = esp_ble_audio_bap_broadcast_assistant_register_cb(&assistant_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register broadcast assistant callbacks, err %d", err);
return err;
}
err = esp_timer_create(&timer_args, &switch_timer);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to create handover timer, err %d", err);
return err;
}
err = esp_timer_create(&start_timer_args, &start_timer);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to create unicast start timer, err %d", err);
return err;
}
ESP_LOGI(TAG, "CAP initiator handover initialized (period %u s)",
(unsigned)CONFIG_EXAMPLE_HANDOVER_PERIOD_S);
return 0;
}

View File

@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "cap_handover.h"
/* The same stream objects move between unicast and broadcast, so one slot each. */
static struct tx_stream tx_streams[SINK_STREAM_COUNT];
static const char *cap_stream_tx_label(const esp_ble_audio_cap_stream_t *cap_stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
if (cap_stream == NULL || cap_stream->bap_stream.ep == NULL) {
return "SRC";
}
if (esp_ble_audio_bap_ep_get_info(cap_stream->bap_stream.ep, &ep_info) != 0) {
return "SRC";
}
return (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) ? "SNK" : "SRC";
}
static bool tx_stream_is_streaming(const struct tx_stream *tx_stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
int err;
if (tx_stream == NULL || tx_stream->stream == NULL) {
return false;
}
/* Broadcast streams have no ep; registration from started_cb implies streaming. */
if (tx_stream->is_broadcast) {
return true;
}
if (tx_stream->stream->bap_stream.ep == NULL) {
return false;
}
err = esp_ble_audio_bap_ep_get_info(tx_stream->stream->bap_stream.ep, &ep_info);
if (err) {
return false;
}
return (ep_info.state == ESP_BLE_AUDIO_BAP_EP_STATE_STREAMING);
}
static void cap_handover_tx_send(struct tx_stream *tx_stream)
{
const char *label;
int err;
if (tx_stream == NULL || tx_stream->stream == NULL) {
return;
}
if (tx_stream_is_streaming(tx_stream) == false) {
return;
}
label = cap_stream_tx_label(tx_stream->stream);
if (tx_stream->stream->bap_stream.qos == NULL ||
tx_stream->stream->bap_stream.qos->sdu == 0) {
ESP_LOGE(TAG, "[%s] Invalid QoS", label);
return;
}
if (tx_stream->data == NULL) {
ESP_LOGE(TAG, "[%s] Buffer unavailable (SDU %u)",
label, tx_stream->stream->bap_stream.qos->sdu);
return;
}
memset(tx_stream->data, (uint8_t)tx_stream->seq_num, tx_stream->stream->bap_stream.qos->sdu);
err = esp_ble_audio_cap_stream_send(tx_stream->stream, tx_stream->data,
tx_stream->stream->bap_stream.qos->sdu,
tx_stream->seq_num);
if (err) {
ESP_LOGD(TAG, "[%s] send failed, err %d", label, err);
return;
}
tx_stream->seq_num++;
}
void cap_handover_tx_stream_sent(esp_ble_audio_bap_stream_t *stream, void *user_data)
{
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream && &tx_streams[i].stream->bap_stream == stream) {
char name[24];
snprintf(name, sizeof(name), "%s #%zu",
cap_stream_tx_label(tx_streams[i].stream), i);
example_audio_tx_scheduler_on_sent(&tx_streams[i].scheduler, user_data, TAG, name);
break;
}
}
}
static void tx_scheduler_cb(void *arg)
{
struct tx_stream *tx_stream = arg;
cap_handover_tx_send(tx_stream);
}
int cap_handover_tx_register_stream(esp_ble_audio_cap_stream_t *cap_stream, bool is_broadcast)
{
int err;
if (cap_stream == NULL) {
return -EINVAL;
}
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream == NULL) {
const char *label = cap_stream_tx_label(cap_stream);
if (cap_stream->bap_stream.qos == NULL || cap_stream->bap_stream.qos->sdu == 0) {
ESP_LOGE(TAG, "[%s #%zu] Invalid QoS", label, i);
return -EINVAL;
}
if (tx_streams[i].data == NULL) {
tx_streams[i].data = calloc(1, cap_stream->bap_stream.qos->sdu);
if (tx_streams[i].data == NULL) {
ESP_LOGE(TAG, "[%s #%zu] Failed to alloc buffer (SDU %u)",
label, i, cap_stream->bap_stream.qos->sdu);
return -ENOMEM;
}
}
tx_streams[i].stream = cap_stream;
tx_streams[i].is_broadcast = is_broadcast;
tx_streams[i].seq_num = 0;
example_audio_tx_scheduler_reset(&tx_streams[i].scheduler);
err = example_audio_tx_scheduler_start(&tx_streams[i].scheduler, cap_stream->bap_stream.qos->interval);
if (err) {
ESP_LOGE(TAG, "[%s #%zu] Scheduler start failed, err %d",
label, i, err);
tx_streams[i].stream = NULL;
tx_streams[i].is_broadcast = false;
return err;
}
ESP_LOGI(TAG, "[%s #%zu] Started (SDU %u, interval %u us)",
label, i,
cap_stream->bap_stream.qos->sdu,
cap_stream->bap_stream.qos->interval);
cap_handover_tx_send(&tx_streams[i]);
return 0;
}
}
ESP_LOGE(TAG, "No free TX stream slot");
return -ENOMEM;
}
int cap_handover_tx_unregister_stream(esp_ble_audio_cap_stream_t *cap_stream)
{
int err;
if (cap_stream == NULL) {
return -EINVAL;
}
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream == cap_stream) {
const char *label = cap_stream_tx_label(tx_streams[i].stream);
err = example_audio_tx_scheduler_stop(&tx_streams[i].scheduler);
if (err) {
ESP_LOGE(TAG, "[%s #%zu] Scheduler stop failed, err %d",
label, i, err);
return err;
}
tx_streams[i].stream = NULL;
tx_streams[i].is_broadcast = false;
if (tx_streams[i].data != NULL) {
free(tx_streams[i].data);
tx_streams[i].data = NULL;
}
ESP_LOGI(TAG, "[%s #%zu] Stopped", label, i);
return 0;
}
}
return -ENODATA;
}
void cap_handover_tx_init(void)
{
int err;
memset(tx_streams, 0, sizeof(tx_streams));
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
err = example_audio_tx_scheduler_init(&tx_streams[i].scheduler,
tx_scheduler_cb,
&tx_streams[i]);
if (err) {
ESP_LOGE(TAG, "Failed to initialize tx scheduler[%zu], err %d", i, err);
return;
}
}
}

View File

@@ -0,0 +1,693 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include <assert.h>
#include "cap_handover.h"
/* One preset per sink stream: each owns its codec cfg buffer, which is what
* lets the streams carry different channel allocations.
*/
HANDOVER_LC3_PRESET_DEFINE(unicast_preset_left, ESP_BLE_AUDIO_LOCATION_FRONT_LEFT);
HANDOVER_LC3_PRESET_DEFINE(unicast_preset_right, ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT);
static esp_ble_audio_bap_lc3_preset_t *const sink_presets[] = {
&unicast_preset_left,
&unicast_preset_right,
};
_Static_assert(ARRAY_SIZE(sink_presets) >= SINK_STREAM_COUNT,
"Need one preset (one channel allocation) per sink stream");
static esp_ble_audio_cap_unicast_group_t *unicast_group;
struct peer_config peer = {
.conn_handle = CONN_HANDLE_INIT,
};
esp_ble_audio_cap_unicast_group_t *unicast_group_get(void)
{
return unicast_group;
}
void unicast_group_set(esp_ble_audio_cap_unicast_group_t *group)
{
unicast_group = group;
}
esp_ble_audio_bap_lc3_preset_t *sink_preset_get(size_t idx)
{
return (idx < ARRAY_SIZE(sink_presets)) ? sink_presets[idx] : NULL;
}
static const char *dir_str(esp_ble_audio_dir_t dir)
{
return dir == ESP_BLE_AUDIO_DIR_SINK ? "SNK" : "SRC";
}
static const char *stream_dir_str(const esp_ble_audio_bap_stream_t *stream)
{
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return "SNK";
}
}
return "???";
}
static int stream_index(const esp_ble_audio_bap_stream_t *stream)
{
/* Index within its own direction's pool, so logs read "SNK #0" / "SNK #1". */
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return (int)i;
}
}
return -1;
}
/* Ask whether the endpoint transmits; do not derive it from dir. These streams
* migrate, and dir means opposite things on the two endpoint kinds: the peer's
* direction on a unicast ASE (SINK), our own on a broadcast source (SOURCE).
* Testing dir == SINK therefore goes silent the moment a stream moves to a BIS.
*/
static bool is_tx_stream(esp_ble_audio_bap_stream_t *stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_err_t err;
/* Detached already: only teardown sees this, and unregistering is a no-op. */
if (stream->ep == NULL) {
return true;
}
err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info);
if (err) {
ESP_LOGE(TAG, "Failed to get ep info, err %d", err);
return false;
}
return ep_info.can_send;
}
static void unicast_stream_configured_cb(esp_ble_audio_bap_stream_t *stream,
const esp_ble_audio_bap_qos_cfg_pref_t *pref)
{
ESP_LOGI(TAG, "[%s #%d] Stream configured, QoS preference:",
stream_dir_str(stream), stream_index(stream));
example_print_qos_pref(TAG, pref);
}
static void unicast_stream_qos_set_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] QoS set",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_enabled_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream enabled",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
{
esp_ble_audio_cap_stream_t *cap_stream;
int err;
ESP_LOGI(TAG, "[%s #%d] Stream started",
stream_dir_str(stream), stream_index(stream));
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
err = cap_handover_tx_register_stream(cap_stream, false);
if (err) {
ESP_LOGE(TAG, "[%s #%d] Failed to register TX, err %d",
stream_dir_str(stream), stream_index(stream), err);
}
}
}
static void unicast_stream_metadata_updated_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Metadata updated",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_disabled_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream disabled",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_stopped_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[%s #%d] Stream stopped, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
(void)cap_handover_tx_unregister_stream(cap_stream);
}
}
static void unicast_stream_disconnected_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[%s #%d] ISO disconnected, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
(void)cap_handover_tx_unregister_stream(cap_stream);
}
}
static void unicast_stream_released_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream released",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_sent_cb(esp_ble_audio_bap_stream_t *stream, void *user_data)
{
cap_handover_tx_stream_sent(stream, user_data);
}
static esp_ble_audio_bap_stream_ops_t unicast_stream_ops = {
.configured = unicast_stream_configured_cb,
.qos_set = unicast_stream_qos_set_cb,
.enabled = unicast_stream_enabled_cb,
.started = unicast_stream_started_cb,
.metadata_updated = unicast_stream_metadata_updated_cb,
.disabled = unicast_stream_disabled_cb,
.stopped = unicast_stream_stopped_cb,
.released = unicast_stream_released_cb,
.sent = unicast_stream_sent_cb,
.disconnected = unicast_stream_disconnected_cb,
};
static int discover_cas(void)
{
int err;
err = esp_ble_audio_cap_initiator_unicast_discover(peer.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to discover CAS, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering CAS");
return 0;
}
static int discover_sinks(void)
{
int err;
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
esp_ble_audio_cap_stream_ops_register(&peer.sink_streams[i], &unicast_stream_ops);
}
err = esp_ble_audio_bap_unicast_client_discover(peer.conn_handle, ESP_BLE_AUDIO_DIR_SINK);
if (err) {
ESP_LOGE(TAG, "Failed to discover sinks, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering sinks");
return 0;
}
static int unicast_group_create(void)
{
/* Referenced by the group while it exists, so they outlive this call. */
static esp_ble_audio_cap_unicast_group_stream_param_t sink_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params[SINK_STREAM_COUNT];
esp_ble_audio_cap_unicast_group_param_t group_param = {0};
size_t pair_count = 0;
int err;
/* One CIS per sink stream. */
for (size_t i = 0; i < peer.sink_ep_count; i++) {
sink_stream_params[i].qos_cfg = &sink_presets[i]->qos;
sink_stream_params[i].stream = &peer.sink_streams[i];
pair_params[pair_count].rx_param = NULL;
pair_params[pair_count].tx_param = &sink_stream_params[i];
pair_count++;
}
if (pair_count == 0) {
ESP_LOGW(TAG, "No endpoints available, skip creating unicast group");
return -ENODEV;
}
group_param.params_count = pair_count;
group_param.params = pair_params;
err = esp_ble_audio_cap_unicast_group_create(&group_param, &unicast_group);
if (err) {
ESP_LOGE(TAG, "Failed to create unicast group, err %d", err);
return err;
}
ESP_LOGI(TAG, "Created unicast group");
return 0;
}
int unicast_group_delete(void)
{
int err;
if (unicast_group == NULL) {
return 0;
}
err = esp_ble_audio_cap_unicast_group_delete(unicast_group);
if (err) {
ESP_LOGE(TAG, "Failed to delete unicast group, err %d", err);
return err;
}
unicast_group = NULL;
ESP_LOGI(TAG, "Deleted unicast group");
return 0;
}
static int unicast_audio_start(void)
{
/* codec_cfg has to stay valid while the stream is non-idle. */
static esp_ble_audio_cap_unicast_audio_start_stream_param_t stream_param[SINK_STREAM_COUNT];
esp_ble_audio_cap_unicast_audio_start_param_t param = {0};
int err;
for (size_t i = 0; i < peer.sink_ep_count; i++) {
stream_param[param.count].member.member = peer.conn;
stream_param[param.count].stream = &peer.sink_streams[i];
stream_param[param.count].ep = peer.sink_eps[i];
stream_param[param.count].codec_cfg = &sink_presets[i]->codec_cfg;
param.count++;
}
if (param.count == 0) {
ESP_LOGW(TAG, "No endpoints available, skip starting unicast audio");
return 0;
}
param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
param.stream_params = stream_param;
err = esp_ble_audio_cap_initiator_unicast_audio_start(&param);
if (err) {
ESP_LOGE(TAG, "Failed to start unicast audio, err %d", err);
return err;
}
ESP_LOGI(TAG, "Starting unicast streams");
return 0;
}
int cap_handover_unicast_setup_and_start(void)
{
int err;
err = unicast_group_create();
if (err) {
return err;
}
err = unicast_audio_start();
if (err) {
unicast_group_delete();
return err;
}
return 0;
}
static void discover_cb(esp_ble_conn_t *conn, int err, esp_ble_audio_dir_t dir)
{
if (conn->handle != peer.conn_handle) {
return;
}
peer.conn = conn;
/* Sink only: a broadcast Audio Stream has no return path, so the Acceptor's
* source ASEs take no part in a handover.
*/
if (dir != ESP_BLE_AUDIO_DIR_SINK) {
return;
}
if (err) {
ESP_LOGE(TAG, "Discovery sinks failed, err %d", err);
return;
}
ESP_LOGI(TAG, "Discover sinks complete");
/* The collocated Commander writes the broadcast source into the Acceptor's
* BASS, so discover that too. Its callback continues the unicast setup.
*/
(void)cap_handover_proc_discover(peer.conn_handle);
}
static void pac_record_cb(esp_ble_conn_t *conn,
esp_ble_audio_dir_t dir,
const esp_ble_audio_codec_cap_t *codec_cap)
{
example_print_codec_cap(TAG, codec_cap);
}
static void endpoint_cb(esp_ble_conn_t *conn,
esp_ble_audio_dir_t dir,
esp_ble_audio_bap_ep_t *ep)
{
/* Keep every sink endpoint: a stereo Acceptor exposes one per channel and
* they are handed over together.
*/
if (dir == ESP_BLE_AUDIO_DIR_SINK &&
peer.sink_ep_count < ARRAY_SIZE(peer.sink_eps)) {
ESP_LOGI(TAG, "[%s #%zu] Endpoint discovered", dir_str(dir), peer.sink_ep_count);
peer.sink_eps[peer.sink_ep_count++] = ep;
}
}
static esp_ble_audio_bap_unicast_client_cb_t unicast_client_cbs = {
.discover = discover_cb,
.pac_record = pac_record_cb,
.endpoint = endpoint_cb,
};
static void unicast_discovery_complete_cb(esp_ble_conn_t *conn, int err,
const esp_ble_audio_csip_set_coordinator_set_member_t *member,
const esp_ble_audio_csip_set_coordinator_csis_inst_t *csis_inst)
{
if (err) {
ESP_LOGE(TAG, "Unicast discovery completed, err %d", err);
return;
}
if (IS_ENABLED(CONFIG_BT_CAP_ACCEPTOR_SET_MEMBER)) {
if (csis_inst == NULL) {
ESP_LOGW(TAG, "Failed to discover CAS CSIS");
return;
}
ESP_LOGI(TAG, "Found CAS with CSIS");
/* TODO: Do set member discovery */
} else {
ESP_LOGI(TAG, "Found CAS");
}
(void)discover_sinks();
}
static void unicast_start_complete_cb(int err, esp_ble_conn_t *conn)
{
if (err) {
ESP_LOGE(TAG, "Unicast start completed, err %d", err);
return;
}
ESP_LOGI(TAG, "Unicast start completed");
cap_handover_proc_unicast_started();
}
static void broadcast_stopped_cb(esp_ble_audio_cap_broadcast_source_t *source, uint8_t reason)
{
ESP_LOGI(TAG, "Broadcast source stopped, reason 0x%02x", reason);
cap_handover_proc_broadcast_stopped(source);
}
static esp_ble_audio_cap_initiator_cb_t cap_cb = {
.unicast_discovery_complete = unicast_discovery_complete_cb,
.unicast_start_complete = unicast_start_complete_cb,
.broadcast_stopped = broadcast_stopped_cb,
};
static bool check_and_connect(uint8_t type, const uint8_t *data,
uint8_t data_len, void *user_data)
{
esp_ble_audio_gap_app_event_t *event;
uint16_t uuid_val;
int err;
event = user_data;
assert(event);
if (type != EXAMPLE_AD_TYPE_SERVICE_DATA16) {
return true; /* Continue parsing to next AD data type */
}
if (data_len < sizeof(uuid_val)) {
ESP_LOGW(TAG, "Invalid ad size %u (cas uuid)", data_len);
return true; /* Continue parsing to next AD data type */
}
uuid_val = sys_get_le16(data);
if (uuid_val != ESP_BLE_AUDIO_UUID_CAS_VAL) {
/* We are looking for the TMAS service data */
return true; /* Continue parsing to next AD data type */
}
ESP_LOGI(TAG, "Found CAS in peer adv data!");
/* Stop scanning before connect — NimBLE rejects ble_gap_connect while
* a discovery procedure is running. On failure restart scanning so we
* don't stall in no-scan no-conn state. */
err = ext_scan_stop();
if (err) {
ESP_LOGE(TAG, "Failed to stop scanning, err %d", err);
return false;
}
err = conn_create(event->ext_scan_recv.addr.type,
event->ext_scan_recv.addr.val);
if (err) {
ESP_LOGE(TAG, "Failed to create conn, err %d", err);
cap_handover_unicast_start();
}
return false; /* Stop parsing */
}
static void ext_scan_recv(esp_ble_audio_gap_app_event_t *event)
{
if (peer.conn_handle != CONN_HANDLE_INIT) {
return;
}
/* Check if the advertising is connectable and if TMAS is supported */
if (event->ext_scan_recv.event_type & EXAMPLE_ADV_PROP_CONNECTABLE) {
esp_ble_audio_data_parse(event->ext_scan_recv.data,
event->ext_scan_recv.data_len,
check_and_connect, (void *)event);
}
}
static void acl_connect(esp_ble_audio_gap_app_event_t *event)
{
int err;
if (event->acl_connect.status) {
ESP_LOGE(TAG, "Connection failed, status %d", event->acl_connect.status);
/* Scanning was stopped before conn_create and acl_disconnect only fires on
* an established connection, so resume here or nothing runs. */
cap_handover_unicast_start();
return;
}
ESP_LOGI(TAG, "Connected: handle %u role %u peer %02x:%02x:%02x:%02x:%02x:%02x",
event->acl_connect.conn_handle, event->acl_connect.role,
EXAMPLE_BT_ADDR_PRINT_ARGS(event->acl_connect.dst.val));
peer.conn_handle = event->acl_connect.conn_handle;
memcpy(peer.dst, event->acl_connect.dst.val, sizeof(peer.dst));
err = pairing_start(event->acl_connect.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to initiate security, err %d", err);
return;
}
}
static void acl_disconnect(esp_ble_audio_gap_app_event_t *event)
{
ESP_LOGI(TAG, "Disconnected: handle %u reason 0x%02x",
event->acl_disconnect.conn_handle, event->acl_disconnect.reason);
peer.conn_handle = CONN_HANDLE_INIT;
peer.conn = NULL;
memset(peer.dst, 0, sizeof(peer.dst));
memset(peer.sink_eps, 0, sizeof(peer.sink_eps));
peer.sink_ep_count = 0;
peer.disc_completed = false;
peer.mtu_exchanged = false;
/* Drop a broadcast source left running before reusing the stream objects. */
cap_handover_proc_reset();
unicast_group_delete();
cap_handover_unicast_start();
}
static void security_change(esp_ble_audio_gap_app_event_t *event)
{
int err;
if (event->security_change.status) {
security_failed_recover(event->security_change.conn_handle,
event->security_change.status);
return;
}
ESP_LOGI(TAG, "Security: handle %u level %u bonded %u",
event->security_change.conn_handle, event->security_change.sec_level,
event->security_change.bonded);
err = exchange_mtu(event->security_change.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to exchange MTU, err %d", err);
return;
}
}
void cap_handover_unicast_gap_cb(esp_ble_audio_gap_app_event_t *event)
{
switch (event->type) {
case ESP_BLE_AUDIO_GAP_EVENT_EXT_SCAN_RECV:
ext_scan_recv(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_ACL_CONNECT:
acl_connect(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_ACL_DISCONNECT:
acl_disconnect(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_SECURITY_CHANGE:
security_change(event);
break;
default:
break;
}
}
static void gatt_mtu_change(esp_ble_audio_gatt_app_event_t *event)
{
uint16_t conn_handle = event->gatt_mtu_change.conn_handle;
int err;
ESP_LOGI(TAG, "MTU updated: handle %u mtu %u",
conn_handle, event->gatt_mtu_change.mtu);
if (event->gatt_mtu_change.mtu < ESP_BLE_AUDIO_ATT_MTU_MIN) {
ESP_LOGW(TAG, "Invalid new mtu %u, shall be at least %u",
event->gatt_mtu_change.mtu, ESP_BLE_AUDIO_ATT_MTU_MIN);
return;
}
err = esp_ble_audio_gattc_disc_start(conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to start svc disc, err %d", err);
return;
}
ESP_LOGI(TAG, "Service discovery started: handle %u", conn_handle);
/* Note:
* MTU exchanged event may arrived after discover completed event.
*/
peer.mtu_exchanged = true;
if (peer.disc_completed) {
(void)discover_cas();
}
}
static void gattc_disc_cmpl(esp_ble_audio_gatt_app_event_t *event)
{
ESP_LOGI(TAG, "Service discovery complete: handle %u",
event->gattc_disc_cmpl.conn_handle);
if (event->gattc_disc_cmpl.status) {
ESP_LOGE(TAG, "gattc disc failed, status %u", event->gattc_disc_cmpl.status);
return;
}
/* Note:
* Discover completed event may arrived before MTU exchanged event.
*/
peer.disc_completed = true;
if (peer.mtu_exchanged) {
(void)discover_cas();
}
}
void cap_handover_unicast_gatt_cb(esp_ble_audio_gatt_app_event_t *event)
{
switch (event->type) {
case ESP_BLE_AUDIO_GATT_EVENT_GATT_MTU_CHANGE:
gatt_mtu_change(event);
break;
case ESP_BLE_AUDIO_GATT_EVENT_GATTC_DISC_CMPL:
gattc_disc_cmpl(event);
break;
default:
break;
}
}
int cap_handover_unicast_start(void)
{
return ext_scan_start();
}
int cap_handover_unicast_init(void)
{
int err;
err = esp_ble_audio_cap_initiator_register_cb(&cap_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register CAP callbacks, err %d", err);
return err;
}
err = esp_ble_audio_bap_unicast_client_register_cb(&unicast_client_cbs);
if (err) {
ESP_LOGE(TAG, "Failed to register BAP unicast client callbacks, err %d", err);
return err;
}
ESP_LOGI(TAG, "CAP initiator unicast initialized");
return 0;
}

View File

@@ -0,0 +1,5 @@
dependencies:
example_init:
path: ${IDF_PATH}/examples/bluetooth/esp_ble_audio/common_components/example_init
example_utils:
path: ${IDF_PATH}/examples/bluetooth/esp_ble_audio/common_components/example_utils

View File

@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "nvs_flash.h"
#include "cap_handover.h"
void app_main(void)
{
esp_ble_audio_init_info_t info = {
.gap_cb = cap_handover_unicast_gap_cb,
.gatt_cb = cap_handover_unicast_gatt_cb,
};
esp_err_t err;
/* Initialize NVS — it is used to store PHY calibration data */
err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
err = bluetooth_init();
if (err) {
ESP_LOGE(TAG, "Failed to initialize BLE, err %d", err);
return;
}
err = app_host_init();
if (err) {
ESP_LOGE(TAG, "Failed to init host, err %d", err);
return;
}
err = esp_ble_audio_common_init(&info);
if (err) {
ESP_LOGE(TAG, "Failed to initialize audio, err %d", err);
return;
}
err = cap_handover_unicast_init();
if (err) {
return;
}
err = cap_handover_proc_init();
if (err) {
return;
}
cap_handover_tx_init();
err = esp_ble_audio_common_start(NULL);
if (err) {
ESP_LOGE(TAG, "Failed to start audio, err %d", err);
return;
}
err = set_device_name();
if (err) {
ESP_LOGE(TAG, "Failed to set device name, err %d", err);
return;
}
/* Scanning only: the broadcast source is created by the handover procedure. */
err = cap_handover_unicast_start();
if (err) {
return;
}
}

View File

@@ -0,0 +1,170 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "esp_log.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "os/os_mbuf.h"
#include "cap_handover.h"
static int gap_event_cb(struct ble_gap_event *event, void *arg)
{
return 0;
}
static int adv_data_set(const uint8_t *payload, uint8_t payload_len, bool periodic)
{
struct os_mbuf *data;
int err;
data = os_msys_get_pkthdr(payload_len, 0);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to get %s adv mbuf", periodic ? "per" : "ext");
return -1;
}
err = os_mbuf_append(data, payload, payload_len);
if (err) {
ESP_LOGE(TAG, "Failed to append %s adv data, err %d", periodic ? "per" : "ext", err);
os_mbuf_free_chain(data);
return err;
}
err = periodic ? ble_gap_periodic_adv_set_data(ADV_HANDLE, data)
: ble_gap_ext_adv_set_data(ADV_HANDLE, data);
if (err) {
ESP_LOGE(TAG, "Failed to set %s adv data, err %d", periodic ? "per" : "ext", err);
return err;
}
return 0;
}
static int adv_set_configure(const uint8_t *ext_data, uint8_t ext_len)
{
struct ble_gap_periodic_adv_params per_params = {0};
struct ble_gap_ext_adv_params ext_params = {0};
int err;
ext_params.connectable = 0;
ext_params.scannable = 0;
ext_params.legacy_pdu = 0;
ext_params.own_addr_type = BLE_OWN_ADDR_PUBLIC;
ext_params.primary_phy = BLE_HCI_LE_PHY_1M;
ext_params.secondary_phy = BLE_HCI_LE_PHY_2M;
ext_params.tx_power = ADV_TX_POWER;
ext_params.sid = ADV_SID;
ext_params.itvl_min = BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS);
ext_params.itvl_max = BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS);
err = ble_gap_ext_adv_configure(ADV_HANDLE, &ext_params, NULL,
gap_event_cb, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to configure ext adv params, err %d", err);
return err;
}
err = adv_data_set(ext_data, ext_len, false);
if (err) {
return err;
}
per_params.include_tx_power = 0;
per_params.itvl_min = BLE_GAP_PERIODIC_ITVL_MS(PER_ADV_INTERVAL_MS);
per_params.itvl_max = BLE_GAP_PERIODIC_ITVL_MS(PER_ADV_INTERVAL_MS);
err = ble_gap_periodic_adv_configure(ADV_HANDLE, &per_params);
if (err) {
ESP_LOGE(TAG, "Failed to configure per adv params, err %d", err);
return err;
}
return 0;
}
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len)
{
int err;
err = adv_data_set(per_data, per_len, true);
if (err) {
return err;
}
err = ble_gap_periodic_adv_start(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to start per advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Periodic advertising started (handle %u)", ADV_HANDLE);
return 0;
}
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len)
{
int err;
err = adv_set_configure(ext_data, ext_len);
if (err) {
return err;
}
err = ble_gap_ext_adv_start(ADV_HANDLE, 0, 0);
if (err) {
ESP_LOGE(TAG, "Failed to start ext advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Advertising started, BASE pending (handle %u)", ADV_HANDLE);
return 0;
}
int adv_stop(void)
{
int err;
err = ble_gap_periodic_adv_stop(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to stop per advertising, err %d", err);
return err;
}
err = ble_gap_ext_adv_stop(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to stop ext advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Advertising stopped (handle %u)", ADV_HANDLE);
return 0;
}
int local_public_addr_get(uint8_t addr[6])
{
int err = ble_hs_id_copy_addr(BLE_ADDR_PUBLIC, addr, NULL);
if (err) {
ESP_LOGE(TAG, "Local BD address unavailable, err %d", err);
return err;
}
return 0;
}
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data)
{
(void)peer_addr;
return ble_gap_periodic_adv_sync_set_info(ADV_HANDLE, conn_handle, service_data);
}

View File

@@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "host/ble_store.h"
#include "services/gap/ble_svc_gap.h"
#include "esp_ble_audio_common_api.h"
#include "cap_handover.h"
/* Init/conn parameters are shared with the bluedroid wrapper via cap_handover.h.
* CONN_DURATION is NimBLE-specific (ble_gap_connect's discovery timeout). */
#define CONN_DURATION 10000 /* 10s */
static int gap_event_cb(struct ble_gap_event *event, void *arg)
{
switch (event->type) {
case BLE_GAP_EVENT_EXT_DISC:
case BLE_GAP_EVENT_CONNECT:
case BLE_GAP_EVENT_DISCONNECT:
case BLE_GAP_EVENT_ENC_CHANGE:
esp_ble_audio_gap_app_post_event(event->type, event);
break;
case BLE_GAP_EVENT_MTU:
case BLE_GAP_EVENT_NOTIFY_RX:
case BLE_GAP_EVENT_NOTIFY_TX:
case BLE_GAP_EVENT_SUBSCRIBE:
esp_ble_audio_gatt_app_post_event(event->type, event);
break;
case BLE_GAP_EVENT_REPEAT_PAIRING: {
struct ble_gap_conn_desc desc = {0};
int rc = ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc);
if (rc == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
return BLE_GAP_REPEAT_PAIRING_RETRY;
}
default:
break;
}
return 0;
}
int app_host_init(void)
{
return 0;
}
int set_device_name(void)
{
return ble_svc_gap_device_name_set(LOCAL_DEVICE_NAME);
}
#define OWN_ADDR_TYPE BLE_OWN_ADDR_PUBLIC
int ext_scan_start(void)
{
struct ble_gap_disc_params params = {0};
int err;
params.passive = 1;
params.itvl = SCAN_INTERVAL;
params.window = SCAN_WINDOW;
err = ble_gap_disc(OWN_ADDR_TYPE, BLE_HS_FOREVER, &params,
gap_event_cb, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to start scanning, err %d", err);
return err;
}
ESP_LOGI(TAG, "Scanning for CAP Acceptor...");
return 0;
}
int ext_scan_stop(void)
{
return ble_gap_disc_cancel();
}
int conn_create(uint8_t addr_type, const uint8_t addr[6])
{
struct ble_gap_conn_params params = {0};
ble_addr_t dst = {0};
params.scan_itvl = INIT_SCAN_INTERVAL;
params.scan_window = INIT_SCAN_WINDOW;
params.itvl_min = CONN_INTERVAL;
params.itvl_max = CONN_INTERVAL;
params.latency = CONN_LATENCY;
params.supervision_timeout = CONN_TIMEOUT;
params.max_ce_len = CONN_MAX_CE_LEN;
params.min_ce_len = CONN_MIN_CE_LEN;
dst.type = addr_type;
memcpy(dst.val, addr, sizeof(dst.val));
return ble_gap_connect(OWN_ADDR_TYPE, &dst, CONN_DURATION,
&params, gap_event_cb, NULL);
}
int pairing_start(uint16_t conn_handle)
{
return ble_gap_security_initiate(conn_handle);
}
int exchange_mtu(uint16_t conn_handle)
{
return ble_gattc_exchange_mtu(conn_handle, NULL, NULL);
}
void security_failed_recover(uint16_t conn_handle, uint8_t status)
{
struct ble_gap_conn_desc desc = {0};
int rc;
ESP_LOGE(TAG, "Security change failed, status %u, clearing local bond and reconnecting", status);
rc = ble_gap_conn_find(conn_handle, &desc);
if (rc == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM);
}

View File

@@ -0,0 +1,54 @@
# This file was generated using idf.py save-defconfig. It can be edited manually.
# Espressif IoT Development Framework (ESP-IDF) Project Minimal Configuration
#
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=n
CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_CLASSIC_ENABLED=n
CONFIG_BT_CONTROLLER_ENABLED=y
CONFIG_BT_BLE_ENABLED=y
CONFIG_BT_BLE_50_FEATURES_SUPPORTED=y
CONFIG_BT_ACL_CONNECTIONS=1
CONFIG_BT_GATTC_NOTIF_REG_MAX=20
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_BLE_FEAT_PERIODIC_ADV_SYNC_TRANSFER=y
CONFIG_BT_ISO_MAX_CHAN=4
CONFIG_BT_CAP_INITIATOR=y
CONFIG_BT_BAP_UNICAST_CLIENT=y
CONFIG_BT_BAP_UNICAST_CLIENT_GROUP_STREAM_COUNT=2
CONFIG_BT_BAP_BROADCAST_SOURCE=y
CONFIG_BT_BAP_BROADCAST_SRC_STREAM_COUNT=2
CONFIG_BT_CSIP_SET_COORDINATOR=y
CONFIG_BT_BAP_SCAN_DELEGATOR=y
CONFIG_BT_BAP_BROADCAST_ASSISTANT=y
CONFIG_BT_CAP_COMMANDER=y
CONFIG_BT_CAP_HANDOVER=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_FREERTOS_HZ=1000
# To place LE Audio .bss + control-plane heap in PSRAM, uncomment the
# lines below (needs a PSRAM-capable target, e.g. esp32s31/esp32h4).
# CONFIG_SPIRAM=y
# CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y
# CONFIG_BT_ISO_BSS_SEG_EXTERNAL_MEMORY=y
# CONFIG_BT_ISO_HEAP_EXTERNAL_MEMORY=y
# CONFIG_BT_AUDIO_BSS_SEG_EXTERNAL_MEMORY=y
# CONFIG_BT_AUDIO_HEAP_EXTERNAL_MEMORY=y
# Bandwidth-optimized (critical-only) compressed BLE Audio/ISO logging over
# UART DMA. Pins shown are for esp32s31 (UART0 TX = GPIO58, 3000000 baud); set
# CONFIG_BLE_LOG_PRPH_UART_DMA_* values to the port/pin your capture reads.
# CONFIG_BLE_LOG_ENABLED=y
# CONFIG_BT_LOG_CRITICAL_ONLY=y
# CONFIG_BLE_COMPRESSED_LOG_ENABLE=y
# CONFIG_BLE_ISO_COMPRESSED_LOG_ENABLE=y
# CONFIG_BLE_LOG_PRPH_UART_DMA=y
# CONFIG_BLE_LOG_PRPH_UART_DMA_PORT=0
# CONFIG_BLE_LOG_PRPH_UART_DMA_BAUD_RATE=3000000
# CONFIG_BLE_LOG_PRPH_UART_DMA_TX_IO_NUM=58

View File

@@ -0,0 +1,6 @@
# Override some defaults so BT stack is enabled
# by default in this example
CONFIG_IDF_TARGET="esp32h4"
CONFIG_BT_LE_ISO_SUPPORT=y

View File

@@ -0,0 +1,6 @@
# Override some defaults so BT stack is enabled
# by default in this example
CONFIG_IDF_TARGET="esp32s31"
CONFIG_BT_LE_ISO_SUPPORT=y

View File

@@ -0,0 +1,13 @@
# NimBLE host overlay for this example.
# Use with:
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.$IDF_TARGET;sdkconfig.defaults.nimble" build
CONFIG_BT_BLUEDROID_ENABLED=n
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_EXT_ADV=y
CONFIG_BT_NIMBLE_NVS_PERSIST=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_CCCDS=20
CONFIG_BT_NIMBLE_ISO=y
CONFIG_BT_NIMBLE_PERIODIC_ADV_SYNC_TRANSFER=y
CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y

View File

@@ -24,6 +24,17 @@
#define CONN_HANDLE_INIT 0xFFFF
/* Number of sink streams the sample drives towards the Acceptor (one per sink ASE,
* so a stereo Acceptor gets one stream per channel).
*
* The same stream objects are reused for unicast and, after a CAP handover, for
* broadcast, so the count is bounded by both the number of sink ASEs that can be
* discovered on the Acceptor and the number of broadcast source streams that can
* be created locally.
*/
#define SINK_STREAM_COUNT MIN(CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT, \
CONFIG_BT_BAP_BROADCAST_SRC_STREAM_COUNT)
#if CONFIG_EXAMPLE_UNICAST
#define LOCAL_DEVICE_NAME "CAP Initiator"

View File

@@ -18,20 +18,44 @@ ESP_BLE_AUDIO_BAP_LC3_BROADCAST_PRESET_16_2_1_DEFINE(broadcast_preset_16_2_1,
ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED);
static esp_ble_audio_cap_broadcast_source_t *broadcast_source;
static esp_ble_audio_cap_stream_t broadcast_stream;
static esp_ble_audio_cap_stream_t broadcast_streams[SINK_STREAM_COUNT];
/* One BIS per channel. The subgroup codec configuration is shared, so the channel
* allocation is carried per BIS as Codec Specific Configuration.
*/
static const esp_ble_audio_location_t bis_locations[] = {
ESP_BLE_AUDIO_LOCATION_FRONT_LEFT,
ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT,
};
_Static_assert(ARRAY_SIZE(bis_locations) >= SINK_STREAM_COUNT,
"Need one channel allocation per broadcast stream");
static uint8_t bis_data[SINK_STREAM_COUNT][6];
static int broadcast_stream_index(const esp_ble_audio_bap_stream_t *stream)
{
for (size_t i = 0; i < ARRAY_SIZE(broadcast_streams); i++) {
if (stream == &broadcast_streams[i].bap_stream) {
return (int)i;
}
}
return -1;
}
static void broadcast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
{
esp_ble_audio_cap_stream_t *cap_stream;
int err;
ESP_LOGI(TAG, "[SRC #0] Stream started");
ESP_LOGI(TAG, "[SRC #%d] Stream started", broadcast_stream_index(stream));
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
err = cap_initiator_tx_register_stream(cap_stream, true);
if (err) {
ESP_LOGE(TAG, "[SRC #0] Failed to register TX, err %d", err);
ESP_LOGE(TAG, "[SRC #%d] Failed to register TX, err %d",
broadcast_stream_index(stream), err);
}
}
@@ -39,7 +63,8 @@ static void broadcast_stream_stopped_cb(esp_ble_audio_bap_stream_t *stream, uint
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[SRC #0] Stream stopped, reason 0x%02x", reason);
ESP_LOGI(TAG, "[SRC #%d] Stream stopped, reason 0x%02x",
broadcast_stream_index(stream), reason);
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
@@ -50,7 +75,8 @@ static void broadcast_stream_disconnected_cb(esp_ble_audio_bap_stream_t *stream,
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[SRC #0] ISO disconnected, reason 0x%02x", reason);
ESP_LOGI(TAG, "[SRC #%d] ISO disconnected, reason 0x%02x",
broadcast_stream_index(stream), reason);
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
@@ -135,13 +161,11 @@ static uint8_t *per_adv_data_get(uint8_t *data_len)
int cap_initiator_broadcast_start(void)
{
esp_ble_audio_cap_initiator_broadcast_stream_param_t stream_params = {
.stream = &broadcast_stream,
};
esp_ble_audio_cap_initiator_broadcast_stream_param_t stream_params[SINK_STREAM_COUNT] = {0};
esp_ble_audio_cap_initiator_broadcast_subgroup_param_t subgroup_param = {
.codec_cfg = &broadcast_preset_16_2_1.codec_cfg,
.stream_params = &stream_params,
.stream_count = 1,
.stream_params = stream_params,
.stream_count = ARRAY_SIZE(stream_params),
};
const esp_ble_audio_cap_initiator_broadcast_create_param_t create_param = {
.qos = &broadcast_preset_16_2_1.qos,
@@ -160,6 +184,22 @@ int cap_initiator_broadcast_start(void)
ESP_LOGI(TAG, "Creating broadcast source");
for (size_t i = 0; i < ARRAY_SIZE(stream_params); i++) {
const esp_ble_audio_location_t loc = bis_locations[i];
/* LTV: length, type, 4-octet Audio_Channel_Allocation (little endian) */
bis_data[i][0] = 5;
bis_data[i][1] = ESP_BLE_AUDIO_CODEC_CFG_CHAN_ALLOC;
bis_data[i][2] = (uint8_t)loc;
bis_data[i][3] = (uint8_t)(loc >> 8);
bis_data[i][4] = (uint8_t)(loc >> 16);
bis_data[i][5] = (uint8_t)(loc >> 24);
stream_params[i].stream = &broadcast_streams[i];
stream_params[i].data = bis_data[i];
stream_params[i].data_len = sizeof(bis_data[i]);
}
err = esp_ble_audio_cap_initiator_broadcast_audio_create(&create_param, &broadcast_source);
if (err) {
ESP_LOGE(TAG, "Failed to create broadcast source, err %d", err);
@@ -212,7 +252,9 @@ end:
int cap_initiator_broadcast_init(void)
{
esp_ble_audio_cap_stream_ops_register(&broadcast_stream, &broadcast_stream_ops);
for (size_t i = 0; i < ARRAY_SIZE(broadcast_streams); i++) {
esp_ble_audio_cap_stream_ops_register(&broadcast_streams[i], &broadcast_stream_ops);
}
ESP_LOGI(TAG, "CAP initiator broadcast initialized");

View File

@@ -13,7 +13,12 @@
#include "cap_initiator.h"
static struct tx_stream tx_streams[IS_ENABLED(CONFIG_EXAMPLE_UNICAST) + IS_ENABLED(CONFIG_EXAMPLE_BROADCAST)];
/* One slot per stream the sample can transmit on, in every enabled mode: the
* unicast sink streams and the broadcast source streams are distinct objects.
*/
static struct tx_stream tx_streams[SINK_STREAM_COUNT *
(IS_ENABLED(CONFIG_EXAMPLE_UNICAST) +
IS_ENABLED(CONFIG_EXAMPLE_BROADCAST))];
static const char *cap_stream_tx_label(const esp_ble_audio_cap_stream_t *cap_stream)
{

View File

@@ -7,20 +7,41 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <assert.h>
#include "cap_initiator.h"
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE(unicast_preset_16_2_1,
/* One preset per sink stream: each ..._DEFINE allocates its own codec configuration
* buffer, which is what lets the streams carry different Audio_Channel_Allocation
* values. Extend both this list and sink_presets[] to drive more than two channels.
*/
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE(unicast_preset_left,
ESP_BLE_AUDIO_LOCATION_FRONT_LEFT,
ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED);
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE(unicast_preset_right,
ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT,
ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED);
/* The return direction carries a single microphone channel. */
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE(unicast_preset_mono,
ESP_BLE_AUDIO_LOCATION_MONO_AUDIO,
ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED);
static esp_ble_audio_bap_lc3_preset_t *const sink_presets[] = {
&unicast_preset_left,
&unicast_preset_right,
};
_Static_assert(ARRAY_SIZE(sink_presets) >= SINK_STREAM_COUNT,
"Need one preset (one channel allocation) per sink stream");
static esp_ble_audio_cap_unicast_group_t *unicast_group;
static struct peer_config {
esp_ble_audio_cap_stream_t source_stream;
esp_ble_audio_cap_stream_t sink_stream;
esp_ble_audio_cap_stream_t sink_streams[SINK_STREAM_COUNT];
esp_ble_audio_bap_ep_t *source_ep;
esp_ble_audio_bap_ep_t *sink_ep;
esp_ble_audio_bap_ep_t *sink_eps[SINK_STREAM_COUNT];
size_t sink_ep_count;
esp_ble_conn_t *conn;
uint16_t conn_handle;
@@ -39,19 +60,35 @@ static const char *dir_str(esp_ble_audio_dir_t dir)
static const char *stream_dir_str(const esp_ble_audio_bap_stream_t *stream)
{
if (stream == &peer.sink_stream.bap_stream) {
return "SNK";
} else if (stream == &peer.source_stream.bap_stream) {
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return "SNK";
}
}
if (stream == &peer.source_stream.bap_stream) {
return "SRC";
}
return "???";
}
static int stream_index(const esp_ble_audio_bap_stream_t *stream)
{
/* Only one sink and one source per peer in this example. */
(void)stream;
return 0;
/* Index within the pool of its own direction, so logs read as "SNK #0" /
* "SNK #1" for the two channels of a stereo Acceptor.
*/
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return (int)i;
}
}
if (stream == &peer.source_stream.bap_stream) {
return 0;
}
return -1;
}
static bool is_tx_stream(esp_ble_audio_bap_stream_t *stream)
@@ -97,9 +134,10 @@ static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
ESP_LOGI(TAG, "[%s #%d] Stream started",
stream_dir_str(stream), stream_index(stream));
example_audio_rx_metrics_reset(&rx_metrics);
if (is_tx_stream(stream)) {
/* Only the source stream receives, so resetting the metrics here would let
* a sink stream starting later wipe the counters of a running source.
*/
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
err = cap_initiator_tx_register_stream(cap_stream, false);
@@ -107,6 +145,8 @@ static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
ESP_LOGE(TAG, "[%s #%d] Failed to register TX, err %d",
stream_dir_str(stream), stream_index(stream), err);
}
} else {
example_audio_rx_metrics_reset(&rx_metrics);
}
}
@@ -206,7 +246,9 @@ static int discover_sinks(void)
{
int err;
esp_ble_audio_cap_stream_ops_register(&peer.sink_stream, &unicast_stream_ops);
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
esp_ble_audio_cap_stream_ops_register(&peer.sink_streams[i], &unicast_stream_ops);
}
err = esp_ble_audio_bap_unicast_client_discover(peer.conn_handle, ESP_BLE_AUDIO_DIR_SINK);
if (err) {
@@ -238,28 +280,44 @@ static int discover_sources(void)
static int unicast_group_create(void)
{
esp_ble_audio_cap_unicast_group_stream_param_t source_stream_param = {
.qos_cfg = &unicast_preset_16_2_1.qos,
.stream = &peer.source_stream,
};
esp_ble_audio_cap_unicast_group_stream_param_t sink_stream_param = {
.qos_cfg = &unicast_preset_16_2_1.qos,
.stream = &peer.sink_stream,
};
esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params = {0};
/* The group keeps referencing these while it exists, so they outlive this call. */
static esp_ble_audio_cap_unicast_group_stream_param_t source_stream_param;
static esp_ble_audio_cap_unicast_group_stream_param_t sink_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params[SINK_STREAM_COUNT];
esp_ble_audio_cap_unicast_group_param_t group_param = {0};
size_t pair_count = 0;
int err;
/* One CIS per sink stream. */
for (size_t i = 0; i < peer.sink_ep_count; i++) {
sink_stream_params[i].qos_cfg = &sink_presets[i]->qos;
sink_stream_params[i].stream = &peer.sink_streams[i];
pair_params[pair_count].rx_param = NULL;
pair_params[pair_count].tx_param = &sink_stream_params[i];
pair_count++;
}
/* The return direction shares the first CIS, making it bidirectional. */
if (peer.source_ep) {
pair_params.rx_param = &source_stream_param;
source_stream_param.qos_cfg = &unicast_preset_mono.qos;
source_stream_param.stream = &peer.source_stream;
if (pair_count == 0) {
pair_params[0].tx_param = NULL;
pair_count = 1;
}
pair_params[0].rx_param = &source_stream_param;
}
if (peer.sink_ep) {
pair_params.tx_param = &sink_stream_param;
if (pair_count == 0) {
ESP_LOGW(TAG, "No endpoints available, skip creating unicast group");
return -ENODEV;
}
group_param.params_count = 1;
group_param.params = &pair_params;
group_param.params_count = pair_count;
group_param.params = pair_params;
err = esp_ble_audio_cap_unicast_group_create(&group_param, &unicast_group);
if (err) {
@@ -295,15 +353,19 @@ int unicast_group_delete(void)
static int unicast_audio_start(void)
{
esp_ble_audio_cap_unicast_audio_start_stream_param_t stream_param[2] = {0};
/* codec_cfg is assigned to the stream and has to stay valid while it is
* non-idle, so the parameters cannot live on the stack.
*/
static esp_ble_audio_cap_unicast_audio_start_stream_param_t
stream_param[SINK_STREAM_COUNT + 1];
esp_ble_audio_cap_unicast_audio_start_param_t param = {0};
int err;
if (peer.sink_ep) {
for (size_t i = 0; i < peer.sink_ep_count; i++) {
stream_param[param.count].member.member = peer.conn;
stream_param[param.count].stream = &peer.sink_stream;
stream_param[param.count].ep = peer.sink_ep;
stream_param[param.count].codec_cfg = &unicast_preset_16_2_1.codec_cfg;
stream_param[param.count].stream = &peer.sink_streams[i];
stream_param[param.count].ep = peer.sink_eps[i];
stream_param[param.count].codec_cfg = &sink_presets[i]->codec_cfg;
param.count++;
}
@@ -311,7 +373,7 @@ static int unicast_audio_start(void)
stream_param[param.count].member.member = peer.conn;
stream_param[param.count].stream = &peer.source_stream;
stream_param[param.count].ep = peer.source_ep;
stream_param[param.count].codec_cfg = &unicast_preset_16_2_1.codec_cfg;
stream_param[param.count].codec_cfg = &unicast_preset_mono.codec_cfg;
param.count++;
}
@@ -383,13 +445,16 @@ static void endpoint_cb(esp_ble_conn_t *conn,
{
if (dir == ESP_BLE_AUDIO_DIR_SOURCE) {
if (peer.source_ep == NULL) {
ESP_LOGI(TAG, "[%s] Endpoint discovered", dir_str(dir));
ESP_LOGI(TAG, "[%s #0] Endpoint discovered", dir_str(dir));
peer.source_ep = ep;
}
} else if (dir == ESP_BLE_AUDIO_DIR_SINK) {
if (peer.sink_ep == NULL) {
ESP_LOGI(TAG, "[%s] Endpoint discovered", dir_str(dir));
peer.sink_ep = ep;
/* Keep every sink endpoint, up to one per stream: a stereo Acceptor
* exposes one sink ASE per channel and all of them are used.
*/
if (peer.sink_ep_count < ARRAY_SIZE(peer.sink_eps)) {
ESP_LOGI(TAG, "[%s #%zu] Endpoint discovered", dir_str(dir), peer.sink_ep_count);
peer.sink_eps[peer.sink_ep_count++] = ep;
}
}
}
@@ -534,7 +599,8 @@ static void acl_disconnect(esp_ble_audio_gap_app_event_t *event)
peer.conn_handle = CONN_HANDLE_INIT;
peer.conn = NULL;
peer.source_ep = NULL;
peer.sink_ep = NULL;
memset(peer.sink_eps, 0, sizeof(peer.sink_eps));
peer.sink_ep_count = 0;
peer.disc_completed = false;
peer.mtu_exchanged = false;

View File

@@ -13,7 +13,7 @@ CONFIG_BT_ACL_CONNECTIONS=1
CONFIG_BT_GATTC_NOTIF_REG_MAX=20
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_BT_ISO_MAX_CHAN=4
CONFIG_BT_CAP_INITIATOR=y
CONFIG_BT_BAP_UNICAST_CLIENT=y

View File

@@ -34,6 +34,10 @@ CONFIG_BT_MCTL_LOCAL_PLAYER_REMOTE_CONTROL=y
CONFIG_BT_TBS=y
CONFIG_BT_TBS_SUPPORTED_FEATURES=3
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_FREERTOS_HZ=1000
# To place LE Audio .bss + control-plane heap in PSRAM, uncomment the
# lines below (needs a PSRAM-capable target, e.g. esp32s31/esp32h4).
# CONFIG_SPIRAM=y
@@ -54,7 +58,3 @@ CONFIG_BT_TBS_SUPPORTED_FEATURES=3
# CONFIG_BLE_LOG_PRPH_UART_DMA_PORT=0
# CONFIG_BLE_LOG_PRPH_UART_DMA_BAUD_RATE=3000000
# CONFIG_BLE_LOG_PRPH_UART_DMA_TX_IO_NUM=58
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_FREERTOS_HZ=1000