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

This commit is contained in:
Liu Linyan
2026-09-04 09:26:24 +08:00
parent 130e4a07bb
commit ed16327b81
47 changed files with 3749 additions and 120 deletions
@@ -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)
@@ -0,0 +1,243 @@
| Supported Targets | ESP32-H4 | ESP32-S31 |
| ----------------- | -------- | --------- |
# CAP Handover Example
(See the README.md file in the upper level `examples` directory for more information about examples.)
## Overview
This example implements a device with a **collocated CAP Initiator and CAP Commander** and moves
the audio back and forth between unicast and broadcast using the **CAP Handover procedures**
(CAP v1.0.1 §7.3.1.10 Unicast to Broadcast, §7.3.1.11 Broadcast to Unicast).
It connects to a CAP Acceptor exactly like [`../initiator/`](../initiator/) does — scan, connect,
pair, exchange MTU, discover CAS / sink ASEs / source ASEs — and additionally discovers the
Acceptor's **Broadcast Audio Scan Service (BASS)**, which the Commander writes into to tell the
Acceptor about the broadcast source. Once unicast audio is streaming, a timer alternates between
the two procedures every `EXAMPLE_HANDOVER_PERIOD_S`.
**Only the sink direction is handed over.** A broadcast Audio Stream is one-directional, so the
return (Acceptor to Initiator) stream is not part of the procedure: §7.3.1.10 applies to "all CISes
within the CIG that is carrying an Audio Stream from the Initiator to the Acceptor", and leaves the
reverse direction implementation specific.
The very same `bt_cap_stream` objects carry the audio before and after a handover — that is a
requirement of the CAP handover API, not a shortcut: the procedure validates that every broadcast
stream is one of the unicast group's streaming sink streams, and that *all* of them are handed over.
Consequently there is **no standalone broadcast source** in this example: it is created by the
unicast-to-broadcast procedure and deleted by the reverse one.
## Requirements
* A board with BLE 5.2, ISO, and LE Audio support (e.g. ESP32-H4, ESP32-S31)
* A second board running [`../acceptor/`](../acceptor/) with **both** `EXAMPLE_UNICAST` and
`EXAMPLE_BROADCAST` enabled (its `sdkconfig.defaults` already does), so it exposes ASCS and BASS
at the same time
* The Acceptor must **not** be built with `EXAMPLE_SCAN_SELF` (its Kconfig already makes that
mutually exclusive with `EXAMPLE_UNICAST`) — see *Periodic advertising sync* below
## Periodic advertising sync: PAST only
This example is a **collocated broadcaster**: the same device is the Broadcast Source and the
Commander that tells the Acceptor about it. It therefore never scans for its own broadcast, and it
hands the periodic advertising train over with **PAST** rather than asking the Acceptor to find it.
* The Add Source operation is written with `PA_Sync = 0x01` (*synchronize, PAST available*).
* The Acceptor answers by putting its Broadcast Receive State into `PA_Sync_State = 1`
(*SyncInfo Request*).
* Seeing that state, this example sends **LE Periodic Advertising Set Info Transfer** — the
variant for handing over one of *our own* advertising sets. (The other variant, Sync Transfer,
applies when an Assistant relays a sync it holds to a third-party broadcaster; that is not this
case.) The Source ID goes in the **high octet** of the service data, which is where the Acceptor
reads it from.
There is deliberately **no self-scan fallback**: a real earbud is not asked to scan for a source
its phone already knows about. If the Acceptor were built to self-scan instead, it would sit at
"Waiting for BASE" forever.
## Configuration
```bash
idf.py menuconfig
```
Under **Example: CAP Handover**:
* `EXAMPLE_HANDOVER_PERIOD_S` (default 300) — seconds between switches. Set to 0 to stay on
unicast after the initial setup.
> **Size note.** The Broadcast Assistant (BASS *client*) is genuinely used — it is how the
> Commander writes Add Source / Modify Source / Remove Source. The Scan Delegator (BASS
> *server*) is **not**: it is pulled in only because every branch of `BT_CAP_COMMANDER`'s
> `depends on` that avoids it requires some other client role we do not need either
> (`BT_VCP_VOL_CTLR`, `BT_MICP_MIC_CTLR`, `BT_TBS_CLIENT`, `BT_MCC`). CAP §7.3.1.8 only requires
> a Commander to act as Scan Delegator when it is **not collocated** with the Initiator **and**
> the stream is encrypted — neither holds here — so this is an over-constraint in the Kconfig
> rather than a real dependency.
### Security & Pairing
Just-Works pairing (LE Secure Connections, no MITM) with bonding, inherited from
`../../common_components/example_init/ble_audio_example_init.c`.
## Build & Flash
```bash
idf.py set-target esp32h4
idf.py -p PORT flash monitor
```
For the **NimBLE** host, layer the overlay:
```bash
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32h4;sdkconfig.defaults.nimble" -p PORT flash monitor
```
(Exit serial monitor with `Ctrl-]`.)
## Example Flow
1. `app_main` brings up NVS, the controller, the LE Audio common layer, the unicast role, the
handover module and the TX pump, then starts scanning.
2. On finding CAS in a connectable advertisement: connect, pair, exchange MTU, GATT discovery.
3. Discover CAS, then the sink and source ASEs, then **BASS**.
4. **Sweep the Acceptor's Broadcast Receive States.** Discovery only subscribes to them, so the
example reads each one once and removes any left over from an earlier run of ours (matched by
Broadcast ID). Receive states live on the Acceptor and survive a reflash of *this* board, and
BAP §6.5.4 forbids an Add Source that would duplicate the
{address, SID, Broadcast ID} triple — without the sweep the first handover after a reboot
fails with `0xFC` (Write Request Rejected). The sweep then continues into
`cap_handover_unicast_setup_and_start()`.
5. A unicast group is created with one CIS per sink stream (the return direction shares the first
CIS) and the streams start.
6. **Unicast to broadcast**, every `EXAMPLE_HANDOVER_PERIOD_S`:
* the advertising set is brought up (extended advertising started, periodic advertising
configured but *stopped* — the BASE does not exist yet);
* the local public address and SID are registered with the audio stack, because the BASS Add
Source operation carries them;
* `esp_ble_audio_cap_handover_unicast_to_broadcast()` stops and releases the unicast group,
creates the broadcast source, and has the Commander write Add Source to the Acceptor;
* from the `created` callback the BASE is encoded and periodic advertising is started.
7. **Broadcast to unicast**, one period later: the Commander stops the reception on the Acceptor
(using the Source ID learned from the Broadcast Receive State), the broadcast source is stopped
and deleted, a new unicast group is created and the streams start again; advertising is stopped.
8. On ACL disconnect any broadcast source left over is deleted and scanning resumes.
## Expected Log
TAG: `CAP_HOV`.
```
I (xxx) CAP_HOV: CAP initiator unicast initialized
I (xxx) CAP_HOV: CAP initiator handover initialized (period 60 s)
I (xxx) CAP_HOV: Scanning for CAP Acceptor...
I (xxx) CAP_HOV: Found CAS in peer adv data!
I (xxx) CAP_HOV: Connected: handle ... role ... peer ...
I (xxx) CAP_HOV: Discover sources complete
I (xxx) CAP_HOV: Discovering BASS
I (xxx) CAP_HOV: BASS discovered (1 receive state(s))
I (xxx) CAP_HOV: Created unicast group
I (xxx) CAP_HOV: [SNK #0] Stream started
I (xxx) CAP_HOV: [SNK #1] Stream started
I (xxx) CAP_HOV: Unicast start completed
I (xxx) CAP_HOV: Handover: unicast -> broadcast (2 stream(s))
I (xxx) CAP_HOV: Advertising started, BASE pending (handle 0)
I (xxx) CAP_HOV: Periodic advertising started (handle 0)
I (xxx) CAP_HOV: Acceptor receive state: src_id 1 pa_sync 2
I (xxx) CAP_HOV: Handover to broadcast completed
I (xxx) CAP_HOV: Handover: broadcast -> unicast (2 stream(s))
I (xxx) CAP_HOV: Advertising stopped (handle 0)
I (xxx) CAP_HOV: Handover to unicast completed
```
Note the TX label follows the transport: `[SNK #0]` while the stream runs on a CIS, `[SRC #0]`
once the same stream object is bound to a broadcast source endpoint. Seeing `[SRC #0] TX: 6000
packets` during a broadcast period is the check that audio really is going out over the BIS.
On the acceptor side each switch shows the sink ASEs being released and a BASS Add Source arriving,
then the reverse.
### Timing
Each procedure is strictly sequential, so there is a silent gap at every switch. Measured on
ESP32-S31 with two sink streams, it is stable to within a few milliseconds:
| Direction | Duration |
|---|---|
| unicast to broadcast | ~1.69 s |
| broadcast to unicast | ~2.65 s |
## Diagnostics
Either side of every handover the example samples the ISO timing of each stream and logs it under
`[<phase>]`, where phase is `u2b-pre`, `u2b-post`, `b2u-pre` or `b2u-post`:
```
I (xxx) CAP_HOV: [u2b-pre][0] state 4 ts 66125612 us offset 0 us seq 6025
I (xxx) CAP_HOV: [u2b-pre][0] iso_interval 10000 us cig_sync_delay 2256 us cis_sync_delay 2256 us
I (xxx) CAP_HOV: [u2b-pre][0] c2p {ft 1 bn 1 latency 2256 us sdu_interval unknown (v1 event)}
I (xxx) CAP_HOV: [u2b-pre][0] p2c {ft 1 bn 0 latency 0 us sdu_interval unknown (v1 event)}
I (xxx) CAP_HOV: [u2b-pre][0] pd_pref [20000, 40000] us pd_range [20000, 40000] us
```
A BIS reports the same first line plus its own terms, and no QoS preference:
```
I (xxx) CAP_HOV: [u2b-post][0] state 4 (timestamp not read: transport just started)
I (xxx) CAP_HOV: [u2b-post][0] iso_interval 10000 us big_sync_delay 1974 us
I (xxx) CAP_HOV: [u2b-post][0] bis {pto 0 bn 1 latency 1974 us}
```
These are the terms CAP §7.3.1.10's rendering-point alignment would need. Reading them:
* `ts` is `TX_Time_Stamp`: the CIG reference point or BIG anchor point, taken from the
controller's free-running clock. Both transports use the same clock, so the two can be
subtracted directly.
* The **post** samples deliberately do not read `ts`. HCI answers *Command Disallowed* until a
stream has sent its first SDU, and a transport that has just come up has not; the timestamp for
the new transport shows up in the next **pre** sample instead.
* `sdu_interval` is reported as unknown because it is only carried by
`HCI_LE_CIS_Established_V2`; the controller sends the v1 event. Use the QoS value instead.
* `latency` is the transport latency the controller actually achieved, not the maximum the preset
asked for.
* `ft` is derived: the host stores `flush_timeout = FT x ISO_Interval`, so it is divided back out
here — the alignment arithmetic wants FT itself.
* Only a CIS prints `pd_pref` / `pd_range`. Those come from the peer ASE's Codec Configured
notification, and a broadcast source has no peer to state a preference, so the fields would
only ever read zero.
## Peer Pairing
1. Flash [`../acceptor/`](../acceptor/) on one board with both roles enabled (default).
2. Flash this example on the other board.
3. Audio starts on CISes, then alternates with BISes every period.
## Notes
* The handover procedures in this stack are **sequential**: the unicast streams are stopped before
the broadcast source is created (CAP allows this as the "Initiator does not have the resources to
concurrently run both" path). There is therefore an audible gap at each switch. CAP §7.3.1.10 also
recommends aligning the rendering points of the two streams, which only makes sense on the
concurrent path and is not implemented.
* `Streaming_Audio_Contexts` and `CCID_List` are required by the specification to be the same on
both sides of a handover. So is the **codec configuration**: a different sampling frequency,
frame duration or frame size would make the Acceptor's decoder reconfigure exactly at the
switching point. Both paths therefore take their configuration from one
`HANDOVER_LC3_PRESET_DEFINE()` in `cap_handover.h`, and `handover_audio_config_check()` rejects
the handover if they ever diverge. Only the channel allocation differs by design: unicast puts
one channel in each stream, broadcast puts it in a per-BIS LTV.
* The **Source ID is assigned by the Acceptor**, not chosen here — the Add Source operation has no
such field. It is learned from the Broadcast Receive State and then used for Modify/Remove
Source. It keeps counting across a reflash of this board and only restarts when the *Acceptor*
reboots, which is why the receive-state sweep in step 4 exists.
## Troubleshooting
| Symptom | Cause |
|---|---|
| First handover after reflashing this board fails with `err 252` | A receive state left on the Acceptor by the previous run. The sweep in step 4 clears it; if it was removed, this is what comes back. |
| Acceptor logs "Syncing without PAST", then waits for the BASE forever | The Add Source went out with `PA_Sync = 0x02`. Check that the advertising set is registered with `esp_ble_audio_bap_broadcast_adv_add()` before the handover — its address and SID are what make PAST available. |
| Broadcast period is silent, no `[SRC #x] TX:` line | The TX pump did not register for the broadcast streams. It decides with `bt_bap_ep_info.can_send`; deriving it from `dir` instead does not work, because a broadcast source endpoint reports `dir = SOURCE` while a unicast client endpoint reports the peer's direction. |
@@ -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)
@@ -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
@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "esp_bt_defs.h"
#include "esp_bt_device.h"
#include "esp_gap_ble_api.h"
#include "adv.h"
#include "cap_handover.h"
static SemaphoreHandle_t adv_sem;
static esp_bt_status_t adv_op_status;
#define WAIT_API(_call) EXAMPLE_WAIT_API_CHECK(_call, adv_sem, portMAX_DELAY, adv_op_status)
static esp_ble_gap_ext_adv_params_t ext_adv_params = {
.type = ESP_BLE_GAP_SET_EXT_ADV_PROP_NONCONN_NONSCANNABLE_UNDIRECTED,
.interval_min = ESP_BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS),
.interval_max = ESP_BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS),
.channel_map = ADV_CHNL_ALL,
.filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
.primary_phy = ESP_BLE_GAP_PHY_1M,
.max_skip = 0,
.secondary_phy = ESP_BLE_GAP_PHY_2M,
.sid = ADV_SID,
.scan_req_notif = false,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.tx_power = ADV_TX_POWER,
};
static esp_ble_gap_periodic_adv_params_t periodic_adv_params = {
.interval_min = ESP_BLE_GAP_PERIODIC_ADV_ITVL_MS(PER_ADV_INTERVAL_MS),
.interval_max = ESP_BLE_GAP_PERIODIC_ADV_ITVL_MS(PER_ADV_INTERVAL_MS),
.properties = 0,
};
static esp_ble_gap_ext_adv_t ext_adv_inst[1] = {
[0] = { ADV_HANDLE, 0, 0 },
};
void adv_gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_EXT_ADV_SET_PARAMS_COMPLETE_EVT:
adv_op_status = param->ext_adv_set_params.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_DATA_SET_COMPLETE_EVT:
adv_op_status = param->ext_adv_data_set.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_START_COMPLETE_EVT:
adv_op_status = param->ext_adv_start.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_STOP_COMPLETE_EVT:
adv_op_status = param->ext_adv_stop.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_SET_PARAMS_COMPLETE_EVT:
adv_op_status = param->peroid_adv_set_params.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_DATA_SET_COMPLETE_EVT:
adv_op_status = param->period_adv_data_set.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_START_COMPLETE_EVT:
adv_op_status = param->period_adv_start.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_STOP_COMPLETE_EVT:
adv_op_status = param->period_adv_stop.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_PERIODIC_ADV_SET_INFO_TRANS_COMPLETE_EVT:
/* Not awaited: the Acceptor reports the result through its receive state. */
if (param->period_adv_set_info_trans.status != ESP_BT_STATUS_SUCCESS) {
ESP_LOGE(TAG, "PAST failed, status %d", param->period_adv_set_info_trans.status);
}
break;
default:
break;
}
}
int adv_host_init(void)
{
adv_sem = xSemaphoreCreateBinary();
if (adv_sem == NULL) {
ESP_LOGE(TAG, "Failed to create adv semaphore");
return -1;
}
return 0;
}
static int adv_set_configure(const uint8_t *ext_data, uint8_t ext_len)
{
WAIT_API(esp_ble_gap_ext_adv_set_params(ADV_HANDLE, &ext_adv_params));
WAIT_API(esp_ble_gap_config_ext_adv_data_raw(ADV_HANDLE, ext_len, ext_data));
WAIT_API(esp_ble_gap_periodic_adv_set_params(ADV_HANDLE, &periodic_adv_params));
return 0;
}
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len)
{
#if CONFIG_BT_BLE_FEAT_PERIODIC_ADV_ENH
WAIT_API(esp_ble_gap_config_periodic_adv_data_raw(ADV_HANDLE, per_len, per_data, false));
WAIT_API(esp_ble_gap_periodic_adv_start(ADV_HANDLE, true));
#else
WAIT_API(esp_ble_gap_config_periodic_adv_data_raw(ADV_HANDLE, per_len, per_data));
WAIT_API(esp_ble_gap_periodic_adv_start(ADV_HANDLE));
#endif
ESP_LOGI(TAG, "Periodic advertising started (handle %u)", ADV_HANDLE);
return 0;
}
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len)
{
int err;
err = adv_set_configure(ext_data, ext_len);
if (err) {
return err;
}
WAIT_API(esp_ble_gap_ext_adv_start(1, ext_adv_inst));
ESP_LOGI(TAG, "Advertising started, BASE pending (handle %u)", ADV_HANDLE);
return 0;
}
int adv_stop(void)
{
uint8_t instance = ADV_HANDLE;
WAIT_API(esp_ble_gap_periodic_adv_stop(ADV_HANDLE));
WAIT_API(esp_ble_gap_ext_adv_stop(1, &instance));
ESP_LOGI(TAG, "Advertising stopped (handle %u)", ADV_HANDLE);
return 0;
}
int local_public_addr_get(uint8_t addr[6])
{
const uint8_t *bda = esp_bt_dev_get_address();
if (bda == NULL) {
ESP_LOGE(TAG, "Local BD address unavailable");
return -1;
}
/* Bluedroid hands out addresses MSB-first; the caller feeds this to the audio
* stack, which stores a bt_addr_le_t and puts it on air LSB-first.
*/
for (size_t i = 0; i < 6; i++) {
addr[i] = bda[5 - i];
}
return 0;
}
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data)
{
esp_bd_addr_t addr;
(void)conn_handle;
memcpy(addr, peer_addr, sizeof(addr));
return esp_ble_gap_periodic_adv_set_info_trans(addr, service_data, ADV_HANDLE);
}
@@ -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);
@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "esp_bt_defs.h"
#include "esp_gap_ble_api.h"
#include "esp_gattc_api.h"
#include "esp_ble_audio_common_api.h"
#include "adv.h"
#include "cap_handover.h"
static SemaphoreHandle_t scan_sem;
static esp_bt_status_t scan_op_status;
#define WAIT_API(_call) EXAMPLE_WAIT_API_CHECK(_call, scan_sem, portMAX_DELAY, scan_op_status)
static esp_bd_addr_t peer_bda;
static esp_ble_ext_scan_params_t ext_scan_params = {
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.filter_policy = BLE_SCAN_FILTER_ALLOW_ALL,
.scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE,
.cfg_mask = ESP_BLE_GAP_EXT_SCAN_CFG_UNCODE_MASK,
.uncoded_cfg = {
.scan_type = BLE_SCAN_TYPE_PASSIVE,
.scan_interval = SCAN_INTERVAL,
.scan_window = SCAN_WINDOW,
},
};
static void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_SET_EXT_SCAN_PARAMS_COMPLETE_EVT:
scan_op_status = param->set_ext_scan_params.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_EXT_SCAN_START_COMPLETE_EVT:
scan_op_status = param->ext_scan_start.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_EXT_SCAN_STOP_COMPLETE_EVT:
scan_op_status = param->ext_scan_stop.status;
xSemaphoreGive(scan_sem);
break;
case ESP_GAP_BLE_NC_REQ_EVT:
esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, true);
break;
case ESP_GAP_BLE_AUTH_CMPL_EVT:
esp_ble_audio_gap_app_post_event(event, param);
break;
default:
/* Bluedroid has one GAP callback slot; the advertising events belong
* to the other wrapper. The two event sets are disjoint. */
adv_gap_event_handler(event, param);
break;
}
}
int app_host_init(void)
{
esp_err_t err;
scan_sem = xSemaphoreCreateBinary();
if (scan_sem == NULL) {
ESP_LOGE(TAG, "Failed to create scan semaphore");
return -1;
}
err = adv_host_init();
if (err) {
vSemaphoreDelete(scan_sem);
return err;
}
err = esp_ble_gap_register_callback(gap_event_handler);
if (err) {
ESP_LOGE(TAG, "Failed to register GAP callback, err %d", err);
vSemaphoreDelete(scan_sem);
return err;
}
return 0;
}
int set_device_name(void)
{
return esp_ble_gap_set_device_name(LOCAL_DEVICE_NAME);
}
int ext_scan_start(void)
{
WAIT_API(esp_ble_gap_set_ext_scan_params(&ext_scan_params));
WAIT_API(esp_ble_gap_start_ext_scan(0, 0));
ESP_LOGI(TAG, "Scanning for CAP Acceptor...");
return 0;
}
int ext_scan_stop(void)
{
WAIT_API(esp_ble_gap_stop_ext_scan());
return 0;
}
int conn_create(uint8_t addr_type, const uint8_t addr[6])
{
const esp_ble_gap_conn_params_t conn_params = {
.scan_interval = INIT_SCAN_INTERVAL,
.scan_window = INIT_SCAN_WINDOW,
.interval_min = CONN_INTERVAL,
.interval_max = CONN_INTERVAL,
.latency = CONN_LATENCY,
.supervision_timeout = CONN_TIMEOUT,
.min_ce_len = CONN_MIN_CE_LEN,
.max_ce_len = CONN_MAX_CE_LEN,
};
esp_gatt_if_t gattc_if;
esp_err_t err;
memcpy(peer_bda, addr, sizeof(peer_bda));
err = esp_ble_gap_prefer_ext_connect_params_set(
peer_bda, ESP_BLE_GAP_PHY_1M_PREF_MASK, &conn_params, NULL, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to set ext conn params, err %d", err);
return err;
}
/* Use the audio engine's GATTC if — events route back to the engine
* which forwards them to the lib. Registering a separate example GATTC
* app would steer events to a handler the engine never sees.
*
* engine returns ESP_GATT_IF_NONE (0xFF) when GATTC is not yet
* registered; feeding that into aux_open silently no-ops in BTC and
* no acl_connect event ever fires, leaving the caller in a no-scan
* / no-conn dead state. */
gattc_if = esp_ble_audio_bluedroid_get_gattc_if();
if (gattc_if == ESP_GATT_IF_NONE) {
ESP_LOGE(TAG, "GATTC not registered");
return ESP_ERR_INVALID_STATE;
}
return esp_ble_gattc_aux_open(gattc_if, peer_bda,
(esp_ble_addr_type_t)addr_type, true);
}
int pairing_start(uint16_t conn_handle)
{
(void)conn_handle;
return esp_ble_set_encryption(peer_bda, ESP_BLE_SEC_ENCRYPT_NO_MITM);
}
int exchange_mtu(uint16_t conn_handle)
{
(void)conn_handle;
/* The Bluedroid GATTC adapter exchanges MTU automatically when aux_open
* brings up the ACL — the MTU_UPDATED event arrives without an explicit
* kick-off. NimBLE has no such auto-exchange, so this wrapper is a no-op
* on bluedroid and a real ble_gattc_exchange_mtu on nimble. */
return 0;
}
void security_failed_recover(uint16_t conn_handle, uint8_t status)
{
(void)conn_handle;
ESP_LOGE(TAG, "Security change failed, status %u, clearing local bond and reconnecting", status);
esp_ble_remove_bond_device(peer_bda);
esp_ble_gap_disconnect(peer_bda);
}
@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "esp_log.h"
#include "sdkconfig.h"
#include "esp_ble_audio_lc3_defs.h"
#include "esp_ble_audio_bap_api.h"
#include "esp_ble_audio_cap_api.h"
#include "esp_ble_audio_pacs_api.h"
#include "esp_ble_audio_bap_lc3_preset_defs.h"
#include "ble_audio_example_init.h"
#include "ble_audio_example_utils.h"
#define TAG "CAP_HOV"
#define CONN_HANDLE_INIT 0xFFFF
#define SINK_STREAM_COUNT MIN(CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT, \
CONFIG_BT_BAP_BROADCAST_SRC_STREAM_COUNT)
#define LOCAL_DEVICE_NAME "CAP Handover"
#define SCAN_INTERVAL 160 /* 100ms */
#define SCAN_WINDOW 160 /* 100ms */
#define INIT_SCAN_INTERVAL 16 /* 10ms */
#define INIT_SCAN_WINDOW 16 /* 10ms */
#define CONN_INTERVAL 24 /* 30ms */
#define CONN_LATENCY 0
#define CONN_TIMEOUT 500 /* 5s */
#define CONN_MIN_CE_LEN 0xFFFF
#define CONN_MAX_CE_LEN 0xFFFF
#define ADV_HANDLE 0
#define ADV_SID 0
#define ADV_TX_POWER 127
#define ADV_INTERVAL_MS 200
#define PER_ADV_INTERVAL_MS 100
#define LOCAL_BROADCAST_ID 0x123456
#define HANDOVER_LC3_PRESET_DEFINE(_name, _loc) \
ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_16_2_1_DEFINE( \
_name, _loc, ESP_BLE_AUDIO_CONTEXT_TYPE_UNSPECIFIED)
int app_host_init(void);
int set_device_name(void);
int ext_scan_start(void);
int ext_scan_stop(void);
int conn_create(uint8_t addr_type, const uint8_t addr[6]);
int pairing_start(uint16_t conn_handle);
int exchange_mtu(uint16_t conn_handle);
void security_failed_recover(uint16_t conn_handle, uint8_t status);
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len);
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len);
int adv_stop(void);
int local_public_addr_get(uint8_t addr[6]);
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data);
struct tx_stream {
esp_ble_audio_cap_stream_t *stream;
uint16_t seq_num;
uint8_t *data;
example_audio_tx_scheduler_t scheduler;
bool is_broadcast;
};
struct peer_config {
esp_ble_audio_cap_stream_t sink_streams[SINK_STREAM_COUNT];
esp_ble_audio_bap_ep_t *sink_eps[SINK_STREAM_COUNT];
size_t sink_ep_count;
esp_ble_conn_t *conn;
uint16_t conn_handle;
uint8_t dst[6];
bool disc_completed;
bool mtu_exchanged;
};
extern struct peer_config peer;
esp_ble_audio_cap_unicast_group_t *unicast_group_get(void);
void unicast_group_set(esp_ble_audio_cap_unicast_group_t *group);
int unicast_group_delete(void);
esp_ble_audio_bap_lc3_preset_t *sink_preset_get(size_t idx);
int cap_handover_unicast_setup_and_start(void);
void cap_handover_unicast_gap_cb(esp_ble_audio_gap_app_event_t *event);
void cap_handover_unicast_gatt_cb(esp_ble_audio_gatt_app_event_t *event);
int cap_handover_unicast_start(void);
int cap_handover_unicast_init(void);
uint8_t *broadcast_ext_adv_data_get(uint8_t *data_len);
uint8_t *broadcast_base_data_get(esp_ble_audio_cap_broadcast_source_t *source,
uint8_t *data_len);
esp_ble_audio_bap_lc3_preset_t *broadcast_preset_get(void);
esp_ble_audio_location_t broadcast_bis_location_get(size_t idx);
int cap_handover_proc_init(void);
int cap_handover_proc_discover(uint16_t conn_handle);
void cap_handover_proc_unicast_started(void);
void cap_handover_proc_broadcast_stopped(esp_ble_audio_cap_broadcast_source_t *source);
void cap_handover_diag_sample(const char *phase, bool has_sent);
void cap_handover_proc_reset(void);
void cap_handover_tx_stream_sent(esp_ble_audio_bap_stream_t *stream, void *user_data);
int cap_handover_tx_register_stream(esp_ble_audio_cap_stream_t *cap_stream, bool is_broadcast);
int cap_handover_tx_unregister_stream(esp_ble_audio_cap_stream_t *cap_stream);
void cap_handover_tx_init(void);
@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include <errno.h>
#include "cap_handover.h"
/* Shared subgroup configuration; the per-BIS channel allocation goes in a
* separate LTV, so the location here stays mono.
*/
HANDOVER_LC3_PRESET_DEFINE(broadcast_preset, ESP_BLE_AUDIO_LOCATION_MONO_AUDIO);
/* One BIS per channel; the handover module turns these into per-BIS LTVs. */
static const esp_ble_audio_location_t bis_locations[] = {
ESP_BLE_AUDIO_LOCATION_FRONT_LEFT,
ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT,
};
_Static_assert(ARRAY_SIZE(bis_locations) >= SINK_STREAM_COUNT,
"Need one channel allocation per broadcast stream");
uint8_t *broadcast_ext_adv_data_get(uint8_t *data_len)
{
uint32_t broadcast_id;
uint8_t *data;
broadcast_id = LOCAL_BROADCAST_ID;
/* - Broadcast Audio Announcement Service UUID (2 octets)
* - Broadcast ID (3 octets)
* - Complete Device Name
*/
*data_len = 7 + 2 + strlen(LOCAL_DEVICE_NAME);
data = calloc(1, *data_len);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to alloc ext adv data (%u octets)", *data_len);
return NULL;
}
data[0] = 0x06; /* 1 + 2 + 3 */
data[1] = EXAMPLE_AD_TYPE_SERVICE_DATA16;
data[2] = (ESP_BLE_AUDIO_UUID_BROADCAST_AUDIO_VAL & 0xFF);
data[3] = ((ESP_BLE_AUDIO_UUID_BROADCAST_AUDIO_VAL >> 8) & 0xFF);
data[4] = (broadcast_id & 0xFF);
data[5] = ((broadcast_id >> 8) & 0xFF);
data[6] = ((broadcast_id >> 16) & 0xFF);
data[7] = strlen(LOCAL_DEVICE_NAME) + 1;
data[8] = EXAMPLE_AD_TYPE_NAME_COMPLETE;
memcpy(data + 9, LOCAL_DEVICE_NAME, strlen(LOCAL_DEVICE_NAME));
return data;
}
uint8_t *broadcast_base_data_get(esp_ble_audio_cap_broadcast_source_t *source,
uint8_t *data_len)
{
NET_BUF_SIMPLE_DEFINE(base_buf, 128);
uint8_t *data;
esp_err_t err;
/* Broadcast Audio Announcement Service UUID (2 octets) and
* Broadcast Audio Source Endpoint (BASE)
*/
err = esp_ble_audio_cap_initiator_broadcast_get_base(source, &base_buf);
if (err) {
ESP_LOGE(TAG, "Failed to get encoded BASE, err %d", err);
return NULL;
}
*data_len = 2 + base_buf.len;
data = calloc(1, *data_len);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to alloc per adv data (%u octets)", *data_len);
return NULL;
}
/* base_buf.len has included the UUID length (2 octets) */
data[0] = 1 + base_buf.len;
data[1] = EXAMPLE_AD_TYPE_SERVICE_DATA16;
memcpy(data + 2, base_buf.data, base_buf.len);
return data;
}
esp_ble_audio_bap_lc3_preset_t *broadcast_preset_get(void)
{
return &broadcast_preset;
}
esp_ble_audio_location_t broadcast_bis_location_get(size_t idx)
{
return (idx < ARRAY_SIZE(bis_locations)) ? bis_locations[idx]
: ESP_BLE_AUDIO_LOCATION_MONO_AUDIO;
}
@@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stddef.h>
#include "esp_ble_iso_common_api.h"
#include "cap_handover.h"
/* flush_timeout holds FT x ISO_Interval, so divide FT back out. sdu_interval
* only comes with HCI_LE_CIS_Established_V2; a v1 event leaves it UNKNOWN.
*/
static void diag_log_cis_dir(const char *phase, size_t idx, const char *dir,
uint16_t iso_interval,
const struct bt_iso_unicast_tx_info *info)
{
ESP_LOGI(TAG, "[%s][%zu] %s {ft %u bn %u latency %lu us sdu_interval %s}",
phase, idx, dir,
iso_interval != 0 ? (unsigned)(info->flush_timeout / iso_interval) : 0U,
info->bn, (unsigned long)info->latency,
info->sdu_interval == BT_ISO_SDU_INTERVAL_UNKNOWN ? "unknown (v1 event)"
: "see qos");
}
static void diag_sample_stream(const char *phase, size_t idx,
esp_ble_audio_cap_stream_t *stream, bool has_sent)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_ble_iso_tx_info_t tx_info = {0};
esp_ble_iso_info_t chan_info = {0};
esp_ble_audio_bap_ep_t *ep = stream->bap_stream.ep;
esp_err_t err;
if (ep == NULL) {
ESP_LOGI(TAG, "[%s][%zu] no endpoint", phase, idx);
return;
}
if (esp_ble_audio_bap_ep_get_info(ep, &ep_info) != ESP_OK) {
ESP_LOGW(TAG, "[%s][%zu] endpoint info unavailable", phase, idx);
return;
}
/* HCI answers Command Disallowed until the stream has sent an SDU, and the
* adapter logs that as an HCI error - so do not ask a transport that just
* came up. Its timestamp shows up in the next pre-handover sample.
*/
if (!has_sent) {
ESP_LOGI(TAG, "[%s][%zu] state %d (timestamp not read: transport just started)",
phase, idx, ep_info.state);
} else if ((err = esp_ble_audio_cap_stream_get_tx_sync(stream, &tx_info)) != ESP_OK) {
ESP_LOGW(TAG, "[%s][%zu] state %d tx_sync failed, err %d",
phase, idx, ep_info.state, err);
} else {
ESP_LOGI(TAG, "[%s][%zu] state %d ts %lu us offset %lu us seq %u",
phase, idx, ep_info.state,
(unsigned long)tx_info.ts, (unsigned long)tx_info.offset,
tx_info.seq_num);
}
if (ep_info.iso_chan == NULL ||
esp_ble_iso_chan_get_info(ep_info.iso_chan, &chan_info) != ESP_OK) {
ESP_LOGI(TAG, "[%s][%zu] no ISO channel info", phase, idx);
return;
}
/* iso_interval is in 1.25 ms units; print us like every other duration. The
* channel kind shows in the field names, so it is not printed separately.
*/
if (chan_info.type == BT_ISO_CHAN_TYPE_BROADCASTER) {
ESP_LOGI(TAG, "[%s][%zu] iso_interval %lu us big_sync_delay %lu us",
phase, idx,
(unsigned long)chan_info.iso_interval * 1250UL,
(unsigned long)chan_info.broadcaster.sync_delay);
ESP_LOGI(TAG, "[%s][%zu] bis {pto %lu bn %u latency %lu us}",
phase, idx,
(unsigned long)chan_info.broadcaster.pto,
chan_info.broadcaster.bn,
(unsigned long)chan_info.broadcaster.latency);
/* No qos_pref: it comes from a peer ASE's Codec Configured, and a
* broadcast source has no peer. Printing four zeros would mislead.
*/
return;
}
ESP_LOGI(TAG, "[%s][%zu] iso_interval %lu us cig_sync_delay %lu us cis_sync_delay %lu us",
phase, idx,
(unsigned long)chan_info.iso_interval * 1250UL,
(unsigned long)chan_info.unicast.cig_sync_delay,
(unsigned long)chan_info.unicast.cis_sync_delay);
diag_log_cis_dir(phase, idx, "c2p", chan_info.iso_interval, &chan_info.unicast.central);
diag_log_cis_dir(phase, idx, "p2c", chan_info.iso_interval, &chan_info.unicast.peripheral);
if (ep_info.qos_pref != NULL) {
ESP_LOGI(TAG, "[%s][%zu] pd_pref [%lu, %lu] us pd_range [%lu, %lu] us",
phase, idx,
(unsigned long)ep_info.qos_pref->pref_pd_min,
(unsigned long)ep_info.qos_pref->pref_pd_max,
(unsigned long)ep_info.qos_pref->pd_min,
(unsigned long)ep_info.qos_pref->pd_max);
}
}
void cap_handover_diag_sample(const char *phase, bool has_sent)
{
for (size_t i = 0; i < peer.sink_ep_count && i < ARRAY_SIZE(peer.sink_streams); i++) {
diag_sample_stream(phase, i, &peer.sink_streams[i], has_sent);
}
}
@@ -0,0 +1,725 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "esp_timer.h"
#include "cap_handover.h"
/* 1.25 ms units. The real interval, not "unknown": the Acceptor's PA sync needs it. */
#define HANDOVER_PA_INTERVAL ((PER_ADV_INTERVAL_MS * 1000) / 1250)
/* Must stay valid until the procedure completes, so not on the stack. */
static esp_ble_audio_cap_initiator_broadcast_stream_param_t bcast_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_initiator_broadcast_subgroup_param_t bcast_subgroup_param;
static esp_ble_audio_cap_initiator_broadcast_create_param_t bcast_create_param;
static uint8_t bcast_bis_data[SINK_STREAM_COUNT][6];
static esp_ble_audio_cap_unicast_group_stream_param_t uni_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_stream_pair_param_t uni_pair_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_param_t uni_group_param;
static esp_ble_audio_cap_unicast_audio_start_stream_param_t uni_start_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_audio_start_param_t uni_start_param;
static esp_ble_audio_cap_commander_broadcast_reception_stop_member_param_t stop_member_param;
static esp_ble_audio_cap_commander_broadcast_reception_stop_param_t stop_param;
static esp_ble_audio_cap_broadcast_source_t *broadcast_source;
static esp_ble_audio_bap_broadcast_adv_info_t adv_info;
static esp_timer_handle_t switch_timer;
static esp_timer_handle_t start_timer;
static bool adv_registered;
static bool proc_active;
static bool on_broadcast;
/* Source ID of our broadcast on the Acceptor, needed to stop its reception. */
static uint8_t recv_state_src_id;
static bool recv_state_valid;
/* Sweep of the Acceptor's receive states, run once per connection. */
static uint8_t recv_state_cnt;
static uint8_t recv_state_next;
static bool recv_state_sweeping;
static void switch_timer_arm(void)
{
esp_err_t err;
if (CONFIG_EXAMPLE_HANDOVER_PERIOD_S == 0 || switch_timer == NULL) {
return;
}
(void)esp_timer_stop(switch_timer);
err = esp_timer_start_once(switch_timer,
(uint64_t)CONFIG_EXAMPLE_HANDOVER_PERIOD_S * 1000000);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to arm handover timer, err %d", err);
}
}
static void start_timer_cb(void *arg)
{
if (peer.conn == NULL) {
return;
}
(void)cap_handover_unicast_setup_and_start();
}
/* CAP holds one procedure at a time and the library shares our callback lists,
* so starting a procedure from inside a callback changes the active procedure
* under a library handler that has not run yet. Leave the dispatch first.
*/
static void unicast_start_defer(void)
{
esp_err_t err;
(void)esp_timer_stop(start_timer);
err = esp_timer_start_once(start_timer, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to schedule unicast start, err %d", err);
}
}
/* What the Acceptor's decoder must keep doing across the switch. Channel
* allocation is absent on purpose: it differs between the two paths by design.
*/
struct codec_shape {
esp_ble_audio_codec_cfg_freq_t freq;
esp_ble_audio_codec_cfg_frame_dur_t frame_dur;
uint16_t octets_per_frame;
uint8_t frame_blocks;
};
static int codec_shape_get(const esp_ble_audio_codec_cfg_t *codec_cfg,
struct codec_shape *out)
{
if (esp_ble_audio_codec_cfg_get_freq(codec_cfg, &out->freq) != ESP_OK ||
esp_ble_audio_codec_cfg_get_frame_dur(codec_cfg, &out->frame_dur) != ESP_OK ||
esp_ble_audio_codec_cfg_get_octets_per_frame(codec_cfg,
&out->octets_per_frame) != ESP_OK ||
esp_ble_audio_codec_cfg_get_frame_blocks_per_sdu(codec_cfg, &out->frame_blocks,
true) != ESP_OK) {
return -EINVAL;
}
return 0;
}
/* Never memcmp: the trailing padding is uninitialised stack. */
static bool codec_shape_eq(const struct codec_shape *a, const struct codec_shape *b)
{
return a->freq == b->freq &&
a->frame_dur == b->frame_dur &&
a->octets_per_frame == b->octets_per_frame &&
a->frame_blocks == b->frame_blocks;
}
/* A mismatch makes the Acceptor reconfigure its decoder at the switching point,
* which is audible. One macro feeds both paths, so this only fires if someone
* later points one of them elsewhere - better loudly than silently.
*/
static int handover_audio_config_check(void)
{
const esp_ble_audio_bap_lc3_preset_t *bcast = broadcast_preset_get();
struct codec_shape bcast_shape;
if (bcast == NULL || codec_shape_get(&bcast->codec_cfg, &bcast_shape) != 0) {
ESP_LOGE(TAG, "Cannot read broadcast codec configuration");
return -EINVAL;
}
for (size_t i = 0; i < SINK_STREAM_COUNT; i++) {
const esp_ble_audio_bap_lc3_preset_t *uni = sink_preset_get(i);
struct codec_shape uni_shape;
if (uni == NULL || codec_shape_get(&uni->codec_cfg, &uni_shape) != 0) {
ESP_LOGE(TAG, "Cannot read sink %u codec configuration", (unsigned)i);
return -EINVAL;
}
if (!codec_shape_eq(&uni_shape, &bcast_shape)) {
ESP_LOGE(TAG, "Codec config differs on sink %u: "
"unicast freq %d dur %d octets %u blocks %u vs "
"broadcast freq %d dur %d octets %u blocks %u",
(unsigned)i,
uni_shape.freq, uni_shape.frame_dur,
uni_shape.octets_per_frame, uni_shape.frame_blocks,
bcast_shape.freq, bcast_shape.frame_dur,
bcast_shape.octets_per_frame, bcast_shape.frame_blocks);
return -EINVAL;
}
/* Same SDU rate and framing, or the paths cannot carry the same frame
* sequence. ISO_Interval is the controller's and only observable once
* the streams run - cap_handover_diag_sample() prints it.
*/
if (uni->qos.interval != bcast->qos.interval ||
uni->qos.framing != bcast->qos.framing) {
ESP_LOGE(TAG, "QoS differs on sink %u: unicast interval %lu framing %d vs "
"broadcast interval %lu framing %d", (unsigned)i,
(unsigned long)uni->qos.interval, uni->qos.framing,
(unsigned long)bcast->qos.interval, bcast->qos.framing);
return -EINVAL;
}
/* PD may legitimately differ: it is how a rendering-point offset would
* be absorbed. Nothing uses that yet, so warn rather than fail.
*/
if (uni->qos.pd != bcast->qos.pd) {
ESP_LOGW(TAG, "Presentation delay differs on sink %u: %lu vs %lu us",
(unsigned)i, (unsigned long)uni->qos.pd, (unsigned long)bcast->qos.pd);
}
}
return 0;
}
static int handover_unicast_to_broadcast(void)
{
esp_ble_audio_cap_handover_unicast_to_broadcast_param_t param = {0};
uint8_t *ext_data = NULL;
uint8_t ext_len = 0;
size_t count = 0;
int err;
err = handover_audio_config_check();
if (err) {
return err;
}
/* All of them: CAP 7.3.1.10 applies to every CIS carrying Initiator-to-Acceptor audio. */
for (size_t i = 0; i < peer.sink_ep_count && count < ARRAY_SIZE(bcast_stream_params); i++) {
esp_ble_audio_cap_stream_t *stream = &peer.sink_streams[i];
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_ble_audio_location_t loc;
if (stream->bap_stream.ep == NULL ||
esp_ble_audio_bap_ep_get_info(stream->bap_stream.ep, &ep_info) != ESP_OK ||
ep_info.state != ESP_BLE_AUDIO_BAP_EP_STATE_STREAMING) {
continue;
}
/* Shared subgroup codec cfg, so the channel allocation goes per BIS. */
loc = broadcast_bis_location_get(count);
bcast_bis_data[count][0] = 5;
bcast_bis_data[count][1] = ESP_BLE_AUDIO_CODEC_CFG_CHAN_ALLOC;
bcast_bis_data[count][2] = (uint8_t)loc;
bcast_bis_data[count][3] = (uint8_t)(loc >> 8);
bcast_bis_data[count][4] = (uint8_t)(loc >> 16);
bcast_bis_data[count][5] = (uint8_t)(loc >> 24);
bcast_stream_params[count].stream = stream;
bcast_stream_params[count].data = bcast_bis_data[count];
bcast_stream_params[count].data_len = sizeof(bcast_bis_data[count]);
count++;
}
if (count == 0) {
ESP_LOGW(TAG, "No streaming sink stream to hand over");
return -ENODEV;
}
bcast_subgroup_param.stream_count = count;
bcast_subgroup_param.stream_params = bcast_stream_params;
bcast_subgroup_param.codec_cfg = &broadcast_preset_get()->codec_cfg;
bcast_create_param.subgroup_count = 1;
bcast_create_param.subgroup_params = &bcast_subgroup_param;
bcast_create_param.qos = &broadcast_preset_get()->qos;
/* PA stays stopped: the BASE only exists once the procedure created the source. */
ext_data = broadcast_ext_adv_data_get(&ext_len);
if (ext_data == NULL) {
ESP_LOGE(TAG, "No adv data, cannot hand over to broadcast");
return -ENOMEM;
}
err = ext_adv_start_without_base(ext_data, ext_len);
free(ext_data);
if (err) {
ESP_LOGE(TAG, "Failed to start advertising for handover, err %d", err);
return err;
}
if (adv_registered == false) {
adv_info.adv_handle = ADV_HANDLE;
adv_info.addr_type = 0; /* public */
adv_info.sid = ADV_SID;
if (local_public_addr_get(adv_info.addr) != 0) {
ESP_LOGE(TAG, "No local address for the BASS Add Source");
return -EIO;
}
err = esp_ble_audio_bap_broadcast_adv_add(&adv_info);
if (err) {
ESP_LOGE(TAG, "Failed to add adv for broadcast source, err %d", err);
return err;
}
adv_registered = true;
}
param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
param.unicast_group = unicast_group_get();
param.adv_handle = ADV_HANDLE;
param.pa_interval = HANDOVER_PA_INTERVAL;
param.broadcast_id = LOCAL_BROADCAST_ID;
param.broadcast_create_param = &bcast_create_param;
ESP_LOGI(TAG, "Handover: unicast -> broadcast (%u stream(s))", (unsigned)count);
cap_handover_diag_sample("u2b-pre", true);
err = esp_ble_audio_cap_handover_unicast_to_broadcast(&param);
if (err) {
ESP_LOGE(TAG, "Failed to hand over to broadcast, err %d", err);
return err;
}
return 0;
}
static int handover_broadcast_to_unicast(void)
{
esp_ble_audio_cap_handover_broadcast_to_unicast_param_t param = {0};
size_t count = 0;
int err;
if (recv_state_valid == false) {
ESP_LOGW(TAG, "Acceptor has no receive state for our broadcast");
return -ENODEV;
}
/* Must be the broadcast source's own streams. Sink only: a BIS has no return path. */
for (size_t i = 0; i < peer.sink_ep_count && count < ARRAY_SIZE(uni_stream_params); i++) {
esp_ble_audio_bap_lc3_preset_t *preset = sink_preset_get(i);
if (preset == NULL || peer.sink_eps[i] == NULL) {
continue;
}
uni_stream_params[count].qos_cfg = &preset->qos;
uni_stream_params[count].stream = &peer.sink_streams[i];
uni_pair_params[count].rx_param = NULL;
uni_pair_params[count].tx_param = &uni_stream_params[count];
uni_start_stream_params[count].member.member = peer.conn;
uni_start_stream_params[count].stream = &peer.sink_streams[i];
uni_start_stream_params[count].ep = peer.sink_eps[i];
uni_start_stream_params[count].codec_cfg = &preset->codec_cfg;
count++;
}
if (count == 0) {
ESP_LOGW(TAG, "No endpoint to hand back to unicast");
return -ENODEV;
}
uni_group_param.params_count = count;
uni_group_param.params = uni_pair_params;
uni_start_param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
uni_start_param.count = count;
uni_start_param.stream_params = uni_start_stream_params;
stop_member_param.member.member = peer.conn;
stop_member_param.src_id = recv_state_src_id;
stop_member_param.num_subgroups = bcast_create_param.subgroup_count;
stop_param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
stop_param.param = &stop_member_param;
stop_param.count = 1;
/* Documented as ignored once reception_stop_param is set, but the procedure
* still matches receive state notifications against this triple. Leaving it
* zero stalls the handover after the broadcast source stops.
*/
param.broadcast_id = LOCAL_BROADCAST_ID;
param.adv_sid = ADV_SID;
param.adv_type = 0; /* public */
param.reception_stop_param = &stop_param;
param.broadcast_source = broadcast_source;
param.unicast_group_param = &uni_group_param;
param.unicast_start_param = &uni_start_param;
ESP_LOGI(TAG, "Handover: broadcast -> unicast (%u stream(s))", (unsigned)count);
cap_handover_diag_sample("b2u-pre", true);
err = esp_ble_audio_cap_handover_broadcast_to_unicast(&param);
if (err) {
ESP_LOGE(TAG, "Failed to hand back to unicast, err %d", err);
return err;
}
/* Stop feeding the broadcast now, not from the stream callbacks: the ISO
* channel goes down with the BIG terminate, but the disconnected callback
* trails the controller's event by ~100 ms and every SDU in between is
* rejected. Unicast re-registers from its own started callback. The other
* direction does not need this - ASCS delivers CIS callbacks promptly.
*/
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
(void)cap_handover_tx_unregister_stream(&peer.sink_streams[i]);
}
return 0;
}
static void switch_timer_cb(void *arg)
{
int err;
if (proc_active) {
/* CAP runs one procedure at a time; retry on the next tick. */
switch_timer_arm();
return;
}
if (peer.conn == NULL) {
return;
}
proc_active = true;
err = on_broadcast ? handover_broadcast_to_unicast()
: handover_unicast_to_broadcast();
if (err) {
proc_active = false;
switch_timer_arm();
}
}
static void unicast_to_broadcast_created_cb(esp_ble_audio_cap_broadcast_source_t *source)
{
uint8_t *per_data;
uint8_t per_len = 0;
int err;
/* The source exists now, so publish its BASE before the Acceptor syncs. */
per_data = broadcast_base_data_get(source, &per_len);
if (per_data == NULL) {
ESP_LOGE(TAG, "Failed to build BASE for handover");
return;
}
err = per_adv_data_start(per_data, per_len);
free(per_data);
if (err) {
ESP_LOGE(TAG, "Failed to publish BASE, err %d", err);
}
}
static void unicast_to_broadcast_complete_cb(int err, esp_ble_conn_t *conn,
esp_ble_audio_cap_unicast_group_t *group,
esp_ble_audio_cap_broadcast_source_t *source)
{
/* On success the procedure deleted the unicast group. */
unicast_group_set(group);
broadcast_source = source;
proc_active = false;
if (err) {
ESP_LOGE(TAG, "Handover to broadcast failed, err %d", err);
/* The group is already gone, so a failure leaves us broadcasting to
* nobody. The stop callback deletes the source and restores unicast.
*/
if (source != NULL &&
esp_ble_audio_cap_initiator_broadcast_audio_stop(source) != 0) {
ESP_LOGE(TAG, "Failed to stop broadcast source");
}
} else {
on_broadcast = true;
ESP_LOGI(TAG, "Handover to broadcast completed");
/* BIG terms are readable now; its timestamp is not, until the first
* SDU. The next b2u-pre sample catches that side with real data.
*/
cap_handover_diag_sample("u2b-post", false);
}
switch_timer_arm();
}
static void broadcast_to_unicast_complete_cb(int err, esp_ble_conn_t *conn,
esp_ble_audio_cap_broadcast_source_t *source,
esp_ble_audio_cap_unicast_group_t *group)
{
/* On success the procedure deleted the broadcast source. */
unicast_group_set(group);
broadcast_source = source;
proc_active = false;
if (err) {
ESP_LOGE(TAG, "Handover to unicast failed, err %d", err);
} else {
on_broadcast = false;
/* Nothing left to announce: drop the announcement and the periodic
* advertising carrying its BASE before declaring the handover done.
*/
if (adv_stop() != 0) {
ESP_LOGW(TAG, "Failed to stop advertising after handover");
}
ESP_LOGI(TAG, "Handover to unicast completed");
/* Mirror of the above: CIS terms readable, timestamp not yet. */
cap_handover_diag_sample("b2u-post", false);
}
switch_timer_arm();
}
static esp_ble_audio_cap_handover_cb_t handover_cb = {
.unicast_to_broadcast_created = unicast_to_broadcast_created_cb,
.unicast_to_broadcast_complete = unicast_to_broadcast_complete_cb,
.broadcast_to_unicast_complete = broadcast_to_unicast_complete_cb,
};
/* Discovery only subscribes, so read the receive states once to see what the
* Acceptor already holds. They outlive our reboot, and BAP 6.5.4 rejects an Add
* Source duplicating the {address, SID, Broadcast_ID} triple - an entry left by
* an earlier run of ours would fail every handover.
*/
static void recv_state_sweep_next(void)
{
while (recv_state_next < recv_state_cnt) {
uint8_t idx = recv_state_next++;
int err;
err = esp_ble_audio_bap_broadcast_assistant_read_recv_state(peer.conn_handle, idx);
if (err == 0) {
return; /* Continues in assistant_recv_state_cb */
}
ESP_LOGW(TAG, "Failed to read receive state %u, err %d", idx, err);
}
recv_state_sweeping = false;
unicast_start_defer();
}
static void assistant_discover_cb(esp_ble_conn_t *conn, int err, uint8_t recv_state_count)
{
if (err) {
ESP_LOGE(TAG, "BASS discovery failed, err %d", err);
return;
}
ESP_LOGI(TAG, "BASS discovered (%u receive state(s))", recv_state_count);
recv_state_cnt = recv_state_count;
recv_state_next = 0;
recv_state_sweeping = true;
recv_state_sweep_next();
}
static void assistant_recv_state_cb(esp_ble_conn_t *conn, int err,
const esp_ble_audio_bap_scan_delegator_recv_state_t *state)
{
bool ours = (err == 0 && state != NULL && state->broadcast_id == LOCAL_BROADCAST_ID);
uint32_t bis_sync = 0;
if (recv_state_sweeping) {
if (ours) {
ESP_LOGW(TAG, "Removing leftover receive state (src_id %u)", state->src_id);
if (esp_ble_audio_bap_broadcast_assistant_rem_src(peer.conn_handle,
state->src_id) == 0) {
return; /* Continues in assistant_rem_src_cb */
}
ESP_LOGE(TAG, "Failed to remove leftover receive state");
}
recv_state_sweep_next();
return;
}
if (!ours) {
return;
}
recv_state_src_id = state->src_id;
recv_state_valid = true;
/* The Acceptor notifies per field change, so the same pa_sync arrives more
* than once (PA synced, then BIG synced). bis_sync tells them apart, and is
* also what says whether audio is flowing.
*/
for (uint8_t i = 0; i < state->num_subgroups; i++) {
bis_sync |= state->subgroups[i].bis_sync;
}
ESP_LOGI(TAG, "Acceptor receive state: src_id %u pa_sync %u bis_sync 0x%08lx",
state->src_id, state->pa_sync_state, (unsigned long)bis_sync);
/* The Acceptor asks for the periodic advertising train it was told about.
* We are the advertiser, so hand it over instead of making it scan.
*/
if (state->pa_sync_state == ESP_BLE_AUDIO_BAP_PA_STATE_INFO_REQ) {
/* The Source ID goes in the high octet of the service data; the Acceptor
* reads it from there to match the transfer to a receive state. */
err = pa_set_info_transfer(peer.conn_handle, peer.dst,
(uint16_t)state->src_id << 8);
if (err) {
ESP_LOGE(TAG, "Failed to transfer PA sync info, err %d", err);
} else {
ESP_LOGI(TAG, "PA sync info transferred (src_id %u)", state->src_id);
}
}
}
static void assistant_recv_state_removed_cb(esp_ble_conn_t *conn, uint8_t src_id)
{
if (recv_state_valid && src_id == recv_state_src_id) {
recv_state_valid = false;
}
}
static void assistant_rem_src_cb(esp_ble_conn_t *conn, int err)
{
if (err) {
ESP_LOGE(TAG, "Remove source failed, err %d", err);
}
if (recv_state_sweeping) {
recv_state_sweep_next();
}
}
static esp_ble_audio_bap_broadcast_assistant_cb_t assistant_cb = {
.discover = assistant_discover_cb,
.recv_state = assistant_recv_state_cb,
.recv_state_removed = assistant_recv_state_removed_cb,
.rem_src = assistant_rem_src_cb,
};
int cap_handover_proc_discover(uint16_t conn_handle)
{
int err;
err = esp_ble_audio_bap_broadcast_assistant_discover(conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to discover BASS, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering BASS");
return 0;
}
void cap_handover_proc_unicast_started(void)
{
if (on_broadcast) {
return;
}
switch_timer_arm();
}
void cap_handover_proc_broadcast_stopped(esp_ble_audio_cap_broadcast_source_t *source)
{
if (source != broadcast_source) {
return;
}
/* A source is only deletable once its BIG is down, so finish it here. */
if (esp_ble_audio_cap_initiator_broadcast_audio_delete(source) != 0) {
ESP_LOGE(TAG, "Failed to delete broadcast source");
return;
}
broadcast_source = NULL;
on_broadcast = false;
(void)adv_stop();
/* A failed handover left the ASEs released; restore unicast while connected. */
if (peer.conn != NULL) {
unicast_start_defer();
}
}
void cap_handover_proc_reset(void)
{
if (switch_timer != NULL) {
(void)esp_timer_stop(switch_timer);
}
if (start_timer != NULL) {
(void)esp_timer_stop(start_timer);
}
if (broadcast_source != NULL) {
/* Deleted by cap_handover_proc_broadcast_stopped once the BIG is down. */
if (esp_ble_audio_cap_initiator_broadcast_audio_stop(broadcast_source) != 0) {
ESP_LOGE(TAG, "Failed to stop broadcast source");
}
}
proc_active = false;
on_broadcast = false;
recv_state_valid = false;
recv_state_sweeping = false;
}
int cap_handover_proc_init(void)
{
const esp_timer_create_args_t timer_args = {
.callback = switch_timer_cb,
.name = "cap_handover",
};
const esp_timer_create_args_t start_timer_args = {
.callback = start_timer_cb,
.name = "cap_uni_start",
};
int err;
err = esp_ble_audio_cap_handover_register_cb(&handover_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register handover callbacks, err %d", err);
return err;
}
err = esp_ble_audio_bap_broadcast_assistant_register_cb(&assistant_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register broadcast assistant callbacks, err %d", err);
return err;
}
err = esp_timer_create(&timer_args, &switch_timer);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to create handover timer, err %d", err);
return err;
}
err = esp_timer_create(&start_timer_args, &start_timer);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to create unicast start timer, err %d", err);
return err;
}
ESP_LOGI(TAG, "CAP initiator handover initialized (period %u s)",
(unsigned)CONFIG_EXAMPLE_HANDOVER_PERIOD_S);
return 0;
}
@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "cap_handover.h"
/* The same stream objects move between unicast and broadcast, so one slot each. */
static struct tx_stream tx_streams[SINK_STREAM_COUNT];
static const char *cap_stream_tx_label(const esp_ble_audio_cap_stream_t *cap_stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
if (cap_stream == NULL || cap_stream->bap_stream.ep == NULL) {
return "SRC";
}
if (esp_ble_audio_bap_ep_get_info(cap_stream->bap_stream.ep, &ep_info) != 0) {
return "SRC";
}
return (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) ? "SNK" : "SRC";
}
static bool tx_stream_is_streaming(const struct tx_stream *tx_stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
int err;
if (tx_stream == NULL || tx_stream->stream == NULL) {
return false;
}
/* Broadcast streams have no ep; registration from started_cb implies streaming. */
if (tx_stream->is_broadcast) {
return true;
}
if (tx_stream->stream->bap_stream.ep == NULL) {
return false;
}
err = esp_ble_audio_bap_ep_get_info(tx_stream->stream->bap_stream.ep, &ep_info);
if (err) {
return false;
}
return (ep_info.state == ESP_BLE_AUDIO_BAP_EP_STATE_STREAMING);
}
static void cap_handover_tx_send(struct tx_stream *tx_stream)
{
const char *label;
int err;
if (tx_stream == NULL || tx_stream->stream == NULL) {
return;
}
if (tx_stream_is_streaming(tx_stream) == false) {
return;
}
label = cap_stream_tx_label(tx_stream->stream);
if (tx_stream->stream->bap_stream.qos == NULL ||
tx_stream->stream->bap_stream.qos->sdu == 0) {
ESP_LOGE(TAG, "[%s] Invalid QoS", label);
return;
}
if (tx_stream->data == NULL) {
ESP_LOGE(TAG, "[%s] Buffer unavailable (SDU %u)",
label, tx_stream->stream->bap_stream.qos->sdu);
return;
}
memset(tx_stream->data, (uint8_t)tx_stream->seq_num, tx_stream->stream->bap_stream.qos->sdu);
err = esp_ble_audio_cap_stream_send(tx_stream->stream, tx_stream->data,
tx_stream->stream->bap_stream.qos->sdu,
tx_stream->seq_num);
if (err) {
ESP_LOGD(TAG, "[%s] send failed, err %d", label, err);
return;
}
tx_stream->seq_num++;
}
void cap_handover_tx_stream_sent(esp_ble_audio_bap_stream_t *stream, void *user_data)
{
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream && &tx_streams[i].stream->bap_stream == stream) {
char name[24];
snprintf(name, sizeof(name), "%s #%zu",
cap_stream_tx_label(tx_streams[i].stream), i);
example_audio_tx_scheduler_on_sent(&tx_streams[i].scheduler, user_data, TAG, name);
break;
}
}
}
static void tx_scheduler_cb(void *arg)
{
struct tx_stream *tx_stream = arg;
cap_handover_tx_send(tx_stream);
}
int cap_handover_tx_register_stream(esp_ble_audio_cap_stream_t *cap_stream, bool is_broadcast)
{
int err;
if (cap_stream == NULL) {
return -EINVAL;
}
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream == NULL) {
const char *label = cap_stream_tx_label(cap_stream);
if (cap_stream->bap_stream.qos == NULL || cap_stream->bap_stream.qos->sdu == 0) {
ESP_LOGE(TAG, "[%s #%zu] Invalid QoS", label, i);
return -EINVAL;
}
if (tx_streams[i].data == NULL) {
tx_streams[i].data = calloc(1, cap_stream->bap_stream.qos->sdu);
if (tx_streams[i].data == NULL) {
ESP_LOGE(TAG, "[%s #%zu] Failed to alloc buffer (SDU %u)",
label, i, cap_stream->bap_stream.qos->sdu);
return -ENOMEM;
}
}
tx_streams[i].stream = cap_stream;
tx_streams[i].is_broadcast = is_broadcast;
tx_streams[i].seq_num = 0;
example_audio_tx_scheduler_reset(&tx_streams[i].scheduler);
err = example_audio_tx_scheduler_start(&tx_streams[i].scheduler, cap_stream->bap_stream.qos->interval);
if (err) {
ESP_LOGE(TAG, "[%s #%zu] Scheduler start failed, err %d",
label, i, err);
tx_streams[i].stream = NULL;
tx_streams[i].is_broadcast = false;
return err;
}
ESP_LOGI(TAG, "[%s #%zu] Started (SDU %u, interval %u us)",
label, i,
cap_stream->bap_stream.qos->sdu,
cap_stream->bap_stream.qos->interval);
cap_handover_tx_send(&tx_streams[i]);
return 0;
}
}
ESP_LOGE(TAG, "No free TX stream slot");
return -ENOMEM;
}
int cap_handover_tx_unregister_stream(esp_ble_audio_cap_stream_t *cap_stream)
{
int err;
if (cap_stream == NULL) {
return -EINVAL;
}
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
if (tx_streams[i].stream == cap_stream) {
const char *label = cap_stream_tx_label(tx_streams[i].stream);
err = example_audio_tx_scheduler_stop(&tx_streams[i].scheduler);
if (err) {
ESP_LOGE(TAG, "[%s #%zu] Scheduler stop failed, err %d",
label, i, err);
return err;
}
tx_streams[i].stream = NULL;
tx_streams[i].is_broadcast = false;
if (tx_streams[i].data != NULL) {
free(tx_streams[i].data);
tx_streams[i].data = NULL;
}
ESP_LOGI(TAG, "[%s #%zu] Stopped", label, i);
return 0;
}
}
return -ENODATA;
}
void cap_handover_tx_init(void)
{
int err;
memset(tx_streams, 0, sizeof(tx_streams));
for (size_t i = 0; i < ARRAY_SIZE(tx_streams); i++) {
err = example_audio_tx_scheduler_init(&tx_streams[i].scheduler,
tx_scheduler_cb,
&tx_streams[i]);
if (err) {
ESP_LOGE(TAG, "Failed to initialize tx scheduler[%zu], err %d", i, err);
return;
}
}
}
@@ -0,0 +1,693 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include <assert.h>
#include "cap_handover.h"
/* One preset per sink stream: each owns its codec cfg buffer, which is what
* lets the streams carry different channel allocations.
*/
HANDOVER_LC3_PRESET_DEFINE(unicast_preset_left, ESP_BLE_AUDIO_LOCATION_FRONT_LEFT);
HANDOVER_LC3_PRESET_DEFINE(unicast_preset_right, ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT);
static esp_ble_audio_bap_lc3_preset_t *const sink_presets[] = {
&unicast_preset_left,
&unicast_preset_right,
};
_Static_assert(ARRAY_SIZE(sink_presets) >= SINK_STREAM_COUNT,
"Need one preset (one channel allocation) per sink stream");
static esp_ble_audio_cap_unicast_group_t *unicast_group;
struct peer_config peer = {
.conn_handle = CONN_HANDLE_INIT,
};
esp_ble_audio_cap_unicast_group_t *unicast_group_get(void)
{
return unicast_group;
}
void unicast_group_set(esp_ble_audio_cap_unicast_group_t *group)
{
unicast_group = group;
}
esp_ble_audio_bap_lc3_preset_t *sink_preset_get(size_t idx)
{
return (idx < ARRAY_SIZE(sink_presets)) ? sink_presets[idx] : NULL;
}
static const char *dir_str(esp_ble_audio_dir_t dir)
{
return dir == ESP_BLE_AUDIO_DIR_SINK ? "SNK" : "SRC";
}
static const char *stream_dir_str(const esp_ble_audio_bap_stream_t *stream)
{
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return "SNK";
}
}
return "???";
}
static int stream_index(const esp_ble_audio_bap_stream_t *stream)
{
/* Index within its own direction's pool, so logs read "SNK #0" / "SNK #1". */
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
if (stream == &peer.sink_streams[i].bap_stream) {
return (int)i;
}
}
return -1;
}
/* Ask whether the endpoint transmits; do not derive it from dir. These streams
* migrate, and dir means opposite things on the two endpoint kinds: the peer's
* direction on a unicast ASE (SINK), our own on a broadcast source (SOURCE).
* Testing dir == SINK therefore goes silent the moment a stream moves to a BIS.
*/
static bool is_tx_stream(esp_ble_audio_bap_stream_t *stream)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_err_t err;
/* Detached already: only teardown sees this, and unregistering is a no-op. */
if (stream->ep == NULL) {
return true;
}
err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info);
if (err) {
ESP_LOGE(TAG, "Failed to get ep info, err %d", err);
return false;
}
return ep_info.can_send;
}
static void unicast_stream_configured_cb(esp_ble_audio_bap_stream_t *stream,
const esp_ble_audio_bap_qos_cfg_pref_t *pref)
{
ESP_LOGI(TAG, "[%s #%d] Stream configured, QoS preference:",
stream_dir_str(stream), stream_index(stream));
example_print_qos_pref(TAG, pref);
}
static void unicast_stream_qos_set_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] QoS set",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_enabled_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream enabled",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream)
{
esp_ble_audio_cap_stream_t *cap_stream;
int err;
ESP_LOGI(TAG, "[%s #%d] Stream started",
stream_dir_str(stream), stream_index(stream));
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
err = cap_handover_tx_register_stream(cap_stream, false);
if (err) {
ESP_LOGE(TAG, "[%s #%d] Failed to register TX, err %d",
stream_dir_str(stream), stream_index(stream), err);
}
}
}
static void unicast_stream_metadata_updated_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Metadata updated",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_disabled_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream disabled",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_stopped_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[%s #%d] Stream stopped, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
(void)cap_handover_tx_unregister_stream(cap_stream);
}
}
static void unicast_stream_disconnected_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
esp_ble_audio_cap_stream_t *cap_stream;
ESP_LOGI(TAG, "[%s #%d] ISO disconnected, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
if (is_tx_stream(stream)) {
cap_stream = CONTAINER_OF(stream, esp_ble_audio_cap_stream_t, bap_stream);
(void)cap_handover_tx_unregister_stream(cap_stream);
}
}
static void unicast_stream_released_cb(esp_ble_audio_bap_stream_t *stream)
{
ESP_LOGI(TAG, "[%s #%d] Stream released",
stream_dir_str(stream), stream_index(stream));
}
static void unicast_stream_sent_cb(esp_ble_audio_bap_stream_t *stream, void *user_data)
{
cap_handover_tx_stream_sent(stream, user_data);
}
static esp_ble_audio_bap_stream_ops_t unicast_stream_ops = {
.configured = unicast_stream_configured_cb,
.qos_set = unicast_stream_qos_set_cb,
.enabled = unicast_stream_enabled_cb,
.started = unicast_stream_started_cb,
.metadata_updated = unicast_stream_metadata_updated_cb,
.disabled = unicast_stream_disabled_cb,
.stopped = unicast_stream_stopped_cb,
.released = unicast_stream_released_cb,
.sent = unicast_stream_sent_cb,
.disconnected = unicast_stream_disconnected_cb,
};
static int discover_cas(void)
{
int err;
err = esp_ble_audio_cap_initiator_unicast_discover(peer.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to discover CAS, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering CAS");
return 0;
}
static int discover_sinks(void)
{
int err;
for (size_t i = 0; i < ARRAY_SIZE(peer.sink_streams); i++) {
esp_ble_audio_cap_stream_ops_register(&peer.sink_streams[i], &unicast_stream_ops);
}
err = esp_ble_audio_bap_unicast_client_discover(peer.conn_handle, ESP_BLE_AUDIO_DIR_SINK);
if (err) {
ESP_LOGE(TAG, "Failed to discover sinks, err %d", err);
return err;
}
ESP_LOGI(TAG, "Discovering sinks");
return 0;
}
static int unicast_group_create(void)
{
/* Referenced by the group while it exists, so they outlive this call. */
static esp_ble_audio_cap_unicast_group_stream_param_t sink_stream_params[SINK_STREAM_COUNT];
static esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params[SINK_STREAM_COUNT];
esp_ble_audio_cap_unicast_group_param_t group_param = {0};
size_t pair_count = 0;
int err;
/* One CIS per sink stream. */
for (size_t i = 0; i < peer.sink_ep_count; i++) {
sink_stream_params[i].qos_cfg = &sink_presets[i]->qos;
sink_stream_params[i].stream = &peer.sink_streams[i];
pair_params[pair_count].rx_param = NULL;
pair_params[pair_count].tx_param = &sink_stream_params[i];
pair_count++;
}
if (pair_count == 0) {
ESP_LOGW(TAG, "No endpoints available, skip creating unicast group");
return -ENODEV;
}
group_param.params_count = pair_count;
group_param.params = pair_params;
err = esp_ble_audio_cap_unicast_group_create(&group_param, &unicast_group);
if (err) {
ESP_LOGE(TAG, "Failed to create unicast group, err %d", err);
return err;
}
ESP_LOGI(TAG, "Created unicast group");
return 0;
}
int unicast_group_delete(void)
{
int err;
if (unicast_group == NULL) {
return 0;
}
err = esp_ble_audio_cap_unicast_group_delete(unicast_group);
if (err) {
ESP_LOGE(TAG, "Failed to delete unicast group, err %d", err);
return err;
}
unicast_group = NULL;
ESP_LOGI(TAG, "Deleted unicast group");
return 0;
}
static int unicast_audio_start(void)
{
/* codec_cfg has to stay valid while the stream is non-idle. */
static esp_ble_audio_cap_unicast_audio_start_stream_param_t stream_param[SINK_STREAM_COUNT];
esp_ble_audio_cap_unicast_audio_start_param_t param = {0};
int err;
for (size_t i = 0; i < peer.sink_ep_count; i++) {
stream_param[param.count].member.member = peer.conn;
stream_param[param.count].stream = &peer.sink_streams[i];
stream_param[param.count].ep = peer.sink_eps[i];
stream_param[param.count].codec_cfg = &sink_presets[i]->codec_cfg;
param.count++;
}
if (param.count == 0) {
ESP_LOGW(TAG, "No endpoints available, skip starting unicast audio");
return 0;
}
param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC;
param.stream_params = stream_param;
err = esp_ble_audio_cap_initiator_unicast_audio_start(&param);
if (err) {
ESP_LOGE(TAG, "Failed to start unicast audio, err %d", err);
return err;
}
ESP_LOGI(TAG, "Starting unicast streams");
return 0;
}
int cap_handover_unicast_setup_and_start(void)
{
int err;
err = unicast_group_create();
if (err) {
return err;
}
err = unicast_audio_start();
if (err) {
unicast_group_delete();
return err;
}
return 0;
}
static void discover_cb(esp_ble_conn_t *conn, int err, esp_ble_audio_dir_t dir)
{
if (conn->handle != peer.conn_handle) {
return;
}
peer.conn = conn;
/* Sink only: a broadcast Audio Stream has no return path, so the Acceptor's
* source ASEs take no part in a handover.
*/
if (dir != ESP_BLE_AUDIO_DIR_SINK) {
return;
}
if (err) {
ESP_LOGE(TAG, "Discovery sinks failed, err %d", err);
return;
}
ESP_LOGI(TAG, "Discover sinks complete");
/* The collocated Commander writes the broadcast source into the Acceptor's
* BASS, so discover that too. Its callback continues the unicast setup.
*/
(void)cap_handover_proc_discover(peer.conn_handle);
}
static void pac_record_cb(esp_ble_conn_t *conn,
esp_ble_audio_dir_t dir,
const esp_ble_audio_codec_cap_t *codec_cap)
{
example_print_codec_cap(TAG, codec_cap);
}
static void endpoint_cb(esp_ble_conn_t *conn,
esp_ble_audio_dir_t dir,
esp_ble_audio_bap_ep_t *ep)
{
/* Keep every sink endpoint: a stereo Acceptor exposes one per channel and
* they are handed over together.
*/
if (dir == ESP_BLE_AUDIO_DIR_SINK &&
peer.sink_ep_count < ARRAY_SIZE(peer.sink_eps)) {
ESP_LOGI(TAG, "[%s #%zu] Endpoint discovered", dir_str(dir), peer.sink_ep_count);
peer.sink_eps[peer.sink_ep_count++] = ep;
}
}
static esp_ble_audio_bap_unicast_client_cb_t unicast_client_cbs = {
.discover = discover_cb,
.pac_record = pac_record_cb,
.endpoint = endpoint_cb,
};
static void unicast_discovery_complete_cb(esp_ble_conn_t *conn, int err,
const esp_ble_audio_csip_set_coordinator_set_member_t *member,
const esp_ble_audio_csip_set_coordinator_csis_inst_t *csis_inst)
{
if (err) {
ESP_LOGE(TAG, "Unicast discovery completed, err %d", err);
return;
}
if (IS_ENABLED(CONFIG_BT_CAP_ACCEPTOR_SET_MEMBER)) {
if (csis_inst == NULL) {
ESP_LOGW(TAG, "Failed to discover CAS CSIS");
return;
}
ESP_LOGI(TAG, "Found CAS with CSIS");
/* TODO: Do set member discovery */
} else {
ESP_LOGI(TAG, "Found CAS");
}
(void)discover_sinks();
}
static void unicast_start_complete_cb(int err, esp_ble_conn_t *conn)
{
if (err) {
ESP_LOGE(TAG, "Unicast start completed, err %d", err);
return;
}
ESP_LOGI(TAG, "Unicast start completed");
cap_handover_proc_unicast_started();
}
static void broadcast_stopped_cb(esp_ble_audio_cap_broadcast_source_t *source, uint8_t reason)
{
ESP_LOGI(TAG, "Broadcast source stopped, reason 0x%02x", reason);
cap_handover_proc_broadcast_stopped(source);
}
static esp_ble_audio_cap_initiator_cb_t cap_cb = {
.unicast_discovery_complete = unicast_discovery_complete_cb,
.unicast_start_complete = unicast_start_complete_cb,
.broadcast_stopped = broadcast_stopped_cb,
};
static bool check_and_connect(uint8_t type, const uint8_t *data,
uint8_t data_len, void *user_data)
{
esp_ble_audio_gap_app_event_t *event;
uint16_t uuid_val;
int err;
event = user_data;
assert(event);
if (type != EXAMPLE_AD_TYPE_SERVICE_DATA16) {
return true; /* Continue parsing to next AD data type */
}
if (data_len < sizeof(uuid_val)) {
ESP_LOGW(TAG, "Invalid ad size %u (cas uuid)", data_len);
return true; /* Continue parsing to next AD data type */
}
uuid_val = sys_get_le16(data);
if (uuid_val != ESP_BLE_AUDIO_UUID_CAS_VAL) {
/* We are looking for the TMAS service data */
return true; /* Continue parsing to next AD data type */
}
ESP_LOGI(TAG, "Found CAS in peer adv data!");
/* Stop scanning before connect — NimBLE rejects ble_gap_connect while
* a discovery procedure is running. On failure restart scanning so we
* don't stall in no-scan no-conn state. */
err = ext_scan_stop();
if (err) {
ESP_LOGE(TAG, "Failed to stop scanning, err %d", err);
return false;
}
err = conn_create(event->ext_scan_recv.addr.type,
event->ext_scan_recv.addr.val);
if (err) {
ESP_LOGE(TAG, "Failed to create conn, err %d", err);
cap_handover_unicast_start();
}
return false; /* Stop parsing */
}
static void ext_scan_recv(esp_ble_audio_gap_app_event_t *event)
{
if (peer.conn_handle != CONN_HANDLE_INIT) {
return;
}
/* Check if the advertising is connectable and if TMAS is supported */
if (event->ext_scan_recv.event_type & EXAMPLE_ADV_PROP_CONNECTABLE) {
esp_ble_audio_data_parse(event->ext_scan_recv.data,
event->ext_scan_recv.data_len,
check_and_connect, (void *)event);
}
}
static void acl_connect(esp_ble_audio_gap_app_event_t *event)
{
int err;
if (event->acl_connect.status) {
ESP_LOGE(TAG, "Connection failed, status %d", event->acl_connect.status);
/* Scanning was stopped before conn_create and acl_disconnect only fires on
* an established connection, so resume here or nothing runs. */
cap_handover_unicast_start();
return;
}
ESP_LOGI(TAG, "Connected: handle %u role %u peer %02x:%02x:%02x:%02x:%02x:%02x",
event->acl_connect.conn_handle, event->acl_connect.role,
EXAMPLE_BT_ADDR_PRINT_ARGS(event->acl_connect.dst.val));
peer.conn_handle = event->acl_connect.conn_handle;
memcpy(peer.dst, event->acl_connect.dst.val, sizeof(peer.dst));
err = pairing_start(event->acl_connect.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to initiate security, err %d", err);
return;
}
}
static void acl_disconnect(esp_ble_audio_gap_app_event_t *event)
{
ESP_LOGI(TAG, "Disconnected: handle %u reason 0x%02x",
event->acl_disconnect.conn_handle, event->acl_disconnect.reason);
peer.conn_handle = CONN_HANDLE_INIT;
peer.conn = NULL;
memset(peer.dst, 0, sizeof(peer.dst));
memset(peer.sink_eps, 0, sizeof(peer.sink_eps));
peer.sink_ep_count = 0;
peer.disc_completed = false;
peer.mtu_exchanged = false;
/* Drop a broadcast source left running before reusing the stream objects. */
cap_handover_proc_reset();
unicast_group_delete();
cap_handover_unicast_start();
}
static void security_change(esp_ble_audio_gap_app_event_t *event)
{
int err;
if (event->security_change.status) {
security_failed_recover(event->security_change.conn_handle,
event->security_change.status);
return;
}
ESP_LOGI(TAG, "Security: handle %u level %u bonded %u",
event->security_change.conn_handle, event->security_change.sec_level,
event->security_change.bonded);
err = exchange_mtu(event->security_change.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to exchange MTU, err %d", err);
return;
}
}
void cap_handover_unicast_gap_cb(esp_ble_audio_gap_app_event_t *event)
{
switch (event->type) {
case ESP_BLE_AUDIO_GAP_EVENT_EXT_SCAN_RECV:
ext_scan_recv(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_ACL_CONNECT:
acl_connect(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_ACL_DISCONNECT:
acl_disconnect(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_SECURITY_CHANGE:
security_change(event);
break;
default:
break;
}
}
static void gatt_mtu_change(esp_ble_audio_gatt_app_event_t *event)
{
uint16_t conn_handle = event->gatt_mtu_change.conn_handle;
int err;
ESP_LOGI(TAG, "MTU updated: handle %u mtu %u",
conn_handle, event->gatt_mtu_change.mtu);
if (event->gatt_mtu_change.mtu < ESP_BLE_AUDIO_ATT_MTU_MIN) {
ESP_LOGW(TAG, "Invalid new mtu %u, shall be at least %u",
event->gatt_mtu_change.mtu, ESP_BLE_AUDIO_ATT_MTU_MIN);
return;
}
err = esp_ble_audio_gattc_disc_start(conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to start svc disc, err %d", err);
return;
}
ESP_LOGI(TAG, "Service discovery started: handle %u", conn_handle);
/* Note:
* MTU exchanged event may arrived after discover completed event.
*/
peer.mtu_exchanged = true;
if (peer.disc_completed) {
(void)discover_cas();
}
}
static void gattc_disc_cmpl(esp_ble_audio_gatt_app_event_t *event)
{
ESP_LOGI(TAG, "Service discovery complete: handle %u",
event->gattc_disc_cmpl.conn_handle);
if (event->gattc_disc_cmpl.status) {
ESP_LOGE(TAG, "gattc disc failed, status %u", event->gattc_disc_cmpl.status);
return;
}
/* Note:
* Discover completed event may arrived before MTU exchanged event.
*/
peer.disc_completed = true;
if (peer.mtu_exchanged) {
(void)discover_cas();
}
}
void cap_handover_unicast_gatt_cb(esp_ble_audio_gatt_app_event_t *event)
{
switch (event->type) {
case ESP_BLE_AUDIO_GATT_EVENT_GATT_MTU_CHANGE:
gatt_mtu_change(event);
break;
case ESP_BLE_AUDIO_GATT_EVENT_GATTC_DISC_CMPL:
gattc_disc_cmpl(event);
break;
default:
break;
}
}
int cap_handover_unicast_start(void)
{
return ext_scan_start();
}
int cap_handover_unicast_init(void)
{
int err;
err = esp_ble_audio_cap_initiator_register_cb(&cap_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register CAP callbacks, err %d", err);
return err;
}
err = esp_ble_audio_bap_unicast_client_register_cb(&unicast_client_cbs);
if (err) {
ESP_LOGE(TAG, "Failed to register BAP unicast client callbacks, err %d", err);
return err;
}
ESP_LOGI(TAG, "CAP initiator unicast initialized");
return 0;
}
@@ -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
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2024 Nordic Semiconductor ASA
* SPDX-FileContributor: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "nvs_flash.h"
#include "cap_handover.h"
void app_main(void)
{
esp_ble_audio_init_info_t info = {
.gap_cb = cap_handover_unicast_gap_cb,
.gatt_cb = cap_handover_unicast_gatt_cb,
};
esp_err_t err;
/* Initialize NVS — it is used to store PHY calibration data */
err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
err = bluetooth_init();
if (err) {
ESP_LOGE(TAG, "Failed to initialize BLE, err %d", err);
return;
}
err = app_host_init();
if (err) {
ESP_LOGE(TAG, "Failed to init host, err %d", err);
return;
}
err = esp_ble_audio_common_init(&info);
if (err) {
ESP_LOGE(TAG, "Failed to initialize audio, err %d", err);
return;
}
err = cap_handover_unicast_init();
if (err) {
return;
}
err = cap_handover_proc_init();
if (err) {
return;
}
cap_handover_tx_init();
err = esp_ble_audio_common_start(NULL);
if (err) {
ESP_LOGE(TAG, "Failed to start audio, err %d", err);
return;
}
err = set_device_name();
if (err) {
ESP_LOGE(TAG, "Failed to set device name, err %d", err);
return;
}
/* Scanning only: the broadcast source is created by the handover procedure. */
err = cap_handover_unicast_start();
if (err) {
return;
}
}
@@ -0,0 +1,170 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "esp_log.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "os/os_mbuf.h"
#include "cap_handover.h"
static int gap_event_cb(struct ble_gap_event *event, void *arg)
{
return 0;
}
static int adv_data_set(const uint8_t *payload, uint8_t payload_len, bool periodic)
{
struct os_mbuf *data;
int err;
data = os_msys_get_pkthdr(payload_len, 0);
if (data == NULL) {
ESP_LOGE(TAG, "Failed to get %s adv mbuf", periodic ? "per" : "ext");
return -1;
}
err = os_mbuf_append(data, payload, payload_len);
if (err) {
ESP_LOGE(TAG, "Failed to append %s adv data, err %d", periodic ? "per" : "ext", err);
os_mbuf_free_chain(data);
return err;
}
err = periodic ? ble_gap_periodic_adv_set_data(ADV_HANDLE, data)
: ble_gap_ext_adv_set_data(ADV_HANDLE, data);
if (err) {
ESP_LOGE(TAG, "Failed to set %s adv data, err %d", periodic ? "per" : "ext", err);
return err;
}
return 0;
}
static int adv_set_configure(const uint8_t *ext_data, uint8_t ext_len)
{
struct ble_gap_periodic_adv_params per_params = {0};
struct ble_gap_ext_adv_params ext_params = {0};
int err;
ext_params.connectable = 0;
ext_params.scannable = 0;
ext_params.legacy_pdu = 0;
ext_params.own_addr_type = BLE_OWN_ADDR_PUBLIC;
ext_params.primary_phy = BLE_HCI_LE_PHY_1M;
ext_params.secondary_phy = BLE_HCI_LE_PHY_2M;
ext_params.tx_power = ADV_TX_POWER;
ext_params.sid = ADV_SID;
ext_params.itvl_min = BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS);
ext_params.itvl_max = BLE_GAP_ADV_ITVL_MS(ADV_INTERVAL_MS);
err = ble_gap_ext_adv_configure(ADV_HANDLE, &ext_params, NULL,
gap_event_cb, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to configure ext adv params, err %d", err);
return err;
}
err = adv_data_set(ext_data, ext_len, false);
if (err) {
return err;
}
per_params.include_tx_power = 0;
per_params.itvl_min = BLE_GAP_PERIODIC_ITVL_MS(PER_ADV_INTERVAL_MS);
per_params.itvl_max = BLE_GAP_PERIODIC_ITVL_MS(PER_ADV_INTERVAL_MS);
err = ble_gap_periodic_adv_configure(ADV_HANDLE, &per_params);
if (err) {
ESP_LOGE(TAG, "Failed to configure per adv params, err %d", err);
return err;
}
return 0;
}
int per_adv_data_start(const uint8_t *per_data, uint8_t per_len)
{
int err;
err = adv_data_set(per_data, per_len, true);
if (err) {
return err;
}
err = ble_gap_periodic_adv_start(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to start per advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Periodic advertising started (handle %u)", ADV_HANDLE);
return 0;
}
int ext_adv_start_without_base(const uint8_t *ext_data, uint8_t ext_len)
{
int err;
err = adv_set_configure(ext_data, ext_len);
if (err) {
return err;
}
err = ble_gap_ext_adv_start(ADV_HANDLE, 0, 0);
if (err) {
ESP_LOGE(TAG, "Failed to start ext advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Advertising started, BASE pending (handle %u)", ADV_HANDLE);
return 0;
}
int adv_stop(void)
{
int err;
err = ble_gap_periodic_adv_stop(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to stop per advertising, err %d", err);
return err;
}
err = ble_gap_ext_adv_stop(ADV_HANDLE);
if (err) {
ESP_LOGE(TAG, "Failed to stop ext advertising, err %d", err);
return err;
}
ESP_LOGI(TAG, "Advertising stopped (handle %u)", ADV_HANDLE);
return 0;
}
int local_public_addr_get(uint8_t addr[6])
{
int err = ble_hs_id_copy_addr(BLE_ADDR_PUBLIC, addr, NULL);
if (err) {
ESP_LOGE(TAG, "Local BD address unavailable, err %d", err);
return err;
}
return 0;
}
int pa_set_info_transfer(uint16_t conn_handle, const uint8_t peer_addr[6],
uint16_t service_data)
{
(void)peer_addr;
return ble_gap_periodic_adv_sync_set_info(ADV_HANDLE, conn_handle, service_data);
}
@@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include <string.h>
#include "esp_log.h"
#include "host/ble_gap.h"
#include "host/ble_hs.h"
#include "host/ble_store.h"
#include "services/gap/ble_svc_gap.h"
#include "esp_ble_audio_common_api.h"
#include "cap_handover.h"
/* Init/conn parameters are shared with the bluedroid wrapper via cap_handover.h.
* CONN_DURATION is NimBLE-specific (ble_gap_connect's discovery timeout). */
#define CONN_DURATION 10000 /* 10s */
static int gap_event_cb(struct ble_gap_event *event, void *arg)
{
switch (event->type) {
case BLE_GAP_EVENT_EXT_DISC:
case BLE_GAP_EVENT_CONNECT:
case BLE_GAP_EVENT_DISCONNECT:
case BLE_GAP_EVENT_ENC_CHANGE:
esp_ble_audio_gap_app_post_event(event->type, event);
break;
case BLE_GAP_EVENT_MTU:
case BLE_GAP_EVENT_NOTIFY_RX:
case BLE_GAP_EVENT_NOTIFY_TX:
case BLE_GAP_EVENT_SUBSCRIBE:
esp_ble_audio_gatt_app_post_event(event->type, event);
break;
case BLE_GAP_EVENT_REPEAT_PAIRING: {
struct ble_gap_conn_desc desc = {0};
int rc = ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc);
if (rc == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
return BLE_GAP_REPEAT_PAIRING_RETRY;
}
default:
break;
}
return 0;
}
int app_host_init(void)
{
return 0;
}
int set_device_name(void)
{
return ble_svc_gap_device_name_set(LOCAL_DEVICE_NAME);
}
#define OWN_ADDR_TYPE BLE_OWN_ADDR_PUBLIC
int ext_scan_start(void)
{
struct ble_gap_disc_params params = {0};
int err;
params.passive = 1;
params.itvl = SCAN_INTERVAL;
params.window = SCAN_WINDOW;
err = ble_gap_disc(OWN_ADDR_TYPE, BLE_HS_FOREVER, &params,
gap_event_cb, NULL);
if (err) {
ESP_LOGE(TAG, "Failed to start scanning, err %d", err);
return err;
}
ESP_LOGI(TAG, "Scanning for CAP Acceptor...");
return 0;
}
int ext_scan_stop(void)
{
return ble_gap_disc_cancel();
}
int conn_create(uint8_t addr_type, const uint8_t addr[6])
{
struct ble_gap_conn_params params = {0};
ble_addr_t dst = {0};
params.scan_itvl = INIT_SCAN_INTERVAL;
params.scan_window = INIT_SCAN_WINDOW;
params.itvl_min = CONN_INTERVAL;
params.itvl_max = CONN_INTERVAL;
params.latency = CONN_LATENCY;
params.supervision_timeout = CONN_TIMEOUT;
params.max_ce_len = CONN_MAX_CE_LEN;
params.min_ce_len = CONN_MIN_CE_LEN;
dst.type = addr_type;
memcpy(dst.val, addr, sizeof(dst.val));
return ble_gap_connect(OWN_ADDR_TYPE, &dst, CONN_DURATION,
&params, gap_event_cb, NULL);
}
int pairing_start(uint16_t conn_handle)
{
return ble_gap_security_initiate(conn_handle);
}
int exchange_mtu(uint16_t conn_handle)
{
return ble_gattc_exchange_mtu(conn_handle, NULL, NULL);
}
void security_failed_recover(uint16_t conn_handle, uint8_t status)
{
struct ble_gap_conn_desc desc = {0};
int rc;
ESP_LOGE(TAG, "Security change failed, status %u, clearing local bond and reconnecting", status);
rc = ble_gap_conn_find(conn_handle, &desc);
if (rc == 0) {
ble_store_util_delete_peer(&desc.peer_id_addr);
}
ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM);
}
@@ -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
@@ -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
@@ -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
@@ -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