diff --git a/components/bt/esp_ble_audio/api/include/esp_ble_audio_common_api.h b/components/bt/esp_ble_audio/api/include/esp_ble_audio_common_api.h index c751cee4780..abc2efce177 100644 --- a/components/bt/esp_ble_audio/api/include/esp_ble_audio_common_api.h +++ b/components/bt/esp_ble_audio/api/include/esp_ble_audio_common_api.h @@ -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 */ diff --git a/components/bt/esp_ble_audio/host/common/init.c b/components/bt/esp_ble_audio/host/common/init.c index d54d3d9d907..15339f45d8a 100644 --- a/components/bt/esp_ble_audio/host/common/init.c +++ b/components/bt/esp_ble_audio/host/common/init.c @@ -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 */ diff --git a/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/bap.h b/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/bap.h index bc8259a1943..597677c7004 100644 --- a/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/bap.h +++ b/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/bap.h @@ -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); diff --git a/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/cap.h b/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/cap.h index ca2be9b9c9e..cfa4a098957 100644 --- a/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/cap.h +++ b/components/bt/esp_ble_audio/include/zephyr/bluetooth/audio/cap.h @@ -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; diff --git a/components/bt/esp_ble_audio/lib/lib b/components/bt/esp_ble_audio/lib/lib index 3ada1f9e754..774d7777665 160000 --- a/components/bt/esp_ble_audio/lib/lib +++ b/components/bt/esp_ble_audio/lib/lib @@ -1 +1 @@ -Subproject commit 3ada1f9e7547fc0d453520f40f5e4589c35c52d6 +Subproject commit 774d7777665adf21ea4bcf2d8f5f283516a0c391 diff --git a/components/bt/esp_ble_iso/api/include/esp_ble_iso_common_api.h b/components/bt/esp_ble_iso/api/include/esp_ble_iso_common_api.h index 06752094fd0..37ff644bcdb 100644 --- a/components/bt/esp_ble_iso/api/include/esp_ble_iso_common_api.h +++ b/components/bt/esp_ble_iso/api/include/esp_ble_iso_common_api.h @@ -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 */ diff --git a/components/bt/esp_ble_iso/host/adapter/bluedroid/gatt/gatt.c b/components/bt/esp_ble_iso/host/adapter/bluedroid/gatt/gatt.c index ca1ef04f1f5..41268ba502f 100644 --- a/components/bt/esp_ble_iso/host/adapter/bluedroid/gatt/gatt.c +++ b/components/bt/esp_ble_iso/host/adapter/bluedroid/gatt/gatt.c @@ -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); } } } diff --git a/components/bt/esp_ble_iso/host/adapter/nimble/gatt/gatt.c b/components/bt/esp_ble_iso/host/adapter/nimble/gatt/gatt.c index 2571265d9cc..6dd164e3310 100644 --- a/components/bt/esp_ble_iso/host/adapter/nimble/gatt/gatt.c +++ b/components/bt/esp_ble_iso/host/adapter/nimble/gatt/gatt.c @@ -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); } } } diff --git a/components/bt/esp_ble_iso/host/common/adv.c b/components/bt/esp_ble_iso/host/common/adv.c index 7f2af049a57..749627d098d 100644 --- a/components/bt/esp_ble_iso/host/common/adv.c +++ b/components/bt/esp_ble_iso/host/common/adv.c @@ -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; +} diff --git a/components/bt/esp_ble_iso/host/common/include/common/app/gap.h b/components/bt/esp_ble_iso/host/common/include/common/app/gap.h index c64822f5cf7..6c42a0869d7 100644 --- a/components/bt/esp_ble_iso/host/common/include/common/app/gap.h +++ b/components/bt/esp_ble_iso/host/common/include/common/app/gap.h @@ -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]; diff --git a/components/bt/esp_ble_iso/host/common/include/common/gatt.h b/components/bt/esp_ble_iso/host/common/include/common/gatt.h index ad33cfdd2e2..b020603902f 100644 --- a/components/bt/esp_ble_iso/host/common/include/common/gatt.h +++ b/components/bt/esp_ble_iso/host/common/include/common/gatt.h @@ -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; diff --git a/components/bt/esp_ble_iso/include/zephyr/bluetooth/bluetooth.h b/components/bt/esp_ble_iso/include/zephyr/bluetooth/bluetooth.h index 74b2191c597..e38b8902b00 100644 --- a/components/bt/esp_ble_iso/include/zephyr/bluetooth/bluetooth.h +++ b/components/bt/esp_ble_iso/include/zephyr/bluetooth/bluetooth.h @@ -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. * diff --git a/docs/en/api-reference/bluetooth/esp-ble-audio.rst b/docs/en/api-reference/bluetooth/esp-ble-audio.rst index 91c597bb37d..157cd459dc5 100644 --- a/docs/en/api-reference/bluetooth/esp-ble-audio.rst +++ b/docs/en/api-reference/bluetooth/esp-ble-audio.rst @@ -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)** diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/README.md b/examples/bluetooth/esp_ble_audio/cap/acceptor/README.md index ed937408d2d..ea22f4b27a6 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/README.md +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/README.md @@ -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. diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/Kconfig.projbuild b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/Kconfig.projbuild index a122f1a884d..57bc79ad613 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/Kconfig.projbuild +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/Kconfig.projbuild @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor.h b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor.h index 77cd669bf9f..1338b9ffd64 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor.h +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor.h @@ -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. */ diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_broadcast.c b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_broadcast.c index b04e8b289bf..45274e3a498 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_broadcast.c +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_broadcast.c @@ -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 && diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_unicast.c b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_unicast.c index 2acba3efa2c..841384516be 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_unicast.c +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/cap_acceptor_unicast.c @@ -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, diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/main.c b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/main.c index 633f82d8ae5..d670e366a04 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/main/main.c +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/main/main.c @@ -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; + } } } diff --git a/examples/bluetooth/esp_ble_audio/cap/acceptor/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/cap/acceptor/sdkconfig.defaults index 68847a5cfbe..6079089a0f5 100644 --- a/examples/bluetooth/esp_ble_audio/cap/acceptor/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_audio/cap/acceptor/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/CMakeLists.txt b/examples/bluetooth/esp_ble_audio/cap/handover/CMakeLists.txt new file mode 100644 index 00000000000..de20d93ce7b --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/CMakeLists.txt @@ -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) diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/README.md b/examples/bluetooth/esp_ble_audio/cap/handover/README.md new file mode 100644 index 00000000000..98b18492c64 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/README.md @@ -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 +`[]`, 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. | diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/CMakeLists.txt b/examples/bluetooth/esp_ble_audio/cap/handover/main/CMakeLists.txt new file mode 100644 index 00000000000..253b9dd9987 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/CMakeLists.txt @@ -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) diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/Kconfig.projbuild b/examples/bluetooth/esp_ble_audio/cap/handover/main/Kconfig.projbuild new file mode 100644 index 00000000000..dd2c6311e4f --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/Kconfig.projbuild @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.c new file mode 100644 index 00000000000..39d35dee209 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.c @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#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); +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.h b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.h new file mode 100644 index 00000000000..baf70d7a147 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/adv.h @@ -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); diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/central.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/central.c new file mode 100644 index 00000000000..30532bc3f14 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/bluedroid/central.c @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#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); +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover.h b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover.h new file mode 100644 index 00000000000..4243d72e8cb --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover.h @@ -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 + +#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); diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_broadcast.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_broadcast.c new file mode 100644 index 00000000000..c5616011bdd --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_broadcast.c @@ -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 +#include +#include + +#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; +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_diag.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_diag.c new file mode 100644 index 00000000000..88361372715 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_diag.c @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#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); + } +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_proc.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_proc.c new file mode 100644 index 00000000000..fce4ca556a2 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_proc.c @@ -0,0 +1,725 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include + +#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(¶m); + 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(¶m); + 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; +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_tx.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_tx.c new file mode 100644 index 00000000000..0d4d4793528 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_tx.c @@ -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 +#include +#include +#include +#include + +#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; + } + } +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_unicast.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_unicast.c new file mode 100644 index 00000000000..02b3cebf2f9 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/cap_handover_unicast.c @@ -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 +#include +#include + +#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(¶m); + 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; +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/idf_component.yml b/examples/bluetooth/esp_ble_audio/cap/handover/main/idf_component.yml new file mode 100644 index 00000000000..bb02c0922aa --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/idf_component.yml @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/main.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/main.c new file mode 100644 index 00000000000..96430ade8b3 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/main.c @@ -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 + +#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; + } +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/adv.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/adv.c new file mode 100644 index 00000000000..7c594dd865d --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/adv.c @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#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); +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/central.c b/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/central.c new file mode 100644 index 00000000000..ca5fa4a640f --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/main/nimble/central.c @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#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, ¶ms, + 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, + ¶ms, 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); +} diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults new file mode 100644 index 00000000000..36c2f59a70e --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32h4 b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32h4 new file mode 100644 index 00000000000..5920ce3f853 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32h4 @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32s31 b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32s31 new file mode 100644 index 00000000000..1f18ae5d6a9 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.esp32s31 @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.nimble b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.nimble new file mode 100644 index 00000000000..0c3c84403ac --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/cap/handover/sdkconfig.defaults.nimble @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator.h b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator.h index 76fd734f957..5b8963c8d66 100644 --- a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator.h +++ b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator.h @@ -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" diff --git a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_broadcast.c b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_broadcast.c index dabd5e8e1e1..7e2c4c648c1 100644 --- a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_broadcast.c +++ b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_broadcast.c @@ -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"); diff --git a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_tx.c b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_tx.c index 1966901d8e0..c33c295afb8 100644 --- a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_tx.c +++ b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_tx.c @@ -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) { diff --git a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_unicast.c b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_unicast.c index 127b8a875a3..75238ebe58d 100644 --- a/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_unicast.c +++ b/examples/bluetooth/esp_ble_audio/cap/initiator/main/cap_initiator_unicast.c @@ -7,20 +7,41 @@ #include #include +#include #include #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; diff --git a/examples/bluetooth/esp_ble_audio/cap/initiator/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/cap/initiator/sdkconfig.defaults index 637ef694277..6fdaed04ee9 100644 --- a/examples/bluetooth/esp_ble_audio/cap/initiator/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_audio/cap/initiator/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults index 4e0e950604d..9e52c35ee96 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults @@ -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