feat(ble_audio): Miscellaneous update for ISO & Audio examples

This commit is contained in:
Liu Linyan
2026-08-17 16:35:14 +08:00
parent 89ea6e2b08
commit cd0572bbb6
26 changed files with 2355 additions and 474 deletions

View File

@@ -7,14 +7,64 @@
## Overview
This example takes the **TMAP Call Gateway (CG)** and **Unicast Media Sender (UMS)** roles, registered together via `esp_ble_audio_tmap_register(ESP_BLE_AUDIO_TMAP_ROLE_CG | ESP_BLE_AUDIO_TMAP_ROLE_UMS)`. It scans for connectable extended advertising that carries TMAS service data with the **UMR** role bit set, connects to the first match, pairs, exchanges MTU, and then drives TMAP and VCP discovery before bringing up unicast audio.
This example takes the **TMAP Call Gateway (CG)** and **Unicast Media Sender (UMS)** roles, registered together via `esp_ble_audio_tmap_register(ESP_BLE_AUDIO_TMAP_ROLE_CG | ESP_BLE_AUDIO_TMAP_ROLE_UMS)`. It scans for connectable extended advertising that carries TMAS service data with the **UMR** role bit set, connects, pairs, exchanges MTU, and then drives TMAP, CSIP and VCP discovery before bringing up unicast audio.
The example uses the ESP-BLE-AUDIO library pieces for: CAP initiator with the BAP Unicast Client (LC3 preset 48_2_1, sink direction, FRONT_LEFT, MEDIA context), VCP Volume Controller, MCP server backed by the media proxy player, and CCP server registering a single GTBS bearer (`Generic TBS`, UCI `un000`, `tel,wechat` URI schemes, 5G technology). A periodic TX scheduler in the ISO task feeds dummy ISO SDUs filled with the sequence number. Device name is set to `TMAP Central`.
The example uses the ESP-BLE-AUDIO library pieces for: CAP initiator with the BAP Unicast Client (LC3 preset 48_2_1, MEDIA context), CSIP Set Coordinator, VCP Volume Controller, MCP server backed by the media proxy player, and CCP server registering a single GTBS bearer (`Generic TBS`, UCI `un000`, `tel,wechat` URI schemes, 5G technology). Each transmitting stream runs its own TX scheduler in the ISO task, feeding dummy ISO SDUs filled with that stream's sequence number. Device name is set to `TMAP Central`.
## Topology
The number of peripherals and the number of CIS is a build-time choice. The first CIS is always **bidirectional** — one sink and one source ASE of the same peripheral share it — and a second CIS, when present, is **transmit-only**:
| Topology | Peripherals | CIS #0 | CIS #1 |
| --- | --- | --- | --- |
| `1P_1CIS` | 1 | TX+RX — sink #0 + source #0 | — |
| `1P_2CIS` (default) | 1 | TX+RX — sink #0 + source #0 | TX — sink #1 of the same peer |
| `2P_2CIS` | 2 (CSIP coordinated set) | TX+RX — rank 1 member | TX — rank 2 member |
All CIS live in a single CIG. Which endpoint goes on which CIS is expressed by one table (`bindings[]` in `main/cap_initiator.c`); everything else in the state machine is shared between the three topologies.
> **The topology picked here must match how [tmap_peripheral](../peripheral/) is built.** The central does not negotiate the topology — it looks for a fixed number of peers and a fixed number of sink ASEs on each. A mismatch does not fall back gracefully: it stalls in discovery or refuses to build the group.
| Central **Unicast topology** | Peripheral boards | **Earbuds type** | **Earbud Location** |
| --- | --- | --- | --- |
| `1 peripheral, 1 CIS (TX+RX)` | 1 | `Single headset` | `Mono` |
| `1 peripheral, 2 CISes (TX+RX, TX)` **(default)** | 1 | `Single headset` **(default)** | `Stereo` **(default)** |
| `2 peripherals (coordinated set), 2 CISes (TX+RX, TX)` | 2 | `Duo headset` on both | `Left` on one, `Right` on the other |
What a mismatch looks like:
* central built for 2 peripherals, only one flashed → stops after `Peer 1/2 connected, scanning for the rest` and never starts audio
* central built for 2 CISes, peripheral left on `Mono``[SNK #1] Peer 0 has no sink endpoint 1` and `Failed to create unicast group`
* peripheral built with `CONFIG_BT_ASCS_MAX_ACTIVE_ASES` below 3 → the third codec configuration is rejected with `[SNK #1] Config response, rsp_code 13` (Insufficient Resources) and `Unicast start completed, err -77`
* two set members flashed with the same ear → same rank on both, `both report rank 1`, streams come up unlocked
The peripheral's Kconfig only offers the location options that suit its earbuds type — mono/stereo for a single headset, left/right for a duo — so the two peripheral settings on their own cannot express an unsupported combination. What still has to be kept in step is picking the *right* one of the three rows above.
### Channel allocation
The rendered channel comes from the peer's **PACS Sink Audio Locations**, not from its CSIP rank — CSIS only requires a rank to be unique and non-zero, it says nothing about which ear a device is. `sink_preset()` in `main/cap_initiator.c` resolves it as:
* peer carries **one** sink stream → its own advertised location (`FRONT_LEFT` / `FRONT_RIGHT`), or mono when it advertises an empty bitmap or both channels
* peer carries **two** sink streams → front left for the first, front right for the second
An acceptor that publishes no location therefore gets a mono stream, which is what the peripheral example's `Mono` location produces.
Rank still decides *which* member gets the bidirectional CIS in `2P_2CIS`, which is a separate question from which ear it is.
### Coordinated set (`2P_2CIS`)
The central resolves each advertised RSI against the SIRK hardcoded in `main/csip_set_coordinator.c`, connects only members of that set, orders them by CSIS rank, and runs the CSIP ordered access procedure — locking the set ascending by rank before stream setup and releasing it once `unicast_start_complete` fires.
CAP names the members by their CSIS instance (`ESP_BLE_AUDIO_CAP_SET_TYPE_CSIP`) as soon as every member exposes one, and falls back to naming them by connection (`..._AD_HOC`) otherwise — that fallback is what lets the same code path serve a peripheral without CSIS. Both forms resolve to the same connections; the set lock itself is driven by the CSIP layer, not by the CAP set type.
Volume is treated as a property of the whole set: every member gets its own VCP volume controller, and volume / mute go through the **CAP commander** (`esp_ble_audio_cap_commander_change_volume*`) so both earbuds change together. The commander refuses to run until each member has a discovered volume controller, so the example reads each member's VCS state first — that also syncs the VCS change counter.
To make that visible, the example keeps driving the set once the streams are up: every 5 minutes it issues one commander procedure, walking the volume through 32 / 96 / 160 / 224 and then muting and unmuting. Each step is a single procedure covering every member, so both earbuds report the same value in the same tick rather than being written one at a time. It runs on its own application task — not the audio task and not the esp_timer one, which the stack's own timers share — and the audio APIs take the host lock themselves, so calling them from there is safe. A tick is skipped rather than queued if the previous procedure has not reported back, and the cycle pauses as soon as the set is no longer whole.
## Requirements
* A board with BLE 5.2, ISO, and LE Audio support (e.g. ESP32-H4, ESP32-S31)
* Peer device running the paired example
* One or two boards running [tmap_peripheral](../peripheral/), depending on the topology
## Configuration
@@ -22,7 +72,11 @@ The example uses the ESP-BLE-AUDIO library pieces for: CAP initiator with the BA
idf.py menuconfig
```
No build-time optionsruntime defaults are baked into source.
Under **Example: TMAP Central (CG & UMS)****Unicast topology**, pick oneand build the peripheral to match, per the table in [Topology](#topology):
* **1 peripheral, 1 CIS (TX+RX)** (`EXAMPLE_TMAP_CEN_TOPO_1P_1CIS`) — peripheral: `Single headset` + `Mono`.
* **1 peripheral, 2 CISes (TX+RX, TX)** (`EXAMPLE_TMAP_CEN_TOPO_1P_2CIS`, **default**) — peripheral: `Single headset` + `Stereo`, which is also its default. It must expose two sink ASEs (`CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT`, 2 in its `sdkconfig.defaults`).
* **2 peripherals (coordinated set), 2 CISes (TX+RX, TX)** (`EXAMPLE_TMAP_CEN_TOPO_2P_2CIS`) — selects `BT_CSIP_SET_COORDINATOR`. Peripheral: `Duo headset` on both boards, one `Left` and one `Right`; their ranks default from that and must stay distinct. Rank picks which member gets the bidirectional CIS, the location picks the channel.
### Security & Pairing
@@ -53,65 +107,86 @@ For `esp32s31`, replace the chip overlay accordingly.
## Example Flow
1. `app_main` initializes NVS, Bluetooth, the audio common layer, and registers TMAP CG+UMS.
2. Sub-modules initialize in order: CAP initiator (with TX scheduler), VCP volume controller, MCP server (media proxy), CCP server (GTBS bearer).
2. Sub-modules initialize in order: CAP initiator (one TX scheduler per sink stream), CSIP set coordinator, VCP volume controller plus CAP commander callbacks, MCP server (media proxy), CCP server (GTBS bearer).
3. The audio stack starts, the device name is set, and extended scanning begins.
4. Each connectable scan result is parsed for TMAS service data; if the peer's TMAP role contains UMR, scan is cancelled and a connection is created.
4. Each connectable scan result is parsed for TMAS service data and, in the two-peripheral topology, for the CSIS RSI. A peer is taken when its TMAP role contains UMR and — for a coordinated set build — its RSI resolves against our SIRK. Peers already connected are skipped by address.
5. On ACL connect, security is initiated; on security change, an MTU exchange is requested.
6. After the MTU exchange and GATT discovery both complete, TMAP and VCP discovery start.
7. TMAP discovery completion triggers `cap_initiator_setup` CAS discover → sink and source ASE discovery → unicast group create → unicast audio start.
8. When a sink stream reaches "started", the TX scheduler is started at the QoS interval and `unicast_audio_tx` sends ISO SDUs filled with the sequence number.
9. After VCP discovery, the volume state is read to sync the change counter, then a mute command is issued from the state callback.
10. On disconnect, the unicast group is deleted and scanning restarts.
6. After the MTU exchange and GATT discovery both complete, TMAP, VCP and CSIP discovery start for that peer, and scanning resumes if more peers are still wanted. VCP discovery runs on **every** member, not just the first.
7. Once every peer has been through set discovery, the members are ordered by rank, the set is locked (two-peripheral topology only) and `cap_initiator_setup` runs CAS discover → sink and source ASE discovery on every member. The CAS discovery also records each peer's CSIS instance, and PACS reports its Sink Audio Locations.
8. When the last member reports back, the unicast group is created from `bindings[]` — each sink's channel resolved from that peer's advertised location — and unicast audio starts. The set lock is released when the start procedure completes.
9. Each sink stream that reaches "started" arms its own TX scheduler at the QoS interval and sends ISO SDUs filled with its own sequence number; the source stream's incoming SDUs are counted as RX metrics.
10. Once the streams are up and every member's VCS state has been read, a periodic demo starts driving the set through the CAP commander — one procedure every 5 minutes, cycling volume and then mute, each applying to the whole set at once.
11. Losing one member costs the others nothing: the CIG stays up, their CIS keep streaming, and the departed member's slot in the group is held for whatever comes back to fill it. Only that member is scanned for and rediscovered, and starting it again reuses its slot — releasing a stream leaves it in the group, and configuring it again rebinds the new endpoint to the CIS the slot already owns. The group is deleted only when the last member goes.
## Expected Log
Initialization phase:
```
TMAP_CEN: CAP initiator initialized
TMAP_CEN: CAP initiator initialized: 2 peer(s), 2 CIS
TMAP_CEN: CSIP set coordinator initialized
TMAP_CEN: VCP volume controller initialized
TMAP_CEN: MCP server initialized
TMAP_CEN: Registered gtbs bearer <n>
TMAP_CEN: Scanning for peripheral...
```
Scan and connect:
Scan and connect (the `2/2` count and the `set member` note only appear in a coordinated-set build):
```
TMAP_CEN: Found TMAS in peer adv data!
TMAP_CEN: Found peer 1/2 (TMAS UMR, set member)
TMAP_CEN: Connected: handle <h> role <r> peer <addr>
TMAP_CEN: Security: handle <h> level <l> bonded <b>
TMAP_CEN: MTU updated: handle <h> mtu <m>
TMAP_CEN: Service discovery started: handle <h>
TMAP_CEN: Service discovery complete: handle <h> status 0
TMAP_CEN: TMAP discovery done: handle <h>
TMAP_CEN: Peer 1/2 connected, scanning for the rest
TMAP_CEN: CSIP discovered: handle <h> set_size 2 rank 1 lockable 1
```
TMAP / VCP / CAP setup:
Set lock and CAP setup:
```
TMAP_CEN: TMAP discovery done
TMAP_CEN: CAP initiator setup
TMAP_CEN: Found CAS
TMAP_CEN: [SNK #0] Endpoint discovered
TMAP_CEN: Sink discover complete
TMAP_CEN: Source discover complete
TMAP_CEN: Created unicast group
TMAP_CEN: Started unicast audio
TMAP_CEN: VCP volume controller discovering
TMAP_CEN: VCP discovery done
TMAP_CEN: VCP state cb done, volume <v> mute <m>
```
Unicast streaming phase:
```
TMAP_CEN: [SNK #0] Stream configured, QoS preference:
TMAP_CEN: [SNK #0] QoS set
TMAP_CEN: [SNK #0] Stream enabled
TMAP_CEN: [SNK #0] Stream connected
TMAP_CEN: [SNK #0] Stream started
TMAP_CEN: Streaming, interval <i>, length <l>
TMAP_CEN: All 2 peer(s) ready
TMAP_CEN: Set locked
TMAP_CEN: CAP initiator setup for 2 member(s)
TMAP_CEN: Found CAS with CSIS: handle <h>
TMAP_CEN: [SNK #0] Endpoint discovered on handle <h>
TMAP_CEN: [SRC #0] Endpoint discovered on handle <h>
TMAP_CEN: Sink discover complete: handle <h>
TMAP_CEN: Source discover complete: handle <h>
TMAP_CEN: [SNK #0] Peer 0 (loc 0x00000001) renders front left
TMAP_CEN: [SNK #1] Peer 1 (loc 0x00000002) renders front right
TMAP_CEN: Created unicast group: 2 CIS
TMAP_CEN: Started 3 unicast stream(s) as a coordinated set
TMAP_CEN: Unicast start completed
TMAP_CEN: Set released
```
Volume, applied to the whole set through the CAP commander:
```
TMAP_CEN: VCP discovery done, vocs <n> aics <n>
TMAP_CEN: VCP state cb done, volume <v> mute <m>
TMAP_CEN: Driving the volume of 2 member(s) every 300 s
TMAP_CEN: Changing volume of 2 member(s) to 32
TMAP_CEN: Set volume changed
TMAP_CEN: Changing volume of 2 member(s) to 96
TMAP_CEN: Set volume changed
TMAP_CEN: Changing volume mute state of 2 member(s) to 1
TMAP_CEN: Set volume mute changed
```
Streaming phase. Each stream reports its own count, so the two sinks advance independently and the source counts what the rank 1 peripheral sends back:
```
TMAP_CEN: [SNK #0] Stream started
TMAP_CEN: [SNK #0] Streaming, interval <i>, length <l>
TMAP_CEN: [SNK #1] Stream started
TMAP_CEN: [SNK #1] Streaming, interval <i>, length <l>
TMAP_CEN: [SNK #0] TX: <count> packets
TMAP_CEN: [SNK #1] TX: <count> packets
TMAP_CEN: [SRC #0] RX: <count> packets
```
Call control / disconnect:
@@ -129,11 +204,22 @@ Tag is `TMAP_CEN`.
## Peer Pairing
Run [tmap_peripheral](../peripheral/) on a second board.
Run [tmap_peripheral](../peripheral/) on one or two boards, matching the topology.
1. Flash the peripheral and let it start extended advertising.
### 1 peripheral (`1P_1CIS` / `1P_2CIS`)
1. Flash the peripheral and let it start extended advertising. `1P_2CIS` works with its defaults (`Single headset` + `Stereo`); for `1P_1CIS` switch its location to **Mono**.
2. Flash and start this central; it scans for TMAS+UMR.
3. The central connects, pairs, and exchanges MTU with the peripheral.
4. TMAP discovery completes on both sides; the central runs CAP unicast setup against the peripheral's ASCS.
5. The central starts the sink stream and feeds ISO SDUs to the peripheral at the QoS interval.
5. The central starts the sink stream(s) and feeds ISO SDUs at the QoS interval; the peripheral transmits back on the bidirectional CIS.
6. The central can drive the peripheral's volume via VCP and accept TBS originate/terminate from the peripheral.
### 2 peripherals (`2P_2CIS`)
1. Build the peripheral twice as **Duo headset**, once with **Earbud Location** = **Left** and once with **Right**. The rank default follows the location (left → 1, right → 2), so the two boards get distinct ranks without touching **Device rank in set**. Flash one image per board.
2. Flash and start this central. It connects the first member it finds, resumes scanning, then connects the second.
3. After both members finish CSIP discovery the central locks the set by rank, sets up one CIG with two CIS, and releases the lock when the procedure completes.
4. The rank 1 board gets the bidirectional CIS and transmits back; the rank 2 board gets a transmit-only CIS. Which channel each renders comes from its **Earbud Location**, so a rank/ear mismatch shows up in the `renders front left` / `renders front right` log rather than silently sending the wrong channel.
5. Once both streams are up and both members' VCS state is read, the volume demo starts: every 5 minutes one CAP commander procedure moves both boards to the same volume, and the mute steps at the end of the cycle mute and unmute them together. Both boards log the same value within one tick — that is the point of routing it through the commander instead of writing each VCS separately.
6. Losing either board leaves the other one streaming: its CIS is untouched, and the central just rescans for the missing one and re-enters it into the slot it left. The surviving earbud logs nothing at all — no `Disable request`, no `ISO disconnected` — while the central logs `Lost a member; 1 still streaming` and later `[SNK #n] Already streaming, left alone`.

View File

@@ -1,5 +1,6 @@
set(srcs "cap_initiator.c"
"ccp_server.c"
"csip_set_coordinator.c"
"mcp_server.c"
"vcp_vol_ctlr.c"
"main.c")

View File

@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
menu "Example: TMAP Central (CG & UMS)"
choice EXAMPLE_TMAP_CEN_TOPOLOGY
prompt "Unicast topology"
default EXAMPLE_TMAP_CEN_TOPO_1P_2CIS
help
How many TMAP peripherals to connect and how many CIS carry the
streams. The first CIS is always bidirectional — one sink and one
source endpoint of the same peripheral share it — and a second CIS,
when present, is transmit-only.
config EXAMPLE_TMAP_CEN_TOPO_1P_1CIS
bool "1 peripheral, 1 CIS (TX+RX)"
help
One peripheral. Its sink and source ASEs are paired into a single
bidirectional CIS.
Build the peripheral as "Single headset" with the "Mono" location,
which is its default.
config EXAMPLE_TMAP_CEN_TOPO_1P_2CIS
bool "1 peripheral, 2 CISes (TX+RX, TX)"
help
One peripheral exposing two sink ASEs. The first sink shares a
bidirectional CIS with the source ASE; the second sink gets its
own transmit-only CIS, and the two are rendered front left and
front right.
Build the peripheral as "Single headset" with the "Stereo"
location, so its PACS advertises both channels, and with at least
two sink ASEs (CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT).
config EXAMPLE_TMAP_CEN_TOPO_2P_2CIS
bool "2 peripherals (coordinated set), 2 CISes (TX+RX, TX)"
select BT_CSIP_SET_COORDINATOR
help
Two peripherals that belong to one CSIP coordinated set, both
served by a single CIG. The rank 1 member gets the bidirectional
CIS, the rank 2 member a transmit-only one; which channel each
renders comes from its own PACS Audio Locations, not from rank.
Build the peripheral twice as "Duo headset", one with the "Left"
location and one with "Right" — their ranks default from that and
must stay distinct. Both share the SIRK hardcoded in the example.
endchoice
endmenu

View File

@@ -0,0 +1,187 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "tmap_central.h"
#if TMAP_CEN_PEER_COUNT > 1
/* Must match the SIRK the peripherals register (peripheral/main/csip_set_member.c). */
#define CSIP_SIRK_DEBUG { 0xcd, 0xcc, 0x72, 0xdd, 0x86, 0x8c, 0xcd, 0xce, \
0x22, 0xfd, 0xa1, 0x21, 0x09, 0x7d, 0x7d, 0x45 }
static const uint8_t set_sirk[ESP_BLE_AUDIO_CSIP_SIRK_SIZE] = CSIP_SIRK_DEBUG;
/* Look up by the caller's handles: the library keys on the controller-assigned
* ACL handle, not a slot number. */
static uint8_t collect_members(const uint16_t *conn_handles, size_t count,
const esp_ble_audio_csip_set_coordinator_set_member_t *out[])
{
uint8_t found = 0;
for (size_t i = 0; i < count; i++) {
const esp_ble_audio_csip_set_coordinator_set_member_t *member =
esp_ble_audio_csip_set_coordinator_set_member_by_conn(conn_handles[i]);
if (member != NULL) {
out[found++] = member;
}
}
return found;
}
static void csip_discover_cb(struct bt_conn *conn,
const esp_ble_audio_csip_set_coordinator_set_member_t *member,
int err, size_t set_count)
{
uint16_t conn_handle = (conn != NULL) ? conn->handle : CONN_HANDLE_INIT;
if (err) {
ESP_LOGE(TAG, "CSIP discovery failed on handle %u, err %d", conn_handle, err);
set_member_discovered(conn_handle, err);
return;
}
if (set_count == 0 || member == NULL) {
ESP_LOGE(TAG, "No coordinated set on handle %u", conn_handle);
set_member_discovered(conn_handle, -ENOENT);
return;
}
ESP_LOGI(TAG, "CSIP discovered: handle %u set_size %u rank %u lockable %u",
conn_handle, member->insts[0].info.set_size,
member->insts[0].info.rank, member->insts[0].info.lockable);
if (member->insts[0].info.set_size != TMAP_CEN_PEER_COUNT) {
ESP_LOGW(TAG, "Peer reports set size %u, this build drives %u member(s)",
member->insts[0].info.set_size, TMAP_CEN_PEER_COUNT);
}
set_member_discovered(conn_handle, 0);
}
static void csip_lock_set_cb(int err)
{
/* A failure is reported by set_lock_complete(), which also says what it
* does about it. */
if (!err) {
ESP_LOGI(TAG, "Set locked");
}
set_lock_complete(err);
}
static void csip_release_set_cb(int err)
{
if (err) {
ESP_LOGE(TAG, "Failed to release set, err %d", err);
} else {
ESP_LOGI(TAG, "Set released");
}
}
static esp_ble_audio_csip_set_coordinator_cb_t csip_cb = {
.discover = csip_discover_cb,
.lock_set = csip_lock_set_cb,
.release_set = csip_release_set_cb,
};
int csip_set_coordinator_init(void)
{
int err;
err = esp_ble_audio_csip_set_coordinator_register_cb(&csip_cb);
if (err) {
ESP_LOGE(TAG, "Failed to register csip callbacks, err %d", err);
return err;
}
ESP_LOGI(TAG, "CSIP set coordinator initialized");
return 0;
}
bool csip_adv_is_set_member(uint8_t ad_type, const uint8_t *data, uint8_t data_len)
{
return esp_ble_audio_csip_set_coordinator_is_set_member(set_sirk, ad_type, data, data_len);
}
int csip_discover_start(uint16_t conn_handle)
{
int err;
err = esp_ble_audio_csip_set_coordinator_discover(conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to start csip discovery on handle %u, err %d", conn_handle, err);
return err;
}
return 0;
}
uint8_t csip_member_rank_get(uint16_t conn_handle)
{
const esp_ble_audio_csip_set_coordinator_set_member_t *member =
esp_ble_audio_csip_set_coordinator_set_member_by_conn(conn_handle);
return (member != NULL) ? member->insts[0].info.rank : 0;
}
int csip_lock_set(const uint16_t *conn_handles, size_t count)
{
const esp_ble_audio_csip_set_coordinator_set_member_t *members[TMAP_CEN_PEER_COUNT];
uint8_t found;
int err;
if (conn_handles == NULL || count == 0 || count > ARRAY_SIZE(members)) {
return -EINVAL;
}
found = collect_members(conn_handles, count, members);
if (found == 0) {
ESP_LOGE(TAG, "No set member to lock");
return -ENOENT;
}
/* The library locks ascending by rank, releases descending. */
err = esp_ble_audio_csip_set_coordinator_lock(members, found, &members[0]->insts[0].info);
if (err) {
ESP_LOGE(TAG, "Failed to issue set lock, err %d", err);
return err;
}
return 0;
}
int csip_release_set(const uint16_t *conn_handles, size_t count)
{
const esp_ble_audio_csip_set_coordinator_set_member_t *members[TMAP_CEN_PEER_COUNT];
uint8_t found;
int err;
if (conn_handles == NULL || count == 0 || count > ARRAY_SIZE(members)) {
return -EINVAL;
}
found = collect_members(conn_handles, count, members);
if (found == 0) {
return -ENOENT;
}
err = esp_ble_audio_csip_set_coordinator_release(members, found, &members[0]->insts[0].info);
if (err) {
ESP_LOGE(TAG, "Failed to issue set release, err %d", err);
return err;
}
return 0;
}
#endif /* TMAP_CEN_PEER_COUNT > 1 */

View File

@@ -13,13 +13,111 @@
#include "tmap_central.h"
static uint16_t default_conn_handle = CONN_HANDLE_INIT;
static bool disc_completed;
static bool mtu_exchanged;
/* MTU exchange and service discovery complete independently, so both are tracked.
* set_discovered is per member rather than a count, so a member that keeps its
* link while another one is replaced does not have to be discovered again. */
struct set_member {
uint16_t handle;
uint8_t dst[6];
bool mtu_exchanged;
bool disc_completed;
bool set_discovered;
};
uint16_t default_conn_handle_get(void)
static struct set_member members[TMAP_CEN_PEER_COUNT];
static size_t member_count;
/* Lock state: pending until the callback, held until stream setup completes. */
static bool lock_pending;
static bool set_locked;
size_t set_member_count(void)
{
return default_conn_handle;
return member_count;
}
uint16_t set_member_handle(size_t index)
{
return (index < member_count) ? members[index].handle : CONN_HANDLE_INIT;
}
static struct set_member *member_by_handle(uint16_t handle)
{
for (size_t i = 0; i < member_count; i++) {
if (members[i].handle == handle) {
return &members[i];
}
}
return NULL;
}
static void member_remove(uint16_t handle)
{
for (size_t i = 0; i < member_count; i++) {
if (members[i].handle != handle) {
continue;
}
for (size_t j = i + 1; j < member_count; j++) {
members[j - 1] = members[j];
}
member_count--;
memset(&members[member_count], 0, sizeof(members[member_count]));
return;
}
}
static bool member_is_known(const uint8_t addr[6])
{
for (size_t i = 0; i < member_count; i++) {
if (memcmp(members[i].dst, addr, sizeof(members[i].dst)) == 0) {
return true;
}
}
return false;
}
/* The library looks members up by handle, not by slot. */
static const uint16_t *lock_handles(void)
{
static uint16_t handles[TMAP_CEN_PEER_COUNT];
for (size_t i = 0; i < member_count; i++) {
handles[i] = members[i].handle;
}
return handles;
}
/* Index 0 must be the rank 1 device; unranked members keep connection order. */
static void set_members_sort_by_rank(void)
{
for (size_t i = 1; i < member_count; i++) {
struct set_member tmp = members[i];
uint8_t rank = csip_member_rank_get(tmp.handle);
size_t j = i;
if (rank == 0) {
continue;
}
while (j > 0) {
uint8_t prev = csip_member_rank_get(members[j - 1].handle);
if (prev != 0 && prev <= rank) {
break;
}
members[j] = members[j - 1];
j--;
}
members[j] = tmp;
}
}
static void tmap_discovery_complete(esp_ble_audio_tmap_role_t role,
@@ -30,32 +128,41 @@ static void tmap_discovery_complete(esp_ble_audio_tmap_role_t role,
return;
}
if (conn->handle != default_conn_handle) {
if (member_by_handle(conn->handle) == NULL) {
return;
}
ESP_LOGI(TAG, "TMAP discovery done");
err = cap_initiator_setup();
if (err) {
return;
}
/* Audio setup waits for set_lock_complete(). */
ESP_LOGI(TAG, "TMAP discovery done: handle %u", conn->handle);
}
static esp_ble_audio_tmap_cb_t tmap_callbacks = {
.discovery_complete = tmap_discovery_complete,
};
static bool check_and_connect(uint8_t type, const uint8_t *data,
/* TMAP role and set identity are separate AD elements; decide after parsing. */
struct adv_match {
bool tmap_umr;
bool rsi_present;
bool rsi_ours;
};
static bool collect_adv_match(uint8_t type, const uint8_t *data,
uint8_t data_len, void *user_data)
{
esp_ble_audio_gap_app_event_t *event;
struct adv_match *match;
uint16_t tmap_role;
uint16_t uuid_val;
int err;
event = user_data;
assert(event);
match = user_data;
assert(match);
if (type == EXAMPLE_AD_TYPE_CSIS_RSI) {
/* Resolves the advertised RSI against our SIRK. */
match->rsi_present = true;
match->rsi_ours = csip_adv_is_set_member(type, data, data_len);
return true; /* Continue parsing to next AD data type */
}
if (type != EXAMPLE_AD_TYPE_SERVICE_DATA16) {
return true; /* Continue parsing to next AD data type */
@@ -73,24 +180,54 @@ static bool check_and_connect(uint8_t type, const uint8_t *data,
return true; /* Continue parsing to next AD data type */
}
ESP_LOGI(TAG, "Found TMAS in peer adv data!");
if (data_len < sizeof(uuid_val) + sizeof(tmap_role)) {
ESP_LOGW(TAG, "Invalid ad size %u (tmap role)", data_len);
return false; /* Stop parsing */
return true; /* Continue parsing to next AD data type */
}
tmap_role = sys_get_le16(data + sizeof(uuid_val));
match->tmap_umr = (tmap_role & ESP_BLE_AUDIO_TMAP_ROLE_UMR) != 0;
if ((tmap_role & ESP_BLE_AUDIO_TMAP_ROLE_UMR) == 0) {
ESP_LOGW(TAG, "No TMAS UMR support!");
return false; /* Stop parsing */
return true; /* Continue parsing to next AD data type */
}
static void ext_scan_recv(esp_ble_audio_gap_app_event_t *event)
{
struct adv_match match = {0};
int err;
if (member_count >= ARRAY_SIZE(members)) {
return;
}
if ((event->ext_scan_recv.event_type & EXAMPLE_ADV_PROP_CONNECTABLE) == 0) {
return;
}
if (member_is_known(event->ext_scan_recv.addr.val)) {
return;
}
esp_ble_audio_data_parse(event->ext_scan_recv.data,
event->ext_scan_recv.data_len,
collect_adv_match, &match);
if (!match.tmap_umr) {
return;
}
/* A coordinated set build only accepts members carrying our SIRK. */
if (TMAP_CEN_PEER_COUNT > 1 && !(match.rsi_present && match.rsi_ours)) {
return;
}
ESP_LOGI(TAG, "Found peer %zu/%u (TMAS UMR%s)", member_count + 1, TMAP_CEN_PEER_COUNT,
match.rsi_present ? ", set member" : "");
err = ext_scan_stop();
if (err) {
ESP_LOGE(TAG, "Failed to stop scanning, err %d", err);
return false;
return;
}
err = conn_create(event->ext_scan_recv.addr.type,
@@ -99,22 +236,6 @@ static bool check_and_connect(uint8_t type, const uint8_t *data,
ESP_LOGE(TAG, "Failed to create conn, err %d", err);
ext_scan_start();
}
return false; /* Stop parsing */
}
static void ext_scan_recv(esp_ble_audio_gap_app_event_t *event)
{
if (default_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)
@@ -130,7 +251,16 @@ static void acl_connect(esp_ble_audio_gap_app_event_t *event)
event->acl_connect.conn_handle, event->acl_connect.role,
EXAMPLE_BT_ADDR_PRINT_ARGS(event->acl_connect.dst.val));
default_conn_handle = event->acl_connect.conn_handle;
if (member_count >= ARRAY_SIZE(members)) {
ESP_LOGE(TAG, "Set member table full");
return;
}
memset(&members[member_count], 0, sizeof(members[member_count]));
members[member_count].handle = event->acl_connect.conn_handle;
memcpy(members[member_count].dst, event->acl_connect.dst.val,
sizeof(members[member_count].dst));
member_count++;
err = pairing_start(event->acl_connect.conn_handle);
if (err) {
@@ -139,16 +269,50 @@ static void acl_connect(esp_ble_audio_gap_app_event_t *event)
}
}
/* Nothing is connected any more: forget the whole session. */
static void set_teardown_finish(void)
{
lock_pending = false;
set_locked = false;
cap_initiator_reset();
vcp_vol_ctlr_reset();
ext_scan_start();
}
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);
default_conn_handle = CONN_HANDLE_INIT;
disc_completed = false;
mtu_exchanged = false;
if (member_by_handle(event->acl_disconnect.conn_handle) == NULL) {
return;
}
unicast_group_delete();
member_remove(event->acl_disconnect.conn_handle);
peer_forget(event->acl_disconnect.conn_handle);
vcp_vol_ctlr_forget(event->acl_disconnect.conn_handle);
/* An incomplete set is no longer something to drive as one. */
vcp_vol_ctlr_streams_stopped();
/* Give the lock back on whatever is still connected, and drop a lock that
* is still in flight: the set it was taken for no longer exists. */
set_release_lock();
lock_pending = false;
if (member_count == 0) {
set_teardown_finish();
return;
}
/* Nothing else to undo: the members that are left keep their links and
* keep streaming. Only the departed member's own streams were released,
* by the stack, and its slot in the CIG stays reserved for whatever comes
* back to fill it. */
ESP_LOGI(TAG, "Lost a member; %zu left, scanning for the other", member_count);
ext_scan_start();
}
@@ -195,8 +359,40 @@ static void iso_gap_app_cb(esp_ble_audio_gap_app_event_t *event)
}
}
/* MTU exchange and service discovery complete in either order. */
static void member_discover_profiles(struct set_member *member)
{
esp_err_t err;
if (!member->mtu_exchanged || !member->disc_completed) {
return;
}
err = esp_ble_audio_tmap_discover(member->handle, &tmap_callbacks);
if (err) {
ESP_LOGE(TAG, "Failed to discover tmap, err %d", err);
return;
}
/* The CAP commander needs a volume controller on every member. */
vcp_vol_ctlr_discover(member->handle);
/* Look for the remaining members while this one is discovered. */
if (member_count < ARRAY_SIZE(members)) {
ESP_LOGI(TAG, "Peer %zu/%u connected, scanning for the rest",
member_count, TMAP_CEN_PEER_COUNT);
ext_scan_start();
}
/* Rank before endpoints, so members can be ordered. No CSIS, no callback. */
if (csip_discover_start(member->handle) != 0) {
set_member_discovered(member->handle, -ENOTSUP);
}
}
static void gatt_mtu_change(esp_ble_audio_gatt_app_event_t *event)
{
struct set_member *member;
esp_err_t err;
ESP_LOGI(TAG, "MTU updated: handle %u mtu %u",
@@ -208,6 +404,11 @@ static void gatt_mtu_change(esp_ble_audio_gatt_app_event_t *event)
return;
}
member = member_by_handle(event->gatt_mtu_change.conn_handle);
if (member == NULL) {
return;
}
err = esp_ble_audio_gattc_disc_start(event->gatt_mtu_change.conn_handle);
if (err) {
ESP_LOGE(TAG, "Failed to start service discovery, err %d", err);
@@ -217,25 +418,14 @@ static void gatt_mtu_change(esp_ble_audio_gatt_app_event_t *event)
ESP_LOGI(TAG, "Service discovery started: handle %u",
event->gatt_mtu_change.conn_handle);
/* Note:
* MTU exchanged event may arrived after discover completed event.
*/
mtu_exchanged = true;
member->mtu_exchanged = true;
if (disc_completed) {
err = esp_ble_audio_tmap_discover(default_conn_handle, &tmap_callbacks);
if (err) {
ESP_LOGE(TAG, "Failed to discover tmap, err %d", err);
return;
}
vcp_vol_ctlr_discover();
}
member_discover_profiles(member);
}
static void gattc_disc_cmpl(esp_ble_audio_gatt_app_event_t *event)
{
esp_err_t err;
struct set_member *member;
ESP_LOGI(TAG, "Service discovery complete: handle %u status %u",
event->gattc_disc_cmpl.conn_handle,
@@ -245,19 +435,125 @@ static void gattc_disc_cmpl(esp_ble_audio_gatt_app_event_t *event)
return;
}
/* Note:
* Discover completed event may arrived before MTU exchanged event.
*/
disc_completed = true;
member = member_by_handle(event->gattc_disc_cmpl.conn_handle);
if (member == NULL) {
return;
}
if (mtu_exchanged) {
err = esp_ble_audio_tmap_discover(default_conn_handle, &tmap_callbacks);
if (err) {
ESP_LOGE(TAG, "Failed to discover tmap, err %d", err);
return;
member->disc_completed = true;
member_discover_profiles(member);
}
/* Ordered access needs all ranks 0 or all unique: a duplicate locks one member
* twice and skips the other. */
static bool set_ranks_are_valid(void)
{
size_t unranked = 0;
for (size_t i = 0; i < member_count; i++) {
uint8_t rank = csip_member_rank_get(members[i].handle);
if (rank == 0) {
unranked++;
continue;
}
vcp_vol_ctlr_discover();
for (size_t j = i + 1; j < member_count; j++) {
if (csip_member_rank_get(members[j].handle) == rank) {
ESP_LOGE(TAG, "Handle %u and handle %u both report rank %u; flash the "
"peripherals for different ears so their ranks differ",
members[i].handle, members[j].handle, rank);
return false;
}
}
}
if (unranked != 0 && unranked != member_count) {
ESP_LOGE(TAG, "Set mixes %zu ranked and %zu unranked member(s)",
member_count - unranked, unranked);
return false;
}
return true;
}
/* One peripheral finished set discovery, or has no CSIS at all. */
void set_member_discovered(uint16_t conn_handle, int err)
{
struct set_member *member = member_by_handle(conn_handle);
/* A standalone headset exposes no CSIS; keep going with what is connected. */
if (err) {
ESP_LOGI(TAG, "Handle %u is not a coordinated set member (err %d)", conn_handle, err);
}
if (member == NULL) {
return;
}
member->set_discovered = true;
if (member_count < ARRAY_SIZE(members)) {
return;
}
for (size_t i = 0; i < member_count; i++) {
if (!members[i].set_discovered) {
return;
}
}
ESP_LOGI(TAG, "All %zu peer(s) ready", member_count);
set_members_sort_by_rank();
/* Streams still work unlocked, so report and carry on. */
if (!set_ranks_are_valid()) {
set_lock_complete(-EINVAL);
return;
}
/* Nothing to lock: go straight to audio setup. */
if (csip_lock_set(lock_handles(), member_count) != 0) {
set_lock_complete(-ENOTSUP);
return;
}
lock_pending = true;
}
void set_lock_complete(int err)
{
/* A set that cannot be locked may still be operated on. */
if (err && lock_pending) {
ESP_LOGW(TAG, "Proceeding without the set lock, err %d", err);
}
set_locked = (err == 0) && lock_pending;
lock_pending = false;
/* A member may have gone while the lock was in flight; the set that is left
* is not the one to set up. */
if (member_count < ARRAY_SIZE(members)) {
set_release_lock();
return;
}
if (cap_initiator_setup() != 0) {
set_release_lock();
}
}
/* Give the lock back once stream setup is done. Called from cap_initiator.c. */
void set_release_lock(void)
{
if (!set_locked) {
return;
}
if (csip_release_set(lock_handles(), member_count) == 0) {
set_locked = false;
}
}
@@ -321,6 +617,11 @@ void app_main(void)
return;
}
err = csip_set_coordinator_init();
if (err) {
return;
}
err = vcp_vol_ctlr_init();
if (err) {
return;

View File

@@ -6,7 +6,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <assert.h>
#include <errno.h>
@@ -37,6 +39,28 @@
#define LOCAL_DEVICE_NAME "TMAP Central"
/* Topology from EXAMPLE_TMAP_CEN_TOPOLOGY. CIS #0 is bidirectional, a second is
* TX-only. Note: CONFIG_BT_ACL_CONNECTIONS is Bluedroid-only. */
#if CONFIG_EXAMPLE_TMAP_CEN_TOPO_1P_1CIS
#define TMAP_CEN_PEER_COUNT 1
#define TMAP_CEN_CIS_COUNT 1
#elif CONFIG_EXAMPLE_TMAP_CEN_TOPO_1P_2CIS
#define TMAP_CEN_PEER_COUNT 1
#define TMAP_CEN_CIS_COUNT 2
#else /* CONFIG_EXAMPLE_TMAP_CEN_TOPO_2P_2CIS */
#define TMAP_CEN_PEER_COUNT 2
#define TMAP_CEN_CIS_COUNT 2
#endif
/* Sink ASEs recorded per peripheral: as many as the client caches, so a peer
* offering more than this topology streams on is not flagged as unhandled. */
#define TMAP_CEN_SINK_EP_MAX CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT
#if TMAP_CEN_PEER_COUNT == 1
_Static_assert(TMAP_CEN_SINK_EP_MAX >= TMAP_CEN_CIS_COUNT,
"One peripheral must expose a sink ASE per CIS");
#endif
#define SCAN_INTERVAL 160 /* 100ms */
#define SCAN_WINDOW 160 /* 100ms */
@@ -65,7 +89,68 @@ int exchange_mtu(uint16_t conn_handle);
void security_failed_recover(uint16_t conn_handle, uint8_t status);
uint16_t default_conn_handle_get(void);
size_t set_member_count(void);
uint16_t set_member_handle(size_t index);
void set_member_discovered(uint16_t conn_handle, int err);
void set_lock_complete(int err);
void set_release_lock(void);
#if TMAP_CEN_PEER_COUNT > 1
int csip_set_coordinator_init(void);
bool csip_adv_is_set_member(uint8_t ad_type, const uint8_t *data, uint8_t data_len);
int csip_discover_start(uint16_t conn_handle);
uint8_t csip_member_rank_get(uint16_t conn_handle);
int csip_lock_set(const uint16_t *conn_handles, size_t count);
int csip_release_set(const uint16_t *conn_handles, size_t count);
#else /* TMAP_CEN_PEER_COUNT > 1 */
static inline int csip_set_coordinator_init(void)
{
return 0;
}
static inline bool csip_adv_is_set_member(uint8_t ad_type, const uint8_t *data, uint8_t data_len)
{
(void)ad_type;
(void)data;
(void)data_len;
return false;
}
static inline int csip_discover_start(uint16_t conn_handle)
{
(void)conn_handle;
return -ENOTSUP;
}
static inline uint8_t csip_member_rank_get(uint16_t conn_handle)
{
(void)conn_handle;
return 0;
}
static inline int csip_lock_set(const uint16_t *conn_handles, size_t count)
{
(void)conn_handles;
(void)count;
return -ENOTSUP;
}
static inline int csip_release_set(const uint16_t *conn_handles, size_t count)
{
(void)conn_handles;
(void)count;
return -ENOTSUP;
}
#endif /* TMAP_CEN_PEER_COUNT > 1 */
int mcp_server_init(void);
@@ -73,10 +158,28 @@ int ccp_server_init(void);
int vcp_vol_ctlr_init(void);
int vcp_vol_ctlr_discover(void);
int vcp_vol_ctlr_discover(uint16_t conn_handle);
void vcp_vol_ctlr_streams_started(void);
void vcp_vol_ctlr_streams_stopped(void);
void vcp_vol_ctlr_forget(uint16_t conn_handle);
void vcp_vol_ctlr_reset(void);
int vcp_vol_ctlr_set_volume(uint8_t volume);
esp_ble_audio_cap_set_type_t set_type_get(void);
bool set_member_fill(esp_ble_audio_cap_set_member_t *member,
esp_ble_audio_cap_set_type_t type,
uint16_t conn_handle);
int cap_initiator_init(void);
int cap_initiator_setup(void);
int unicast_group_delete(void);
void peer_forget(uint16_t conn_handle);
void cap_initiator_reset(void);

View File

@@ -7,17 +7,191 @@
*/
#include <errno.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "tmap_central.h"
static esp_ble_audio_vcp_vol_ctlr_t *vcp_vol_ctlr;
/* VCS control-point writes carry a change counter that must match the server's.
* After discovery the local counter is 0 while the server may have incremented it
* during a previous session (e.g. before the central rebooted). Read the state
* first to sync the counter, then issue the demo mute from the state callback.
*/
static bool initial_state_read_pending;
/* A TWS pair must change volume on both earbuds together, so commands go through
* the CAP commander. It refuses until every member has a volume controller, and a
* VCS write needs the change counter synced, hence the per-member state read.
*
* Kept per member rather than as a count: a member that keeps its link while
* another one is replaced keeps its controller and its synced counter too. */
struct vol_ctlr_slot {
uint16_t conn_handle;
esp_ble_audio_vcp_vol_ctlr_t *ctlr;
bool state_read;
};
static struct vol_ctlr_slot vol_ctlrs[TMAP_CEN_PEER_COUNT];
static bool streams_started;
/* Walks the set through one commander procedure per tick, so the log shows both
* earbuds following every step of a single procedure rather than being written
* one at a time.
*
* This is application behaviour, so it gets an application task rather than
* borrowing one of the stack's. Not an esp_timer callback either: that task is
* shared with the BLE stack's own timers — the ISO TX schedulers post from it
* every SDU interval — and it is serial, so a GATT write chain dispatched there
* would sit in front of them. The audio APIs take the host lock themselves
* (esp_ble_audio_cap_commander_* -> bt_cap_commander_*_safe -> k_lock), which is
* what makes calling them from here safe. */
#define VOLUME_PERIOD_MS (5 * 60 * 1000) /* 5 minutes */
#define VOLUME_TASK_STACK 3072
#define VOLUME_TASK_PRIO 5
static const struct {
bool is_mute;
uint8_t value; /* volume level, or mute on/off */
} volume_steps[] = {
{ false, 32 },
{ false, 96 },
{ false, 160 },
{ false, 224 },
{ true, 1 },
{ true, 0 },
};
static size_t volume_index;
/* Written here, cleared from the commander callback in the audio task. */
static volatile bool volume_busy;
static volatile bool volume_running;
static struct vol_ctlr_slot *slot_by_handle(uint16_t conn_handle)
{
for (size_t i = 0; i < ARRAY_SIZE(vol_ctlrs); i++) {
if (vol_ctlrs[i].ctlr != NULL && vol_ctlrs[i].conn_handle == conn_handle) {
return &vol_ctlrs[i];
}
}
return NULL;
}
static struct vol_ctlr_slot *slot_by_ctlr(const esp_ble_audio_vcp_vol_ctlr_t *ctlr)
{
for (size_t i = 0; i < ARRAY_SIZE(vol_ctlrs); i++) {
if (vol_ctlrs[i].ctlr == ctlr) {
return &vol_ctlrs[i];
}
}
return NULL;
}
static int set_volume_mute(bool mute)
{
esp_ble_audio_cap_set_member_t members[TMAP_CEN_PEER_COUNT] = {0};
esp_ble_audio_cap_commander_change_volume_mute_state_param_t param = {0};
size_t count;
int err;
count = set_member_count();
if (count == 0) {
return -ENOTCONN;
}
param.type = set_type_get();
for (size_t i = 0; i < count; i++) {
if (!set_member_fill(&members[i], param.type, set_member_handle(i))) {
ESP_LOGE(TAG, "No set member for handle %u", set_member_handle(i));
return -ENOENT;
}
}
param.members = members;
param.count = count;
param.mute = mute;
err = esp_ble_audio_cap_commander_change_volume_mute_state(&param);
if (err) {
ESP_LOGE(TAG, "Failed to %s the set, err %d", mute ? "mute" : "unmute", err);
return err;
}
ESP_LOGI(TAG, "Changing volume mute state of %zu member(s) to %u", count, mute);
return 0;
}
static void volume_tick(void)
{
size_t step = volume_index;
int err;
/* The commander runs one procedure at a time, so a tick is skipped rather
* than piled on top of a set that has not answered yet. */
if (volume_busy) {
ESP_LOGW(TAG, "Previous volume procedure still running, skipping a step");
return;
}
volume_index = (volume_index + 1) % ARRAY_SIZE(volume_steps);
/* Marked busy before issuing: a procedure that fails early reports back
* from inside the call, and that callback must not be overwritten here. */
volume_busy = true;
if (volume_steps[step].is_mute) {
err = set_volume_mute(volume_steps[step].value != 0);
} else {
err = vcp_vol_ctlr_set_volume(volume_steps[step].value);
}
if (err) {
volume_busy = false;
}
}
static void volume_task(void *arg)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(VOLUME_PERIOD_MS));
if (volume_running) {
volume_tick();
}
}
}
static void volume_stop(void)
{
volume_running = false;
volume_busy = false;
}
/* Needs every VCS state read and the streams up, in either order. */
static void volume_try_start(void)
{
size_t count = set_member_count();
if (volume_running || !streams_started || count == 0) {
return;
}
for (size_t i = 0; i < count; i++) {
const struct vol_ctlr_slot *slot = slot_by_handle(set_member_handle(i));
if (slot == NULL || !slot->state_read) {
return;
}
}
volume_index = 0;
volume_busy = false;
volume_running = true;
ESP_LOGI(TAG, "Driving the volume of %zu member(s) every %u s",
count, VOLUME_PERIOD_MS / 1000);
}
static void vcs_discover_cb(esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr, int err,
uint8_t vocs_count, uint8_t aics_count)
@@ -27,13 +201,12 @@ static void vcs_discover_cb(esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr, int err,
return;
}
ESP_LOGI(TAG, "VCP discovery done");
ESP_LOGI(TAG, "VCP discovery done, vocs %u aics %u", vocs_count, aics_count);
initial_state_read_pending = true;
/* Control-point writes carry a change counter: read the state to sync it. */
err = esp_ble_audio_vcp_vol_ctlr_read_state(vol_ctlr);
if (err) {
ESP_LOGE(TAG, "Failed to read VCS state, err %d", err);
initial_state_read_pending = false;
}
}
@@ -49,22 +222,21 @@ static void vcs_write_cb(esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr, int err)
static void vcs_state_cb(esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr,
int err, uint8_t volume, uint8_t mute)
{
struct vol_ctlr_slot *slot;
if (err) {
ESP_LOGE(TAG, "VCP state cb failed, err %d", err);
initial_state_read_pending = false;
return;
}
ESP_LOGI(TAG, "VCP state cb done, volume %u mute %u", volume, mute);
if (initial_state_read_pending) {
initial_state_read_pending = false;
err = esp_ble_audio_vcp_vol_ctlr_mute(vol_ctlr);
if (err) {
ESP_LOGE(TAG, "Failed to send mute command, err %d", err);
}
slot = slot_by_ctlr(vol_ctlr);
if (slot != NULL) {
slot->state_read = true;
}
volume_try_start();
}
static void vcs_flags_cb(esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr,
@@ -90,24 +262,131 @@ static esp_ble_audio_vcp_vol_ctlr_cb_t vcp_cbs = {
.flags = vcs_flags_cb,
};
int vcp_vol_ctlr_discover(void)
/* One callback per procedure, not per member: conn names the member that failed. */
static void volume_changed_cb(esp_ble_conn_t *conn, int err)
{
uint16_t conn_handle;
if (err) {
ESP_LOGE(TAG, "Set volume failed on handle %u, err %d",
(conn != NULL) ? conn->handle : CONN_HANDLE_INIT, err);
} else {
ESP_LOGI(TAG, "Set volume changed");
}
volume_busy = false;
}
static void volume_mute_changed_cb(esp_ble_conn_t *conn, int err)
{
if (err) {
ESP_LOGE(TAG, "Set volume mute failed on handle %u, err %d",
(conn != NULL) ? conn->handle : CONN_HANDLE_INIT, err);
} else {
ESP_LOGI(TAG, "Set volume mute changed");
}
volume_busy = false;
}
static esp_ble_audio_cap_commander_cb_t cap_commander_cbs = {
.volume_changed = volume_changed_cb,
.volume_mute_changed = volume_mute_changed_cb,
};
int vcp_vol_ctlr_discover(uint16_t conn_handle)
{
esp_ble_audio_vcp_vol_ctlr_t *vol_ctlr;
struct vol_ctlr_slot *slot;
int err;
conn_handle = default_conn_handle_get();
if (conn_handle == CONN_HANDLE_INIT) {
ESP_LOGE(TAG, "%s, not connected", __func__);
return -ENOTCONN;
}
err = esp_ble_audio_vcp_vol_ctlr_discover(conn_handle, &vcp_vol_ctlr);
slot = slot_by_ctlr(NULL);
if (slot == NULL) {
ESP_LOGE(TAG, "No volume controller slot for handle %u", conn_handle);
return -ENOMEM;
}
err = esp_ble_audio_vcp_vol_ctlr_discover(conn_handle, &vol_ctlr);
if (err) {
ESP_LOGE(TAG, "Failed to discover vcp vol ctlr, err %d", err);
ESP_LOGE(TAG, "Failed to discover vcp vol ctlr on handle %u, err %d", conn_handle, err);
return err;
}
ESP_LOGI(TAG, "VCP volume controller discovering");
slot->conn_handle = conn_handle;
slot->ctlr = vol_ctlr;
slot->state_read = false;
ESP_LOGI(TAG, "VCP volume controller discovering: handle %u", conn_handle);
return 0;
}
void vcp_vol_ctlr_forget(uint16_t conn_handle)
{
struct vol_ctlr_slot *slot = slot_by_handle(conn_handle);
if (slot != NULL) {
memset(slot, 0, sizeof(*slot));
}
}
void vcp_vol_ctlr_streams_started(void)
{
streams_started = true;
volume_try_start();
}
/* The set is no longer whole, so it is no longer something to drive as one. */
void vcp_vol_ctlr_streams_stopped(void)
{
streams_started = false;
volume_stop();
}
void vcp_vol_ctlr_reset(void)
{
volume_stop();
memset(vol_ctlrs, 0, sizeof(vol_ctlrs));
streams_started = false;
}
int vcp_vol_ctlr_set_volume(uint8_t volume)
{
esp_ble_audio_cap_set_member_t members[TMAP_CEN_PEER_COUNT] = {0};
esp_ble_audio_cap_commander_change_volume_param_t param = {0};
size_t count;
int err;
count = set_member_count();
if (count == 0) {
return -ENOTCONN;
}
param.type = set_type_get();
for (size_t i = 0; i < count; i++) {
if (!set_member_fill(&members[i], param.type, set_member_handle(i))) {
return -ENOENT;
}
}
param.members = members;
param.count = count;
param.volume = volume;
err = esp_ble_audio_cap_commander_change_volume(&param);
if (err) {
ESP_LOGE(TAG, "Failed to set set volume, err %d", err);
return err;
}
ESP_LOGI(TAG, "Changing volume of %zu member(s) to %u", count, volume);
return 0;
}
@@ -122,6 +401,18 @@ int vcp_vol_ctlr_init(void)
return err;
}
err = esp_ble_audio_cap_commander_register_cb(&cap_commander_cbs);
if (err) {
ESP_LOGE(TAG, "Failed to register cap commander cb, err %d", err);
return err;
}
if (xTaskCreate(volume_task, "volume", VOLUME_TASK_STACK, NULL,
VOLUME_TASK_PRIO, NULL) != pdPASS) {
ESP_LOGE(TAG, "Failed to create the volume task");
return -ENOMEM;
}
ESP_LOGI(TAG, "VCP volume controller initialized");
return 0;

View File

@@ -9,18 +9,21 @@ 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_ACL_CONNECTIONS=2
CONFIG_BT_GATT_MAX_SR_PROFILES=12
CONFIG_BT_GATTC_NOTIF_REG_MAX=64
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_BT_ISO_MAX_CIG=1
CONFIG_BT_TMAP=y
CONFIG_BT_CAP_INITIATOR=y
CONFIG_BT_CAP_COMMANDER=y
CONFIG_BT_CSIP_SET_COORDINATOR=y
CONFIG_BT_BAP_UNICAST_CLIENT=y
CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT=2
CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SRC_COUNT=2
CONFIG_BT_BAP_UNICAST_CLIENT_GROUP_STREAM_COUNT=2
CONFIG_BT_VCP_VOL_CTLR=y
CONFIG_BT_MPL=y

View File

@@ -6,7 +6,7 @@ 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_CONNECTIONS=2
CONFIG_BT_NIMBLE_MAX_CCCDS=64
CONFIG_BT_NIMBLE_ISO=y
CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y

View File

@@ -22,11 +22,29 @@ The example uses the ESP-BLE-AUDIO library pieces for: BAP Unicast Server with P
idf.py menuconfig
```
> **This board must be built to match the topology [tmap_central](../central/) is built for.** Neither side negotiates it: the central looks for a fixed number of peers, each exposing a fixed number of sink ASEs with a specific channel. A mismatch stalls in discovery or fails to build the unicast group.
| Central **Unicast topology** | Boards of this example | **Earbuds type** | **Earbud Location** |
| --- | --- | --- | --- |
| `1 peripheral, 1 CIS (TX+RX)` | 1 | `Single headset` | `Mono` |
| `1 peripheral, 2 CISes (TX+RX, TX)` **(default)** | 1 | `Single headset` **(default)** | `Stereo` **(default)** |
| `2 peripherals (coordinated set), 2 CISes (TX+RX, TX)` | 2 | `Duo headset` on both | `Left` on one, `Right` on the other |
Under **Example: TMAP Peripheral (CT & UMR)**:
* **Earbuds type** — `Single ear headset` or `Duo headset`. Duo selects `BT_CSIP_SET_MEMBER` and `BT_CAP_ACCEPTOR_SET_MEMBER`, enabling CSIS and adding RSI to the advertisement.
* **Device rank in set** — integer 12, only when Duo is selected; written into the CSIS register parameters.
* **Earbud Location** — `Left Ear` or `Right Ear`. Adds `FRONT_LEFT` / `FRONT_RIGHT` to the PACS sink/source location bitmap.
* **Earbuds type** — `Single headset` (standalone: no CSIS, no RSI) or `Duo headset` (selects `BT_CSIP_SET_MEMBER` and `BT_CAP_ACCEPTOR_SET_MEMBER`, enabling CSIS and adding RSI to the advertisement).
* **Earbud Location** — sets the PACS sink and source location bitmap, and **this is what the central uses to pick the channel it sends here**. The options offered follow the earbuds type, so the two choices together can only express a topology the central supports:
* with `Single headset` — a whole device, so mono or stereo:
* `Mono` — empty bitmap; the central sends one mono stream. Pair with the central's `1 peripheral, 1 CIS` topology.
* `Stereo` (default) — `FRONT_LEFT | FRONT_RIGHT`, so one board renders both channels. Matches the central's default `1 peripheral, 2 CISes` topology.
* with `Duo headset` — one of a pair, so left or right:
* `Left` (default) — `FRONT_LEFT`, and rank 1.
* `Right``FRONT_RIGHT`, and rank 2.
* **Device rank in set** — integer 12, only when Duo is selected; written into the CSIS register parameters. The CSIS instance is registered as lockable, which requires a non-zero rank that is **unique** within the set: duplicate ranks break the CSIP ordered access procedure the central locks the set with. The default follows Earbud Location (left → 1, right → 2), and a set member can only be left or right, so building the two boards for their respective ears already gives them distinct ranks. Rank orders the set lock and decides which member the central gives its bidirectional CIS to; it does **not** say which ear this board is.
The ASE counts come from `sdkconfig.defaults`: two sink ASEs and one source ASE, which is what the central's topologies need (two sinks on one device, or one sink per device, plus a source for the bidirectional CIS). Each ASE gets its own stream slot, RX metrics and — for source ASEs — its own TX scheduler, buffer and sequence number.
`CONFIG_BT_ASCS_MAX_ACTIVE_ASES` is pinned to 3 there as well, and it is **not** the same thing as the CIS count. It bounds how many ASEs may be non-idle at once, and a bidirectional CIS carries two ASEs on one CIS — so `1 peripheral, 2 CISes` needs 3 active ASEs (two sinks plus one source) over only 2 CIS. Its Kconfig default is `BT_ISO_MAX_CHAN`, i.e. 2, which is one short; leaving it at the default makes the third codec configuration fail with ASCS response `0x0D` (Insufficient Resources) and `AscsAseNewFail` in the library log, and the central reports `Unicast start completed, err -77`.
### Security & Pairing
@@ -52,6 +70,9 @@ idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32h4;sdkco
For `esp32s31`, replace the chip overlay accordingly.
A coordinated set needs this example on two boards, one built for the `Left`
ear and the other for `Right`.
(Exit serial monitor with `Ctrl-]`.)
## Example Flow
@@ -64,9 +85,10 @@ For `esp32s31`, replace the chip overlay accordingly.
6. TMAP discovery records whether the peer is CG and/or UMS, then `ccp_discover_tbs` triggers TBS discovery.
7. After GTBS is discovered the TBS URI list is read; `read_uri_schemes_string_cb` saves the first URI and chains into `mcp_discover_mcs`.
8. MCS discovery walks player name → track title → duration → position → playback speed → seeking speed → playing order → orders supported → media state → opcodes → CCID.
9. ASCS callbacks accept config/QoS/enable/start; the `enabled` stream op auto-starts sink streams (receiver-start-ready); RX metrics are reset on start and updated per received SDU.
10. Helpers `initiate_call`/`terminate_call` (gated on peer CG) and `play_media`/`pause_media` (gated on peer UMS) issue TBS and MCC commands.
11. On disconnect, the connection handle is cleared and advertising restarts.
9. ASCS callbacks accept config/QoS/enable/start; the `enabled` stream op auto-starts sink streams (receiver-start-ready); RX metrics are reset on start and updated per received SDU, per sink ASE.
10. A source ASE that reaches "started" arms its own TX scheduler at the QoS interval and transmits dummy SDUs back to the central — this is the uplink half of the central's bidirectional CIS. The scheduler and buffer are released when the stream stops, when its CIS disconnects, or when the ASE is released.
11. Helpers `initiate_call`/`terminate_call` (gated on peer CG) and `play_media`/`pause_media` (gated on peer UMS) issue TBS and MCC commands.
12. On disconnect, the connection handle is cleared and advertising restarts.
## Expected Log
@@ -78,7 +100,7 @@ TMAP_PER: CSIP set member initialized
TMAP_PER: PRSI: 0x<hex>
TMAP_PER: vcp vol renderer, vocs_cnt <n> aics_cnt <n>
TMAP_PER: VCP volume renderer initialized
TMAP_PER: BAP unicast server initialized
TMAP_PER: BAP unicast server initialized: 2 sink / 1 source ASE
TMAP_PER: CCP call controller initialized
TMAP_PER: MCP controller initialized
TMAP_PER: Advertising started (handle 0)
@@ -102,7 +124,7 @@ TMAP_PER: Read media state succeeded, state <s>
TMAP_PER: Read content control id succeeded, ccid <c>
```
ASCS / unicast streaming:
ASCS / unicast streaming. Which streams appear depends on the topology the central was built for — a second `SNK #1` when it drives two CISes onto this device, and `SRC #0` whenever it pairs a source into the bidirectional CIS:
```
TMAP_PER: [SNK] Config request:
@@ -111,12 +133,17 @@ TMAP_PER: [SNK #0] Enable request (meta_len <n>)
TMAP_PER: [SNK #0] Stream enabled
TMAP_PER: [SNK #0] Start request
TMAP_PER: [SNK #0] Stream started
TMAP_PER: [SNK #0] RX: <count> packets
TMAP_PER: [SRC #0] Stream started
TMAP_PER: [SRC #0] TX: <count> packets
TMAP_PER: [SNK #0] Stop request
TMAP_PER: [SNK #0] Stream stopped, reason 0x<rr>
TMAP_PER: [SNK #0] Disable request
TMAP_PER: [SNK #0] Release request
```
Each ASE keeps its own counters, so `[SNK #0]` and `[SNK #1]` advance independently rather than sharing one total.
VCP / call control / disconnect:
```
@@ -144,11 +171,19 @@ Tag is `TMAP_PER`.
## Peer Pairing
Run [tmap_central](../central/) on a second board.
Run [tmap_central](../central/) on another board. How many boards of this example are needed, and how they must be configured, follows the topology the central is built for:
| Central topology | Boards of this example | Configuration |
| --- | --- | --- |
| `1P_1CIS` | 1 | `Single headset` with the `Mono` location, so the central sends one mono stream |
| `1P_2CIS` | 1 | `Stereo` location, so PACS advertises both channels |
| `2P_2CIS` | 2 | `Duo headset` on both, `Left` on one and `Right` on the other — the rank default follows, giving them 1 and 2 |
1. Flash this peripheral and start it; advertising begins on handle 0.
2. Flash and start the central; it scans for TMAS+UMR and connects to this device.
2. Flash and start the central; it scans for TMAS+UMR (plus a matching CSIS RSI in a coordinated-set build) and connects to this device.
3. After pairing and MTU exchange, both sides complete TMAP discovery; this peripheral then discovers GTBS and reads the URI list.
4. The MCC chain reads the central's media proxy state; the central's CAP initiator configures and starts the sink stream.
5. The peripheral auto-starts the sink stream from the `enabled` callback and logs received SDU metrics.
4. The MCC chain reads the central's media proxy state; the central's CAP initiator configures and starts the streams.
5. The peripheral auto-starts each sink stream from the `enabled` callback and logs its received SDU metrics; a configured source stream transmits back on the bidirectional CIS.
6. Use `initiate_call`/`terminate_call` to drive the central's TBS, and `play_media`/`pause_media` to send PLAY/PAUSE via MCC.
For the two-board set, the CSIS instance is lockable, so the central takes the set lock by rank before configuring the streams and releases it afterwards. Each board logs `Client <p> locked the lock` / `... released the lock`.

View File

@@ -7,11 +7,17 @@ menu "Example: TMAP Peripheral (CT & UMR)"
choice EXAMPLE_TMAP_PER_TYPE_CHOICE
prompt "Earbuds type"
default EXAMPLE_TMAP_PER_SINGLE
help
Select the Earbuds Type to compile.
Select the Earbuds Type to compile. Together with Earbud Location this
has to match the topology tmap_central is built for; the defaults of
both examples pair up as "1 peripheral, 2 CISes".
config EXAMPLE_TMAP_PER_SINGLE
bool "Single ear headset"
bool "Single headset"
help
A standalone acceptor: no CSIS instance and no RSI. Which channels
it renders is the separate choice below.
config EXAMPLE_TMAP_PER_DUO
bool "Duo headset"
@@ -20,26 +26,66 @@ menu "Example: TMAP Peripheral (CT & UMR)"
endchoice
choice EXAMPLE_TMAP_PER_LOCATION
prompt "Earbud Location"
default EXAMPLE_TMAP_PER_STEREO if EXAMPLE_TMAP_PER_SINGLE
default EXAMPLE_TMAP_PER_LEFT
help
Which channels this device renders, published as the PACS Sink and
Source Audio Locations, and what the central reads to decide the
channel it sends here. The options follow the earbuds type: a single
headset is a whole device, so it is mono or stereo, while a set member
is one of a pair, so it is left or right.
config EXAMPLE_TMAP_PER_MONO
bool "Mono"
depends on !EXAMPLE_TMAP_PER_DUO
help
Publish an empty Audio Locations bitmap, which BAP reads as "mono,
no preference", so the central sends one mono stream. This is what
a one-ear headset renders; pair it with the central's
"1 peripheral, 1 CIS" topology.
config EXAMPLE_TMAP_PER_STEREO
bool "Stereo"
depends on !EXAMPLE_TMAP_PER_DUO
help
Publish front left and front right, so one device renders both
channels. This is the default, matching the central's default
"1 peripheral, 2 CISes" topology, which configures both channels
on this board.
config EXAMPLE_TMAP_PER_LEFT
bool "Left"
depends on EXAMPLE_TMAP_PER_DUO
config EXAMPLE_TMAP_PER_RIGHT
bool "Right"
depends on EXAMPLE_TMAP_PER_DUO
endchoice
if EXAMPLE_TMAP_PER_DUO
config EXAMPLE_TMAP_PER_SET_RANK
int "Device rank in set"
range 1 2
default 1 if EXAMPLE_TMAP_PER_LEFT
default 2 if EXAMPLE_TMAP_PER_RIGHT
default 1
help
Rank of this device in set.
Rank of this device in set. A lockable coordinated set requires a
non-zero rank that is unique among its members, so the two boards
must end up with rank 1 and rank 2 — duplicate ranks break the
CSIP ordered access procedure the central locks the set with.
The default follows Earbud Location (left is 1, right is 2), and a
set member can only be left or right, so building the two boards
for their respective ears already gives them distinct ranks.
Rank orders the set lock, and decides which member the central's
"2 peripherals" topology gives the bidirectional CIS to. It does
not say which ear this board is — that comes from Earbud Location.
endif
choice EXAMPLE_TMAP_PER_LOCATION
prompt "Earbud Location"
help
Select the Earbud location.
config EXAMPLE_TMAP_PER_LEFT
bool "Left Ear"
config EXAMPLE_TMAP_PER_RIGHT
bool "Right Ear"
endchoice
endmenu

View File

@@ -9,6 +9,7 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <errno.h>
@@ -51,13 +52,22 @@ static struct audio_sink {
static size_t configured_sink_stream_count;
/* Per source ASE: a shared sequence number jumps by the stream count. */
static struct audio_source {
esp_ble_audio_bap_stream_t *stream;
uint16_t seq_num;
esp_ble_audio_bap_stream_t *stream;
example_audio_tx_scheduler_t scheduler;
uint16_t seq_num;
uint8_t *data;
uint8_t index;
bool running;
} source_streams[CONFIG_BT_ASCS_MAX_ASE_SRC_COUNT];
static size_t configured_source_stream_count;
static void unicast_server_tx(struct audio_source *src);
static void source_tx_stop(esp_ble_audio_bap_stream_t *stream);
static const esp_ble_audio_bap_qos_cfg_pref_t qos_pref =
ESP_BLE_AUDIO_BAP_QOS_CFG_PREF(true, /* Unframed PDUs supported */
ESP_BLE_ISO_PHY_2M, /* Preferred Target PHY */
@@ -116,6 +126,17 @@ static int stream_index(const esp_ble_audio_bap_stream_t *stream)
return -1;
}
static struct audio_source *source_by_stream(const esp_ble_audio_bap_stream_t *stream)
{
for (size_t i = 0; i < ARRAY_SIZE(source_streams); i++) {
if (source_streams[i].stream == stream) {
return &source_streams[i];
}
}
return NULL;
}
static int config_cb(esp_ble_conn_t *conn,
const esp_ble_audio_bap_ep_t *ep,
esp_ble_audio_dir_t dir,
@@ -364,7 +385,10 @@ static int release_cb(esp_ble_audio_bap_stream_t *stream,
for (size_t i = 0; i < ARRAY_SIZE(source_streams); i++) {
if (source_streams[i].stream == stream) {
memset(&source_streams[i], 0, sizeof(source_streams[i]));
/* Per-session fields only; the work item and index persist. */
source_tx_stop(stream);
source_streams[i].stream = NULL;
source_streams[i].seq_num = 0;
if (configured_source_stream_count > 0) {
configured_source_stream_count--;
}
@@ -416,21 +440,155 @@ static void stream_enabled(esp_ble_audio_bap_stream_t *stream)
static void stream_started(esp_ble_audio_bap_stream_t *stream)
{
struct audio_source *src;
int err;
ESP_LOGI(TAG, "[%s #%d] Stream started",
stream_dir_str(stream), stream_index(stream));
for (size_t i = 0; i < ARRAY_SIZE(sink_streams); i++) {
if (sink_streams[i].stream == stream) {
example_audio_rx_metrics_reset(&sink_streams[i].rx_metrics);
break;
return;
}
}
/* Source stream: feed the uplink half of the bidirectional CIS. */
src = source_by_stream(stream);
if (src == NULL) {
return;
}
if (stream->qos == NULL || stream->qos->sdu == 0 || stream->qos->interval == 0) {
ESP_LOGE(TAG, "[SRC #%u] Invalid stream qos", src->index);
return;
}
if (src->data == NULL) {
src->data = calloc(1, stream->qos->sdu);
if (src->data == NULL) {
ESP_LOGE(TAG, "[SRC #%u] Failed to alloc TX buffer, SDU %u",
src->index, stream->qos->sdu);
return;
}
}
src->seq_num = 0;
example_audio_tx_scheduler_reset(&src->scheduler);
/* Note: esp timer is not accurate enough */
err = example_audio_tx_scheduler_start(&src->scheduler, stream->qos->interval);
if (err) {
ESP_LOGE(TAG, "[SRC #%u] Failed to start tx scheduler, err %d", src->index, err);
return;
}
src->running = true;
unicast_server_tx(src);
}
/* Keyed on the stream, not the endpoint state: the ISO disconnected callback runs
* before the endpoint leaves streaming. */
static void source_tx_stop(esp_ble_audio_bap_stream_t *stream)
{
struct audio_source *src;
int err;
src = source_by_stream(stream);
if (src == NULL || !src->running) {
return;
}
err = example_audio_tx_scheduler_stop(&src->scheduler);
if (err) {
ESP_LOGE(TAG, "[SRC #%u] Failed to stop tx scheduler, err %d", src->index, err);
}
src->running = false;
if (src->data != NULL) {
free(src->data);
src->data = NULL;
}
}
static void stream_stopped(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
ESP_LOGI(TAG, "[%s #%d] Stream stopped, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
source_tx_stop(stream);
}
static void stream_disconnected(esp_ble_audio_bap_stream_t *stream, uint8_t reason)
{
ESP_LOGI(TAG, "[%s #%d] ISO disconnected, reason 0x%02x",
stream_dir_str(stream), stream_index(stream), reason);
source_tx_stop(stream);
}
static void stream_sent(esp_ble_audio_bap_stream_t *stream, void *user_data)
{
struct audio_source *src;
char name[24];
src = source_by_stream(stream);
if (src == NULL) {
return;
}
snprintf(name, sizeof(name), "SRC #%u", src->index);
example_audio_tx_scheduler_on_sent(&src->scheduler, user_data, TAG, name);
}
static void unicast_server_tx(struct audio_source *src)
{
esp_ble_audio_bap_ep_info_t ep_info = {0};
esp_ble_audio_bap_stream_t *stream;
esp_err_t err;
stream = src->stream;
if (stream == NULL || stream->ep == NULL) {
return;
}
err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info);
if (err) {
return;
}
if (ep_info.state != ESP_BLE_AUDIO_BAP_EP_STATE_STREAMING) {
return;
}
if (stream->qos == NULL || stream->qos->sdu == 0) {
ESP_LOGE(TAG, "[SRC #%u] Invalid stream qos", src->index);
return;
}
if (src->data == NULL) {
ESP_LOGE(TAG, "[SRC #%u] TX buffer unavailable, SDU %u", src->index, stream->qos->sdu);
return;
}
memset(src->data, (uint8_t)src->seq_num, stream->qos->sdu);
err = esp_ble_audio_bap_stream_send(stream, src->data, stream->qos->sdu, src->seq_num);
if (err) {
/* Backpressure is normal; a stuck stream shows up as its TX count stalling. */
return;
}
src->seq_num++;
}
/* Each stream has its own timer, so this only ever feeds its own stream. */
static void tx_scheduler_cb(void *arg)
{
unicast_server_tx(arg);
}
static void stream_recv(esp_ble_audio_bap_stream_t *stream,
@@ -455,10 +613,12 @@ static void stream_recv(esp_ble_audio_bap_stream_t *stream,
}
static esp_ble_audio_bap_stream_ops_t stream_ops = {
.enabled = stream_enabled,
.started = stream_started,
.stopped = stream_stopped,
.recv = stream_recv,
.enabled = stream_enabled,
.started = stream_started,
.stopped = stream_stopped,
.recv = stream_recv,
.sent = stream_sent,
.disconnected = stream_disconnected,
};
static esp_ble_audio_bap_unicast_server_register_param_t param = {
@@ -497,12 +657,12 @@ int bap_unicast_sr_init(void)
return err;
}
#if CONFIG_EXAMPLE_TMAP_PER_LEFT
#if CONFIG_EXAMPLE_TMAP_PER_LEFT || CONFIG_EXAMPLE_TMAP_PER_STEREO
location |= ESP_BLE_AUDIO_LOCATION_FRONT_LEFT;
#endif /* CONFIG_EXAMPLE_TMAP_PER_LEFT */
#if CONFIG_EXAMPLE_TMAP_PER_RIGHT
#endif /* CONFIG_EXAMPLE_TMAP_PER_LEFT || CONFIG_EXAMPLE_TMAP_PER_STEREO */
#if CONFIG_EXAMPLE_TMAP_PER_RIGHT || CONFIG_EXAMPLE_TMAP_PER_STEREO
location |= ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT;
#endif /* CONFIG_EXAMPLE_TMAP_PER_RIGHT */
#endif /* CONFIG_EXAMPLE_TMAP_PER_RIGHT || CONFIG_EXAMPLE_TMAP_PER_STEREO */
#if CONFIG_BT_PAC_SNK
/* Register CT required capabilities */
@@ -562,7 +722,21 @@ int bap_unicast_sr_init(void)
esp_ble_audio_bap_stream_cb_register(&streams[i], &stream_ops);
}
ESP_LOGI(TAG, "BAP unicast server initialized");
for (size_t i = 0; i < ARRAY_SIZE(source_streams); i++) {
/* Own timer per stream, fed with its own context. */
source_streams[i].index = (uint8_t)i;
err = example_audio_tx_scheduler_init(&source_streams[i].scheduler,
tx_scheduler_cb,
&source_streams[i]);
if (err) {
ESP_LOGE(TAG, "Failed to init tx scheduler[%zu], err %d", i, err);
return err;
}
}
ESP_LOGI(TAG, "BAP unicast server initialized: %u sink / %u source ASE",
CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT, CONFIG_BT_ASCS_MAX_ASE_SRC_COUNT);
return 0;
}

View File

@@ -36,7 +36,7 @@ static esp_ble_gap_ext_adv_params_t ext_adv_params = {
.secondary_phy = ESP_BLE_GAP_PHY_2M,
.sid = ADV_SID,
.scan_req_notif = false,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.own_addr_type = BLE_ADDR_TYPE_RANDOM,
.tx_power = ADV_TX_POWER,
};
@@ -48,6 +48,10 @@ 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_EXT_ADV_SET_RAND_ADDR_COMPLETE_EVT:
adv_op_status = param->ext_adv_set_rand_addr.status;
xSemaphoreGive(adv_sem);
break;
case ESP_GAP_BLE_EXT_ADV_SET_PARAMS_COMPLETE_EVT:
adv_op_status = param->ext_adv_set_params.status;
xSemaphoreGive(adv_sem);
@@ -107,10 +111,14 @@ int set_device_name(void)
int ext_adv_start(const uint8_t *ext_data, uint16_t ext_len)
{
esp_bd_addr_t addr = ADV_ADDR;
WAIT_API(esp_ble_gap_ext_adv_set_params(ADV_HANDLE, &ext_adv_params));
WAIT_API(esp_ble_gap_ext_adv_set_rand_addr(ADV_HANDLE, addr));
WAIT_API(esp_ble_gap_config_ext_adv_data_raw(ADV_HANDLE, ext_len, ext_data));
WAIT_API(esp_ble_gap_ext_adv_start(1, ext_adv_inst));
ESP_LOGI(TAG, "Advertising started (handle %u)", ADV_HANDLE);
ESP_LOGI(TAG, "Advertising started (handle %u) as %02x:%02x:%02x:%02x:%02x:%02x",
ADV_HANDLE, addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]);
return 0;
}

View File

@@ -21,7 +21,15 @@ static void csip_lock_changed_cb(esp_ble_conn_t *conn,
esp_ble_audio_csip_set_member_svc_inst_t *inst,
bool locked)
{
ESP_LOGI(TAG, "Client %p %s the lock", conn, locked ? "locked" : "released");
/* No conn means the server released it itself, on the lock timeout or from
* bt_csip_set_member_lock(), rather than a client asking for it. */
if (conn == NULL) {
ESP_LOGI(TAG, "Lock %s locally", locked ? "taken" : "released");
return;
}
ESP_LOGI(TAG, "Client on handle %u %s the lock", conn->handle,
locked ? "locked" : "released");
}
static uint8_t sirk_read_req_cb(esp_ble_conn_t *conn,
@@ -40,7 +48,9 @@ int csip_set_member_init(void)
esp_ble_audio_csip_set_member_register_param_t param = {
.set_size = 2,
.rank = CONFIG_EXAMPLE_TMAP_PER_SET_RANK,
.lockable = false,
/* Lockable so the coordinator can run the CSIP ordered access procedure
* (rank-ordered lock) around stream setup, as a real CAP initiator does. */
.lockable = true,
.sirk = CSIP_SIRK_DEBUG,
.cb = &csip_cb,
};

View File

@@ -62,12 +62,25 @@ int ext_adv_start(const uint8_t *ext_data, uint16_t ext_len)
{
struct ble_gap_ext_adv_params ext_params = {0};
struct os_mbuf *data = NULL;
const uint8_t addr[6] = ADV_ADDR;
uint8_t addr_le[6];
int err;
/* NimBLE takes the address least significant byte first. */
for (size_t i = 0; i < sizeof(addr); i++) {
addr_le[i] = addr[sizeof(addr) - 1 - i];
}
err = ble_hs_id_set_rnd(addr_le);
if (err) {
ESP_LOGE(TAG, "Failed to set the static random address, err %d", err);
return err;
}
ext_params.connectable = 1;
ext_params.scannable = 0;
ext_params.legacy_pdu = 0;
ext_params.own_addr_type = BLE_OWN_ADDR_PUBLIC;
ext_params.own_addr_type = BLE_OWN_ADDR_RANDOM;
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;

View File

@@ -37,6 +37,22 @@
#define ADV_HANDLE 0
#define ADV_SID 0
/* Advertise a static random address rather than whatever the factory
* programmed: two boards sharing an address corrupt every address-keyed lookup
* in the stack — bonding, the CSIS lock holder, the central's member dedup.
* The last byte is the set rank, which CSIS already requires to be unique
* within the set. The two most significant bits are 1, as static random needs.
*/
#if defined(CONFIG_EXAMPLE_TMAP_PER_SET_RANK)
#define ADV_ADDR_ID CONFIG_EXAMPLE_TMAP_PER_SET_RANK
#else
#define ADV_ADDR_ID 1
#endif
/* Most significant byte first, as esp_bd_addr_t and the logs print it. */
#define ADV_ADDR { 0xC0, 0xDE, 0x00, 0x00, 0x00, ADV_ADDR_ID }
#define ADV_TX_POWER 127
#define ADV_INTERVAL_MS 200

View File

@@ -19,6 +19,9 @@ CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_BT_TMAP=y
CONFIG_BT_CAP_ACCEPTOR=y
CONFIG_BT_BAP_UNICAST_SERVER=y
CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT=2
CONFIG_BT_ASCS_MAX_ASE_SRC_COUNT=1
CONFIG_BT_ASCS_MAX_ACTIVE_ASES=3
CONFIG_BT_PAC_SNK_NOTIFIABLE=y
CONFIG_BT_PAC_SNK_LOC_WRITEABLE=y
CONFIG_BT_PAC_SNK_LOC_NOTIFIABLE=y
@@ -40,8 +43,6 @@ CONFIG_BT_TBS_CLIENT_TERMINATE_CALL=y
CONFIG_BT_TBS_CLIENT_BEARER_URI_SCHEMES_SUPPORTED_LIST=y
CONFIG_BT_AUDIO_CODEC_CFG_MAX_METADATA_SIZE=60
CONFIG_EXAMPLE_TMAP_PER_DUO=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_FREERTOS_HZ=1000

View File

@@ -9,7 +9,9 @@
This is a raw BLE Connected Isochronous Stream (CIS) example operating directly at the ISO transport layer over either the NimBLE or Bluedroid host (selected at build time via Kconfig). It is **not** a BAP/CAP (BLE Audio profile) example — it does not implement Unicast Server/Client, ASCS, PACS, or any LC3 codec; it only exercises the underlying CIG/CIS plumbing.
The central scans for a peer advertising the name `CIS Peripheral`, opens an ACL link, optionally pairs (security level `ESP_BLE_ISO_SECURITY_NO_MITM`), creates a single-CIS CIG (10 ms SDU interval, 2M PHY, RTN 2, 120-byte SDU, sequential/unframed), connects the CIS, configures the input data path to the HCI in transparent format, and then drives a software TX scheduler that submits one SDU every 10 ms.
The central scans for a peer advertising the name `CIS Peripheral`, opens an ACL link, optionally pairs (security level `ESP_BLE_ISO_SECURITY_NO_MITM`), creates a **two-CIS** CIG (10 ms SDU interval, 2M PHY, RTN 2, 120-byte SDU, sequential/unframed), connects both CIS, configures each input data path to the HCI in transparent format, and then drives one software TX scheduler per CIS that submits one SDU every 10 ms.
Both CIS ride the same ACL connection to the same peer, which is what `esp_ble_iso_chan_connect()` expresses: it takes one ACL handle plus a channel count. Each stream is otherwise independent — its own timer, sequence numbering and TX counters — because the controller establishes them one after another, so a shared scheduler would send on a CIS that is not up yet and would report both streams under a single name.
The transmitted payload is a dummy buffer filled with the current sequence number byte — there is no real audio data, the example just demonstrates the ISO transport mechanics via the `esp_ble_iso_*` APIs.
@@ -26,7 +28,7 @@ The transmitted payload is a dummy buffer filled with the current sequence numbe
idf.py menuconfig
```
No build-time options — runtime defaults are baked into source.
No menuconfig options — runtime defaults are baked into source. The number of CIS is the `CIS_COUNT` macro in `main/main.c`; raising it also requires `CONFIG_BT_ISO_MAX_CHAN` in `sdkconfig.defaults` to be at least that value (the host default is 1, so a second CIS is rejected without it).
### Security & Pairing
@@ -60,9 +62,9 @@ For `esp32s31`, replace the chip overlay accordingly.
2. Start passive extended scanning for a device whose Complete Local Name is `CIS Peripheral`.
3. Cancel scan and create an ACL connection (interval 80 ms, supervision 5 s) once the target is matched.
4. On ACL connect, initiate pairing because the configured security level is `ESP_BLE_ISO_SECURITY_NO_MITM`.
5. After the security change, call `esp_ble_iso_cig_create` (one CIS, 10 ms latencies and SDU interval, sequential/unframed, SCA unknown) and `esp_ble_iso_chan_connect` over the ACL handle.
6. On CIS connect, set up the input data path (HCI / transparent) and start the periodic TX scheduler.
7. The scheduler invokes `esp_ble_iso_chan_send` every 10 ms with an incrementing sequence number on a 120-byte dummy SDU.
5. After the security change, call `esp_ble_iso_cig_create` (two CIS, 10 ms latencies and SDU interval, sequential/unframed, SCA unknown) and `esp_ble_iso_chan_connect` once for both channels over the same ACL handle.
6. The controller establishes the CIS one at a time. On each CIS connect, set up that channel's input data path (HCI / transparent) and start its own periodic TX scheduler.
7. Each scheduler invokes `esp_ble_iso_chan_send` every 10 ms on its own channel, with a per-CIS incrementing sequence number on a 120-byte dummy SDU.
## Expected Log
@@ -76,17 +78,22 @@ I CIS_CEN: Connected: handle <h> role <r> peer XX:XX:XX:XX:XX:XX
I CIS_CEN: Security: handle <h> level <l> bonded <b>
```
CIS setup and streaming phase (TX log emitted every `LOG_INTERVAL_PACKETS` SDUs by the shared utility):
CIS setup and streaming phase. The two CIS come up a couple of hundred milliseconds apart, and each reports its own count (TX log emitted every `LOG_INTERVAL_PACKETS` SDUs by the shared utility):
```
I CIS_CEN: [CIS #0] Connected
I CIS_CEN: [CIS #1] Connected
I CIS_CEN: [CIS #0] TX: <count> packets
I CIS_CEN: [CIS #1] TX: <count> packets
```
Disconnect path:
There should be no `IsoSendChanNotConn` errors in the gap between the two `Connected` lines: each CIS only starts its timer once it is established, so nothing is submitted to a stream that is still connecting.
Disconnect path (per CIS, then the ACL):
```
I CIS_CEN: [CIS #0] Disconnected, reason 0x<rr>
I CIS_CEN: [CIS #1] Disconnected, reason 0x<rr>
I CIS_CEN: Disconnected: handle <h> reason 0x<rr>
```
@@ -97,6 +104,6 @@ Run [cis_peripheral](../cis_peripheral/) on a second board.
1. Flash and run `cis_peripheral` first; it begins extended advertising as `CIS Peripheral`.
2. Flash and run `cis_central` on the second board; it scans and matches that name.
3. The central creates the ACL connection and initiates pairing.
4. After the security change, the central creates the CIG and connects the CIS.
5. Both sides set up their data paths (input on central, output on peripheral).
6. The central streams 120-byte SDUs every 10 ms; the peripheral reports `RX: <count> packets` periodically.
4. After the security change, the central creates the CIG and connects both CIS over that one ACL.
5. Both sides set up a data path per CIS (input on central, output on peripheral).
6. The central streams 120-byte SDUs every 10 ms on each CIS; the peripheral reports `RX: <count> packets` per CIS periodically.

View File

@@ -5,6 +5,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <string.h>
#include <assert.h>
@@ -31,17 +32,37 @@
#define CIS_PHY ESP_BLE_ISO_PHY_2M
#define CIS_RTN 2
#define CIS_SDU_SIZE 120
#define CIS_COUNT 2
static bool acl_connected;
static uint16_t iso_seq_num;
static uint8_t iso_data[CIS_SDU_SIZE];
static esp_ble_iso_cig_t *out_cig;
static example_iso_tx_scheduler_t tx_scheduler;
/* Each CIS is streamed independently: it comes up at its own time and carries
* its own sequence numbering, so it gets its own timer and TX counters instead
* of sharing one scheduler (which would report both under a single name). */
struct cis_ctx {
esp_ble_iso_chan_t chan;
example_iso_tx_scheduler_t scheduler;
uint16_t seq_num;
uint8_t data[CIS_SDU_SIZE];
char name[8];
};
static void iso_chan_send(void);
static struct cis_ctx cis_ctxs[CIS_COUNT];
static void iso_chan_send(struct cis_ctx *ctx);
static struct cis_ctx *ctx_by_chan(const esp_ble_iso_chan_t *chan)
{
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
if (&cis_ctxs[i].chan == chan) {
return &cis_ctxs[i];
}
}
return NULL;
}
static void iso_connected_cb(esp_ble_iso_chan_t *chan)
{
@@ -49,38 +70,40 @@ static void iso_connected_cb(esp_ble_iso_chan_t *chan)
.pid = ESP_BLE_ISO_DATA_PATH_HCI,
.format = ESP_BLE_ISO_CODING_FORMAT_TRANSPARENT,
};
struct cis_ctx *ctx = ctx_by_chan(chan);
esp_err_t err;
ESP_LOGI(TAG, "[CIS #0] Connected");
ESP_LOGI(TAG, "[%s] Connected", ctx->name);
err = esp_ble_iso_setup_data_path(chan, ESP_BLE_ISO_DATA_PATH_DIR_INPUT, &data_path);
if (err) {
ESP_LOGE(TAG, "[CIS #0] Failed to setup data path, err %d", err);
ESP_LOGE(TAG, "[%s] Failed to setup data path, err %d", ctx->name, err);
return;
}
iso_seq_num = 0;
example_iso_tx_scheduler_reset(&tx_scheduler);
ctx->seq_num = 0;
example_iso_tx_scheduler_reset(&ctx->scheduler);
/* Note: esp timer is not accurate enough */
err = example_iso_tx_scheduler_start(&tx_scheduler, CIG_SDU_INTERVAL_US);
err = example_iso_tx_scheduler_start(&ctx->scheduler, CIG_SDU_INTERVAL_US);
if (err) {
ESP_LOGE(TAG, "[CIS #0] Scheduler start failed, err %d", err);
ESP_LOGE(TAG, "[%s] Scheduler start failed, err %d", ctx->name, err);
return;
}
iso_chan_send();
iso_chan_send(ctx);
}
static void iso_disconnected_cb(esp_ble_iso_chan_t *chan, uint8_t reason)
{
struct cis_ctx *ctx = ctx_by_chan(chan);
esp_err_t err;
ESP_LOGI(TAG, "[CIS #0] Disconnected, reason 0x%02x", reason);
ESP_LOGI(TAG, "[%s] Disconnected, reason 0x%02x", ctx->name, reason);
err = example_iso_tx_scheduler_stop(&tx_scheduler);
err = example_iso_tx_scheduler_stop(&ctx->scheduler);
if (err) {
ESP_LOGE(TAG, "[CIS #0] Scheduler stop failed, err %d", err);
ESP_LOGE(TAG, "[%s] Scheduler stop failed, err %d", ctx->name, err);
}
/* Per BT Core 6.0 §7.7.5: a CIS on the Central retains its handle and
@@ -91,13 +114,15 @@ static void iso_disconnected_cb(esp_ble_iso_chan_t *chan, uint8_t reason)
*/
err = esp_ble_iso_remove_data_path(chan, ESP_BLE_ISO_DATA_PATH_DIR_INPUT);
if (err) {
ESP_LOGE(TAG, "[CIS #0] Failed to remove data path, err %d", err);
ESP_LOGE(TAG, "[%s] Failed to remove data path, err %d", ctx->name, err);
}
}
static void iso_sent_cb(esp_ble_iso_chan_t *chan, void *user_data)
{
example_iso_tx_scheduler_on_sent(&tx_scheduler, user_data, TAG, "CIS #0");
struct cis_ctx *ctx = ctx_by_chan(chan);
example_iso_tx_scheduler_on_sent(&ctx->scheduler, user_data, TAG, ctx->name);
}
static esp_ble_iso_chan_ops_t iso_ops = {
@@ -117,24 +142,21 @@ static esp_ble_iso_chan_qos_t iso_qos = {
.rx = NULL,
};
static esp_ble_iso_chan_t iso_chan = {
.ops = &iso_ops,
.qos = &iso_qos,
};
static void create_cig_and_cis(uint16_t acl_handle)
{
esp_ble_iso_connect_param_t connect_param = {0};
esp_ble_iso_connect_param_t connect_param[CIS_COUNT] = {0};
esp_ble_iso_cig_param_t cig_param = {0};
esp_ble_iso_chan_t *channels[1] = {0};
esp_ble_iso_chan_t *channels[CIS_COUNT] = {0};
int err;
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
channels[i] = &cis_ctxs[i].chan;
}
if (out_cig) {
goto connect;
}
channels[0] = &iso_chan;
cig_param.cis_channels = channels;
cig_param.num_cis = ARRAY_SIZE(channels);
cig_param.sca = CIG_SCA;
@@ -152,37 +174,41 @@ static void create_cig_and_cis(uint16_t acl_handle)
}
connect:
connect_param.iso_chan = &iso_chan;
/* Both CIS ride the same ACL to the peer. */
for (size_t i = 0; i < ARRAY_SIZE(connect_param); i++) {
connect_param[i].iso_chan = &cis_ctxs[i].chan;
}
err = esp_ble_iso_chan_connect(&connect_param, acl_handle, 1);
err = esp_ble_iso_chan_connect(connect_param, acl_handle, ARRAY_SIZE(connect_param));
if (err) {
ESP_LOGE(TAG, "Failed to create CIS, err %d", err);
return;
}
}
static void iso_chan_send(void)
static void iso_chan_send(struct cis_ctx *ctx)
{
int err;
memset(iso_data, (uint8_t)iso_seq_num, sizeof(iso_data));
memset(ctx->data, (uint8_t)ctx->seq_num, sizeof(ctx->data));
err = esp_ble_iso_chan_send(&iso_chan,
iso_data,
sizeof(iso_data),
iso_seq_num);
err = esp_ble_iso_chan_send(&ctx->chan,
ctx->data,
sizeof(ctx->data),
ctx->seq_num);
if (err) {
ESP_LOGD(TAG, "[CIS #0] Send failed, err %d", err);
ESP_LOGD(TAG, "[%s] Send failed, err %d", ctx->name, err);
return;
}
iso_seq_num++;
ctx->seq_num++;
}
/* Each CIS has its own timer, so the scheduler only ever feeds its own channel;
* a CIS that is not established yet has no timer running and is never sent to. */
static void tx_scheduler_cb(void *arg)
{
(void)arg;
iso_chan_send();
iso_chan_send(arg);
}
static bool data_cb(uint8_t type, const uint8_t *data,
@@ -343,12 +369,21 @@ void app_main(void)
return;
}
err = example_iso_tx_scheduler_init(&tx_scheduler,
tx_scheduler_cb,
NULL);
if (err) {
ESP_LOGE(TAG, "Failed to init tx scheduler, err %d", err);
return;
/* All CIS share the callbacks and TX QoS; each keeps its own timer, fed
* with its own context so the scheduler knows which channel to send on. */
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
cis_ctxs[i].chan.ops = &iso_ops;
cis_ctxs[i].chan.qos = &iso_qos;
snprintf(cis_ctxs[i].name, sizeof(cis_ctxs[i].name), "CIS #%zu", i);
err = example_iso_tx_scheduler_init(&cis_ctxs[i].scheduler,
tx_scheduler_cb,
&cis_ctxs[i]);
if (err) {
ESP_LOGE(TAG, "[%s] Failed to init tx scheduler, err %d",
cis_ctxs[i].name, err);
return;
}
}
err = set_device_name();

View File

@@ -12,6 +12,7 @@ CONFIG_BT_BLE_50_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_ISO_CENTRAL=y
CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y

View File

@@ -9,7 +9,9 @@
This is a raw BLE Connected Isochronous Stream (CIS) example operating directly at the ISO transport layer over either the NimBLE or Bluedroid host (selected at build time via Kconfig). It is **not** a BAP/CAP (BLE Audio profile) example — it does not implement Unicast Server, ASCS, PACS, or any LC3 codec; it only exercises the underlying CIS accept/receive plumbing.
The peripheral starts connectable extended advertising under the name `CIS Peripheral` (1M primary / 2M secondary PHY, 200 ms interval), registers an ISO server with security level `ESP_BLE_ISO_SECURITY_NO_MITM` and an accept callback that hands out the single CIS channel slot, sets up the output data path to the HCI in transparent format when the CIS connects, and tallies received SDUs through the shared RX-metrics helper.
The peripheral starts connectable extended advertising under the name `CIS Peripheral` (1M primary / 2M secondary PHY, 200 ms interval), registers an ISO server with security level `ESP_BLE_ISO_SECURITY_NO_MITM` and an accept callback that hands out the next free CIS channel slot, sets up the output data path to the HCI in transparent format when each CIS connects, and tallies received SDUs through the shared RX-metrics helper.
It accepts **two CIS** from the central, both on the same ACL connection. The central establishes them one after another, so the accept callback is invoked twice and returns a different channel each time; each channel keeps its own RX metrics so the two streams are reported separately.
There is no audio decoding — incoming SDUs are simply counted (valid / error / lost / null), so any 120-byte dummy payload from the central is consumed as opaque data via the `esp_ble_iso_*` APIs.
@@ -24,7 +26,7 @@ There is no audio decoding — incoming SDUs are simply counted (valid / error /
idf.py menuconfig
```
No build-time options — runtime defaults are baked into source.
No menuconfig options — runtime defaults are baked into source. The number of CIS accepted is the `CIS_COUNT` macro in `main/main.c`; it must be matched by `CONFIG_BT_ISO_MAX_CHAN` in `sdkconfig.defaults` (the host default is 1, so a second CIS would be rejected) and by the central's own `CIS_COUNT`.
### Security & Pairing
@@ -55,11 +57,11 @@ For `esp32s31`, replace the chip overlay accordingly.
## Example Flow
1. Initialize NVS, the selected BLE host, and the ISO common layer with a GAP callback.
2. Register an ISO server (`esp_ble_iso_server_register`) whose accept callback returns the single static CIS channel.
2. Register an ISO server (`esp_ble_iso_server_register`) whose accept callback returns the next CIS channel that is not already carrying one.
3. Build flags + complete-local-name AD payload and start connectable extended advertising on handle 0 at a 200 ms interval.
4. On ACL connect, log the peer; the central drives subsequent pairing and CIG/CIS creation.
5. When the CIS request arrives, the accept callback supplies the channel; on CIS connect, set up the output data path (HCI / transparent) and reset RX metrics.
6. Each received SDU updates valid/error/lost counters; periodic RX summaries are logged by the shared utility.
5. Each CIS request invokes the accept callback, which supplies a free channel; on CIS connect, set up that channel's output data path (HCI / transparent) and reset its RX metrics.
6. Each received SDU updates the valid/error/lost counters of the channel it arrived on; periodic per-CIS RX summaries are logged by the shared utility.
7. On ACL disconnect, restart extended advertising.
## Expected Log
@@ -74,18 +76,24 @@ I CIS_PER: Connected: handle <h> role <r> peer XX:XX:XX:XX:XX:XX
I CIS_PER: Security: handle <h> level <l> bonded <b>
```
CIS accept and streaming phase (RX log emitted every `LOG_INTERVAL_PACKETS` SDUs by the shared utility):
CIS accept and streaming phase. Both requests arrive on the same ACL handle, one after the other, and each CIS reports its own count (RX log emitted every `LOG_INTERVAL_PACKETS` SDUs by the shared utility):
```
I CIS_PER: Incoming CIS request from handle <h>
I CIS_PER: [CIS #0] Connected
I CIS_PER: Incoming CIS request from handle <h>
I CIS_PER: [CIS #1] Connected
I CIS_PER: [CIS #0] RX: <count> packets
I CIS_PER: [CIS #1] RX: <count> packets
```
Disconnect path:
A `No channels available` error on a request means more CIS were offered than `CIS_COUNT` provides for.
Disconnect path (per CIS, then the ACL):
```
I CIS_PER: [CIS #0] Disconnected, reason 0x<rr>
I CIS_PER: [CIS #1] Disconnected, reason 0x<rr>
I CIS_PER: Disconnected: handle <h> reason 0x<rr>
```
@@ -96,6 +104,6 @@ Run [cis_central](../cis_central/) on a second board.
1. Flash and run `cis_peripheral` first; it advertises as `CIS Peripheral` on extended-advertising handle 0.
2. Flash and run `cis_central` on the second board; it scans and matches the name.
3. The central opens the ACL connection and initiates pairing; this peripheral handles the security change passively.
4. The central creates the CIG and CIS; the peripheral's accept callback returns the local channel.
5. The peripheral configures its output data path on CIS connection.
6. Incoming 120-byte SDUs are counted and `RX: <count> packets` is logged periodically.
4. The central creates the CIG and connects both CIS over that one ACL; the peripheral's accept callback returns a free local channel for each.
5. The peripheral configures an output data path per CIS as each one connects.
6. Incoming 120-byte SDUs are counted per CIS and `[CIS #<n>] RX: <count> packets` is logged periodically for each.

View File

@@ -6,6 +6,7 @@
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
@@ -20,10 +21,29 @@
#include "peripheral.h"
#define CIS_SDU_SIZE 120
#define CIS_COUNT 2
static uint8_t ext_adv_data[3 + 2 + LOCAL_DEVICE_NAME_LEN];
static example_iso_rx_metrics_t rx_metrics;
/* Each CIS is received and reported independently, so it keeps its own metrics. */
struct cis_ctx {
esp_ble_iso_chan_t chan;
example_iso_rx_metrics_t rx_metrics;
char name[8];
};
static struct cis_ctx cis_ctxs[CIS_COUNT];
static struct cis_ctx *ctx_by_chan(const esp_ble_iso_chan_t *chan)
{
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
if (&cis_ctxs[i].chan == chan) {
return &cis_ctxs[i];
}
}
return NULL;
}
static void iso_connected_cb(esp_ble_iso_chan_t *chan)
{
@@ -31,30 +51,35 @@ static void iso_connected_cb(esp_ble_iso_chan_t *chan)
.pid = ESP_BLE_ISO_DATA_PATH_HCI,
.format = ESP_BLE_ISO_CODING_FORMAT_TRANSPARENT,
};
struct cis_ctx *ctx = ctx_by_chan(chan);
esp_err_t err;
ESP_LOGI(TAG, "[CIS #0] Connected");
ESP_LOGI(TAG, "[%s] Connected", ctx->name);
err = esp_ble_iso_setup_data_path(chan, ESP_BLE_ISO_DATA_PATH_DIR_OUTPUT, &data_path);
if (err) {
ESP_LOGE(TAG, "[CIS #0] Failed to setup data path, err %d", err);
ESP_LOGE(TAG, "[%s] Failed to setup data path, err %d", ctx->name, err);
return;
}
example_iso_rx_metrics_reset(&rx_metrics);
example_iso_rx_metrics_reset(&ctx->rx_metrics);
}
static void iso_disconnected_cb(esp_ble_iso_chan_t *chan, uint8_t reason)
{
ESP_LOGI(TAG, "[CIS #0] Disconnected, reason 0x%02x", reason);
struct cis_ctx *ctx = ctx_by_chan(chan);
ESP_LOGI(TAG, "[%s] Disconnected, reason 0x%02x", ctx->name, reason);
}
static void iso_recv_cb(esp_ble_iso_chan_t *chan,
const esp_ble_iso_recv_info_t *info,
const uint8_t *data, uint16_t len)
{
rx_metrics.last_sdu_len = len;
example_iso_rx_metrics_on_recv(info, &rx_metrics, TAG, "CIS #0");
struct cis_ctx *ctx = ctx_by_chan(chan);
ctx->rx_metrics.last_sdu_len = len;
example_iso_rx_metrics_on_recv(info, &ctx->rx_metrics, TAG, ctx->name);
}
static esp_ble_iso_chan_ops_t iso_ops = {
@@ -72,24 +97,23 @@ static esp_ble_iso_chan_qos_t iso_qos = {
.tx = NULL,
};
static esp_ble_iso_chan_t iso_chan = {
.ops = &iso_ops,
.qos = &iso_qos,
};
static int iso_accept(const esp_ble_iso_accept_info_t *info,
esp_ble_iso_chan_t **chan)
{
ESP_LOGI(TAG, "Incoming CIS request from handle %u", info->acl->handle);
if (iso_chan.iso) {
ESP_LOGE(TAG, "No channels available");
return -ENOMEM;
/* The central establishes the CIS one at a time, so hand out the next
* channel that is not already carrying one. */
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
if (cis_ctxs[i].chan.iso == NULL) {
*chan = &cis_ctxs[i].chan;
return 0;
}
}
*chan = &iso_chan;
ESP_LOGE(TAG, "No channels available");
return 0;
return -ENOMEM;
}
static esp_ble_iso_server_t iso_server = {
@@ -189,6 +213,14 @@ void app_main(void)
return;
}
/* All CIS share the same callbacks and RX QoS; they differ only by which
* channel iso_accept() hands out and where its metrics are kept. */
for (size_t i = 0; i < ARRAY_SIZE(cis_ctxs); i++) {
cis_ctxs[i].chan.ops = &iso_ops;
cis_ctxs[i].chan.qos = &iso_qos;
snprintf(cis_ctxs[i].name, sizeof(cis_ctxs[i].name), "CIS #%zu", i);
}
err = esp_ble_iso_server_register(&iso_server);
if (err) {
ESP_LOGE(TAG, "Failed to register ISO server, err %d", err);

View File

@@ -12,6 +12,7 @@ CONFIG_BT_BLE_50_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_FEAT_ISO_EN=y
CONFIG_BT_ISO_PERIPHERAL=y
CONFIG_BT_ISO_MAX_CHAN=2
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y