From cd0572bbb67688472a2ee8d50487d65367cb6d27 Mon Sep 17 00:00:00 2001 From: Liu Linyan Date: Mon, 17 Aug 2026 16:35:14 +0800 Subject: [PATCH] feat(ble_audio): Miscellaneous update for ISO & Audio examples --- .../bt/esp_ble_audio/host/common/init.c | 2 +- components/bt/esp_ble_audio/lib/lib | 2 +- .../esp_ble_audio/tmap/central/README.md | 168 +++- .../tmap/central/main/CMakeLists.txt | 1 + .../tmap/central/main/Kconfig.projbuild | 51 + .../tmap/central/main/cap_initiator.c | 877 ++++++++++++++---- .../tmap/central/main/csip_set_coordinator.c | 187 ++++ .../esp_ble_audio/tmap/central/main/main.c | 439 +++++++-- .../tmap/central/main/tmap_central.h | 109 ++- .../tmap/central/main/vcp_vol_ctlr.c | 339 ++++++- .../tmap/central/sdkconfig.defaults | 5 +- .../tmap/central/sdkconfig.defaults.nimble | 2 +- .../esp_ble_audio/tmap/peripheral/README.md | 59 +- .../tmap/peripheral/main/Kconfig.projbuild | 80 +- .../tmap/peripheral/main/bap_unicast_sr.c | 200 +++- .../peripheral/main/bluedroid/peripheral.c | 12 +- .../tmap/peripheral/main/csip_set_member.c | 14 +- .../tmap/peripheral/main/nimble/peripheral.c | 15 +- .../tmap/peripheral/main/tmap_peripheral.h | 16 + .../tmap/peripheral/sdkconfig.defaults | 5 +- .../esp_ble_iso/cis_central/README.md | 27 +- .../esp_ble_iso/cis_central/main/main.c | 123 ++- .../cis_central/sdkconfig.defaults | 1 + .../esp_ble_iso/cis_peripheral/README.md | 28 +- .../esp_ble_iso/cis_peripheral/main/main.c | 66 +- .../cis_peripheral/sdkconfig.defaults | 1 + 26 files changed, 2355 insertions(+), 474 deletions(-) create mode 100644 examples/bluetooth/esp_ble_audio/tmap/central/main/Kconfig.projbuild create mode 100644 examples/bluetooth/esp_ble_audio/tmap/central/main/csip_set_coordinator.c diff --git a/components/bt/esp_ble_audio/host/common/init.c b/components/bt/esp_ble_audio/host/common/init.c index 495b044b4d1..110feee524b 100644 --- a/components/bt/esp_ble_audio/host/common/init.c +++ b/components/bt/esp_ble_audio/host/common/init.c @@ -185,7 +185,7 @@ static const uint16_t ext_structs[] = { sizeof(struct bt_bond_info), }; -#define LEA_VERSION (0x20260802) +#define LEA_VERSION (0x20260820) struct lib_ext_cfgs { /* BLE */ diff --git a/components/bt/esp_ble_audio/lib/lib b/components/bt/esp_ble_audio/lib/lib index 316a6547003..7c6b2930f20 160000 --- a/components/bt/esp_ble_audio/lib/lib +++ b/components/bt/esp_ble_audio/lib/lib @@ -1 +1 @@ -Subproject commit 316a654700387c9e8d47417c15fcc5d5c309bcb6 +Subproject commit 7c6b2930f2018c09ceff86d295bd59cd210926b4 diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/README.md b/examples/bluetooth/esp_ble_audio/tmap/central/README.md index 975acfcd944..020597da3fd 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/README.md +++ b/examples/bluetooth/esp_ble_audio/tmap/central/README.md @@ -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 options — runtime defaults are baked into source. +Under **Example: TMAP Central (CG & UMS)** → **Unicast topology**, pick one — and 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 -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 role peer TMAP_CEN: Security: handle level bonded TMAP_CEN: MTU updated: handle mtu TMAP_CEN: Service discovery started: handle TMAP_CEN: Service discovery complete: handle status 0 +TMAP_CEN: TMAP discovery done: handle +TMAP_CEN: Peer 1/2 connected, scanning for the rest +TMAP_CEN: CSIP discovered: handle 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 mute -``` - -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 , length +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 +TMAP_CEN: [SNK #0] Endpoint discovered on handle +TMAP_CEN: [SRC #0] Endpoint discovered on handle +TMAP_CEN: Sink discover complete: handle +TMAP_CEN: Source discover complete: handle +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 aics +TMAP_CEN: VCP state cb done, volume mute +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 , length +TMAP_CEN: [SNK #1] Stream started +TMAP_CEN: [SNK #1] Streaming, interval , length +TMAP_CEN: [SNK #0] TX: packets +TMAP_CEN: [SNK #1] TX: packets +TMAP_CEN: [SRC #0] RX: 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`. diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/CMakeLists.txt b/examples/bluetooth/esp_ble_audio/tmap/central/main/CMakeLists.txt index e851db1810b..d46876efc1d 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/main/CMakeLists.txt +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/CMakeLists.txt @@ -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") diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/Kconfig.projbuild b/examples/bluetooth/esp_ble_audio/tmap/central/main/Kconfig.projbuild new file mode 100644 index 00000000000..f2a22ae8556 --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/Kconfig.projbuild @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/cap_initiator.c b/examples/bluetooth/esp_ble_audio/tmap/central/main/cap_initiator.c index 3bb47dc307d..265790c037c 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/main/cap_initiator.c +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/cap_initiator.c @@ -10,27 +10,140 @@ #include #include #include +#include #include "esp_ble_audio_bap_lc3_preset_defs.h" #include "tmap_central.h" -static esp_ble_audio_cap_stream_t unicast_sink_streams[CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT]; -static esp_ble_audio_cap_stream_t unicast_source_streams[CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SRC_COUNT]; -static esp_ble_audio_bap_ep_t *unicast_sink_eps[CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SNK_COUNT]; -static esp_ble_audio_bap_ep_t *unicast_source_eps[CONFIG_BT_BAP_UNICAST_CLIENT_ASE_SRC_COUNT]; +/* ASE ids repeat across peripherals: an endpoint only means something with its + * connection. */ +struct peer_eps { + esp_ble_conn_t *conn; + uint16_t conn_handle; + uint8_t sink_ep_count; + esp_ble_audio_bap_ep_t *sink_eps[TMAP_CEN_SINK_EP_MAX]; + esp_ble_audio_bap_ep_t *source_ep; + /* PACS Sink Audio Locations, i.e. which ear this peripheral actually is. */ + esp_ble_audio_location_t sink_loc; + /* CSIS instance inside the peer's CAS, NULL when it is not a set member. */ + esp_ble_audio_csip_set_coordinator_csis_inst_t *csis_inst; +}; + +static struct peer_eps peers[TMAP_CEN_PEER_COUNT]; + +/* Which CIS carries which endpoint — the only place the topologies differ. */ +struct cis_binding { + uint8_t peer; /* index into peers[], ordered by set rank */ + uint8_t sink_idx; /* which sink endpoint of that peer */ + bool with_rx; /* pair the peer's source endpoint into the same CIS */ +}; + +static const struct cis_binding bindings[TMAP_CEN_CIS_COUNT] = { + { .peer = 0, .sink_idx = 0, .with_rx = true }, +#if CONFIG_EXAMPLE_TMAP_CEN_TOPO_1P_2CIS + { .peer = 0, .sink_idx = 1, .with_rx = false }, +#elif CONFIG_EXAMPLE_TMAP_CEN_TOPO_2P_2CIS + { .peer = 1, .sink_idx = 0, .with_rx = false }, +#endif +}; + +static esp_ble_audio_cap_stream_t unicast_sink_streams[TMAP_CEN_CIS_COUNT]; +static esp_ble_audio_cap_stream_t unicast_source_streams[1]; static esp_ble_audio_cap_unicast_group_t *unicast_group; -static example_audio_tx_scheduler_t tx_scheduler; -static uint16_t tx_seq_num; -static uint8_t *iso_data; +/* Counts the discoveries this round actually dispatched, so a member that kept + * its endpoints through a partial teardown is not waited for again. */ +static size_t discover_pending; -static void unicast_audio_tx(void); +/* Per stream: a shared sequence number jumps by the stream count. */ +struct sink_tx { + example_audio_tx_scheduler_t scheduler; + uint16_t seq_num; + uint8_t *data; + uint8_t index; + bool running; +}; -ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_48_2_1_DEFINE(unicast_preset_48_2_1, +static struct sink_tx sink_tx[TMAP_CEN_CIS_COUNT]; + +/* Only CIS #0 is bidirectional, so one RX metrics block is enough. */ +static example_audio_rx_metrics_t rx_metrics; + +static void unicast_audio_tx(struct sink_tx *tx); + +static void peer_discovery_done(void); + +/* One preset per rendered channel; only the channel allocation differs. */ +ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_48_2_1_DEFINE(unicast_preset_mono, + ESP_BLE_AUDIO_LOCATION_MONO_AUDIO, + ESP_BLE_AUDIO_CONTEXT_TYPE_MEDIA); +ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_48_2_1_DEFINE(unicast_preset_left, ESP_BLE_AUDIO_LOCATION_FRONT_LEFT, ESP_BLE_AUDIO_CONTEXT_TYPE_MEDIA); +ESP_BLE_AUDIO_BAP_LC3_UNICAST_PRESET_48_2_1_DEFINE(unicast_preset_right, + ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT, + ESP_BLE_AUDIO_CONTEXT_TYPE_MEDIA); + +/* How many sink streams this binding's peer carries, and which of them this is. */ +static void binding_sink_position(size_t binding, size_t *nth, size_t *total) +{ + *nth = 0; + *total = 0; + + for (size_t i = 0; i < ARRAY_SIZE(bindings); i++) { + if (bindings[i].peer != bindings[binding].peer) { + continue; + } + + if (i < binding) { + (*nth)++; + } + + (*total)++; + } +} + +/* Channel follows the peer's PACS Sink Audio Locations — rank says nothing about + * which ear a device is. Two streams on one peer still split L/R. */ +static esp_ble_audio_bap_lc3_preset_t *sink_preset(size_t binding) +{ + esp_ble_audio_location_t loc = peers[bindings[binding].peer].sink_loc; + size_t nth; + size_t total; + + binding_sink_position(binding, &nth, &total); + + if (total > 1) { + return (nth == 0) ? &unicast_preset_left : &unicast_preset_right; + } + + if (loc == ESP_BLE_AUDIO_LOCATION_FRONT_LEFT) { + return &unicast_preset_left; + } + + if (loc == ESP_BLE_AUDIO_LOCATION_FRONT_RIGHT) { + return &unicast_preset_right; + } + + return &unicast_preset_mono; +} + +static const char *sink_channel_str(size_t binding) +{ + const esp_ble_audio_bap_lc3_preset_t *preset = sink_preset(binding); + + if (preset == &unicast_preset_left) { + return "front left"; + } + + if (preset == &unicast_preset_right) { + return "front right"; + } + + return "mono"; +} static const char *dir_str(esp_ble_audio_dir_t dir) { @@ -67,6 +180,160 @@ static int stream_index(const esp_ble_audio_bap_stream_t *stream) return -1; } +static struct sink_tx *sink_tx_of(const esp_ble_audio_bap_stream_t *stream) +{ + for (size_t i = 0; i < ARRAY_SIZE(unicast_sink_streams); i++) { + if (&unicast_sink_streams[i].bap_stream == stream) { + return &sink_tx[i]; + } + } + + return NULL; +} + +/* Still attached to an endpoint that has not gone back to idle. Streams of a + * peer that dropped are released by the stack before its disconnect is + * reported, so this only ever picks out the ones still worth stopping. */ +static bool stream_is_live(const esp_ble_audio_cap_stream_t *cap_stream) +{ + const esp_ble_audio_bap_stream_t *bap_stream = &cap_stream->bap_stream; + esp_ble_audio_bap_ep_info_t ep_info = {0}; + + if (bap_stream->conn == NULL || bap_stream->ep == NULL) { + return false; + } + + if (esp_ble_audio_bap_ep_get_info(bap_stream->ep, &ep_info) != 0) { + return false; + } + + return ep_info.state != ESP_BLE_AUDIO_BAP_EP_STATE_IDLE; +} + +/* Handle 0 is valid, so free slots are CONN_HANDLE_INIT rather than zeroed. */ +static void peers_reset(void) +{ + memset(peers, 0, sizeof(peers)); + + for (size_t i = 0; i < ARRAY_SIZE(peers); i++) { + peers[i].conn_handle = CONN_HANDLE_INIT; + } +} + +static struct peer_eps *peer_by_handle(uint16_t conn_handle) +{ + if (conn_handle == CONN_HANDLE_INIT) { + return NULL; + } + + for (size_t i = 0; i < ARRAY_SIZE(peers); i++) { + if (peers[i].conn_handle == conn_handle) { + return &peers[i]; + } + } + + return NULL; +} + +/* Everything recorded for a peer dies with its connection: the endpoints, the + * conn reference and the CSIS instance are all owned by the link. */ +void peer_forget(uint16_t conn_handle) +{ + struct peer_eps *peer = peer_by_handle(conn_handle); + + if (peer == NULL) { + return; + } + + memset(peer, 0, sizeof(*peer)); + peer->conn_handle = CONN_HANDLE_INIT; +} + +/* A peer that can be streamed on as-is, so a rebuild does not rediscover it. */ +static bool peer_is_ready(const struct peer_eps *peer) +{ + return peer != NULL && peer->conn != NULL && peer->sink_ep_count > 0; +} + +/* Re-seat into the rank order main.c settled on; collection is by handle. */ +static void peers_reorder(void) +{ + struct peer_eps ordered[TMAP_CEN_PEER_COUNT]; + + memset(ordered, 0, sizeof(ordered)); + + for (size_t i = 0; i < ARRAY_SIZE(ordered); i++) { + struct peer_eps *peer = peer_by_handle(set_member_handle(i)); + + if (peer != NULL) { + ordered[i] = *peer; + } else { + ordered[i].conn_handle = CONN_HANDLE_INIT; + } + } + + memcpy(peers, ordered, sizeof(peers)); +} + +/* CSIP once every member exposes a CSIS instance; ad-hoc is the only form that + * works without one. Both resolve to the same connections. */ +esp_ble_audio_cap_set_type_t set_type_get(void) +{ + size_t count = set_member_count(); + + if (count == 0) { + return ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC; + } + + for (size_t i = 0; i < count; i++) { + const struct peer_eps *peer = peer_by_handle(set_member_handle(i)); + + if (peer == NULL || peer->csis_inst == NULL) { + return ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC; + } + } + + return ESP_BLE_AUDIO_CAP_SET_TYPE_CSIP; +} + +bool set_member_fill(esp_ble_audio_cap_set_member_t *member, + esp_ble_audio_cap_set_type_t type, + uint16_t conn_handle) +{ + const struct peer_eps *peer = peer_by_handle(conn_handle); + + if (peer == NULL) { + return false; + } + + if (type == ESP_BLE_AUDIO_CAP_SET_TYPE_CSIP) { + member->csip = peer->csis_inst; + return member->csip != NULL; + } + + member->member = peer->conn; + + return member->member != NULL; +} + +static struct peer_eps *peer_slot_alloc(uint16_t conn_handle) +{ + struct peer_eps *peer = peer_by_handle(conn_handle); + + if (peer != NULL) { + return peer; + } + + for (size_t i = 0; i < ARRAY_SIZE(peers); i++) { + if (peers[i].conn_handle == CONN_HANDLE_INIT) { + peers[i].conn_handle = conn_handle; + return &peers[i]; + } + } + + return NULL; +} + static void unicast_stream_configured_cb(esp_ble_audio_bap_stream_t *stream, const esp_ble_audio_bap_qos_cfg_pref_t *pref) { @@ -89,51 +356,49 @@ static void unicast_stream_enabled_cb(esp_ble_audio_bap_stream_t *stream) static void unicast_stream_started_cb(esp_ble_audio_bap_stream_t *stream) { - esp_ble_audio_bap_ep_info_t ep_info = {0}; + struct sink_tx *tx; esp_err_t err; ESP_LOGI(TAG, "[%s #%d] Stream started", stream_dir_str(stream), stream_index(stream)); - err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info); - if (err) { - ESP_LOGE(TAG, "Failed to get ep info, err %d", err); + tx = sink_tx_of(stream); + if (tx == NULL) { + /* Source stream: the peripheral transmits, we only count. */ + example_audio_rx_metrics_reset(&rx_metrics); return; } - /* Note: - * For a BAP Unicast Client, this is considered outgoing audio (TX). - */ - if (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) { - if (stream->qos == NULL || stream->qos->sdu == 0) { - ESP_LOGE(TAG, "Invalid stream qos"); - return; - } - - if (iso_data == NULL) { - iso_data = calloc(1, stream->qos->sdu); - if (iso_data == NULL) { - ESP_LOGE(TAG, "Failed to alloc TX buffer, SDU %u", stream->qos->sdu); - return; - } - } - - assert(stream->qos->interval); - - ESP_LOGI(TAG, "Streaming, interval %u, length %u", stream->qos->interval, stream->qos->sdu); - - tx_seq_num = 0; - example_audio_tx_scheduler_reset(&tx_scheduler); - - /* Note: esp timer is not accurate enough */ - err = example_audio_tx_scheduler_start(&tx_scheduler, stream->qos->interval); - if (err) { - ESP_LOGE(TAG, "Failed to start tx scheduler, err %d", err); - return; - } - - unicast_audio_tx(); + if (stream->qos == NULL || stream->qos->sdu == 0 || stream->qos->interval == 0) { + ESP_LOGE(TAG, "[SNK #%u] Invalid stream qos", tx->index); + return; } + + if (tx->data == NULL) { + tx->data = calloc(1, stream->qos->sdu); + if (tx->data == NULL) { + ESP_LOGE(TAG, "[SNK #%u] Failed to alloc TX buffer, SDU %u", + tx->index, stream->qos->sdu); + return; + } + } + + ESP_LOGI(TAG, "[SNK #%u] Streaming, interval %u, length %u", + tx->index, stream->qos->interval, stream->qos->sdu); + + tx->seq_num = 0; + example_audio_tx_scheduler_reset(&tx->scheduler); + + /* Note: esp timer is not accurate enough */ + err = example_audio_tx_scheduler_start(&tx->scheduler, stream->qos->interval); + if (err) { + ESP_LOGE(TAG, "[SNK #%u] Failed to start tx scheduler, err %d", tx->index, err); + return; + } + + tx->running = true; + + unicast_audio_tx(tx); } static void unicast_stream_metadata_updated_cb(esp_ble_audio_bap_stream_t *stream) @@ -148,31 +413,37 @@ static void unicast_stream_disabled_cb(esp_ble_audio_bap_stream_t *stream) stream_dir_str(stream), stream_index(stream)); } -static void unicast_stream_stopped_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason) +/* Keyed on the stream, not the endpoint state: the ISO disconnected callback runs + * before the endpoint leaves streaming, so a state query would still match. */ +static void sink_tx_stop(esp_ble_audio_bap_stream_t *stream) { - esp_ble_audio_bap_ep_info_t ep_info = {0}; + struct sink_tx *tx; esp_err_t err; - ESP_LOGI(TAG, "[%s #%d] Stream stopped, reason 0x%02x", - stream_dir_str(stream), stream_index(stream), reason); - - err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info); - if (err) { - ESP_LOGE(TAG, "Failed to get ep info, err %d", err); + tx = sink_tx_of(stream); + if (tx == NULL || !tx->running) { return; } - if (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) { - err = example_audio_tx_scheduler_stop(&tx_scheduler); - if (err) { - ESP_LOGE(TAG, "Failed to stop tx scheduler, err %d", err); - } - - if (iso_data != NULL) { - free(iso_data); - iso_data = NULL; - } + err = example_audio_tx_scheduler_stop(&tx->scheduler); + if (err) { + ESP_LOGE(TAG, "[SNK #%u] Failed to stop tx scheduler, err %d", tx->index, err); } + + tx->running = false; + + if (tx->data != NULL) { + free(tx->data); + tx->data = NULL; + } +} + +static void unicast_stream_stopped_cb(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); + + sink_tx_stop(stream); } static void unicast_stream_released_cb(esp_ble_audio_bap_stream_t *stream) @@ -181,13 +452,31 @@ static void unicast_stream_released_cb(esp_ble_audio_bap_stream_t *stream) stream_dir_str(stream), stream_index(stream)); } -static void unicast_stream_sent_cb(esp_ble_audio_bap_stream_t *stream, void *user_data) +static void unicast_stream_recv_cb(esp_ble_audio_bap_stream_t *stream, + const esp_ble_iso_recv_info_t *info, + const uint8_t *data, uint16_t len) { char name[24]; snprintf(name, sizeof(name), "%s #%d", stream_dir_str(stream), stream_index(stream)); - example_audio_tx_scheduler_on_sent(&tx_scheduler, user_data, TAG, name); + rx_metrics.last_sdu_len = len; + example_audio_rx_metrics_on_recv(info, &rx_metrics, TAG, name); +} + +static void unicast_stream_sent_cb(esp_ble_audio_bap_stream_t *stream, void *user_data) +{ + struct sink_tx *tx; + char name[24]; + + tx = sink_tx_of(stream); + if (tx == NULL) { + return; + } + + snprintf(name, sizeof(name), "%s #%d", + stream_dir_str(stream), stream_index(stream)); + example_audio_tx_scheduler_on_sent(&tx->scheduler, user_data, TAG, name); } static void unicast_stream_connected_cb(esp_ble_audio_bap_stream_t *stream) @@ -198,29 +487,10 @@ static void unicast_stream_connected_cb(esp_ble_audio_bap_stream_t *stream) static void unicast_stream_disconnected_cb(esp_ble_audio_bap_stream_t *stream, uint8_t reason) { - esp_ble_audio_bap_ep_info_t ep_info = {0}; - esp_err_t err; - ESP_LOGI(TAG, "[%s #%d] ISO disconnected, reason 0x%02x", stream_dir_str(stream), stream_index(stream), reason); - err = esp_ble_audio_bap_ep_get_info(stream->ep, &ep_info); - if (err) { - ESP_LOGE(TAG, "Failed to get ep info, err %d", err); - return; - } - - if (ep_info.dir == ESP_BLE_AUDIO_DIR_SINK) { - err = example_audio_tx_scheduler_stop(&tx_scheduler); - if (err) { - ESP_LOGE(TAG, "Failed to stop tx scheduler, err %d", err); - } - - if (iso_data != NULL) { - free(iso_data); - iso_data = NULL; - } - } + sink_tx_stop(stream); } static esp_ble_audio_bap_stream_ops_t unicast_stream_ops = { @@ -232,6 +502,7 @@ static esp_ble_audio_bap_stream_ops_t unicast_stream_ops = { .disabled = unicast_stream_disabled_cb, .stopped = unicast_stream_stopped_cb, .released = unicast_stream_released_cb, + .recv = unicast_stream_recv_cb, .sent = unicast_stream_sent_cb, .connected = unicast_stream_connected_cb, .disconnected = unicast_stream_disconnected_cb, @@ -241,30 +512,33 @@ static void unicast_discovery_complete_cb(esp_ble_conn_t *conn, int err, const esp_ble_audio_csip_set_coordinator_set_member_t *member, const esp_ble_audio_csip_set_coordinator_csis_inst_t *csis_inst) { + struct peer_eps *peer; + if (err) { ESP_LOGE(TAG, "Unicast discovery completed, err %d", err); + peer_discovery_done(); return; } - if (default_conn_handle_get() != conn->handle) { - ESP_LOGE(TAG, "%s, not connected", __func__); - return; - } - - if (IS_ENABLED(CONFIG_BT_CAP_ACCEPTOR_SET_MEMBER)) { - if (csis_inst == NULL) { - ESP_LOGW(TAG, "Failed to discover CAS CSIS"); - return; - } - - ESP_LOGI(TAG, "Found CAS with CSIS"); + if (csis_inst != NULL) { + ESP_LOGI(TAG, "Found CAS with CSIS: handle %u", conn->handle); + } else if (TMAP_CEN_PEER_COUNT > 1) { + ESP_LOGW(TAG, "Failed to discover CAS CSIS on handle %u", conn->handle); } else { - ESP_LOGI(TAG, "Found CAS"); + ESP_LOGI(TAG, "Found CAS: handle %u", conn->handle); + } + + /* Keep the CSIS instance: it is how CAP names a coordinated set member. */ + peer = peer_slot_alloc(conn->handle); + if (peer != NULL) { + peer->conn = conn; + peer->csis_inst = (esp_ble_audio_csip_set_coordinator_csis_inst_t *)csis_inst; } err = esp_ble_audio_bap_unicast_client_discover(conn->handle, ESP_BLE_AUDIO_DIR_SINK); if (err) { ESP_LOGE(TAG, "Failed to discover sink, err %d", err); + peer_discovery_done(); } } @@ -275,6 +549,15 @@ static void unicast_start_complete_cb(int err, esp_ble_conn_t *conn) } else { ESP_LOGI(TAG, "Unicast start completed"); } + + /* The lock covers the stream setup procedure, so give it back either way. */ + set_release_lock(); + + /* Latching this on a failed start would drive the volume of a set that + * never came up: the flag stays set until a disconnect clears it. */ + if (!err) { + vcp_vol_ctlr_streams_started(); + } } static void unicast_update_complete_cb(int err, esp_ble_conn_t *conn) @@ -304,24 +587,49 @@ static esp_ble_audio_cap_initiator_cb_t cap_cb = { static int unicast_group_create(void) { - esp_ble_audio_cap_unicast_group_stream_param_t group_stream_params = {0}; - esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params = {0}; + esp_ble_audio_cap_unicast_group_stream_param_t tx_params[TMAP_CEN_CIS_COUNT] = {0}; + esp_ble_audio_cap_unicast_group_stream_param_t rx_params[TMAP_CEN_CIS_COUNT] = {0}; + esp_ble_audio_cap_unicast_group_stream_pair_param_t pair_params[TMAP_CEN_CIS_COUNT] = {0}; esp_ble_audio_cap_unicast_group_param_t group_param = {0}; int err; + /* Reused rather than rebuilt: the CIG stays up while a member is away, so + * the one that comes back re-enters the slot it left. Releasing a stream + * keeps it in the group (bt_bap_stream_detach() leaves stream->group and + * stream->iso alone), and configuring it again rebinds the new endpoint to + * the CIS the slot already owns. */ if (unicast_group != NULL) { return 0; } - group_stream_params.qos_cfg = &unicast_preset_48_2_1.qos; - group_stream_params.stream = &unicast_sink_streams[0]; + /* One pair is one CIS. tx_param only -> TX-only CIS; both -> bidirectional, + * which only works for a sink and source on the same peripheral. */ + for (size_t i = 0; i < ARRAY_SIZE(bindings); i++) { + const struct peer_eps *peer = &peers[bindings[i].peer]; - pair_params.tx_param = &group_stream_params; - pair_params.rx_param = NULL; + if (bindings[i].sink_idx >= peer->sink_ep_count) { + ESP_LOGE(TAG, "[SNK #%zu] Peer %u has no sink endpoint %u", + i, bindings[i].peer, bindings[i].sink_idx); + return -ENODEV; + } + + ESP_LOGI(TAG, "[SNK #%zu] Peer %u (loc 0x%08lx) renders %s", + i, bindings[i].peer, peer->sink_loc, sink_channel_str(i)); + + tx_params[i].qos_cfg = &sink_preset(i)->qos; + tx_params[i].stream = &unicast_sink_streams[i]; + pair_params[i].tx_param = &tx_params[i]; + + if (bindings[i].with_rx && peer->source_ep != NULL) { + rx_params[i].qos_cfg = &unicast_preset_mono.qos; + rx_params[i].stream = &unicast_source_streams[0]; + pair_params[i].rx_param = &rx_params[i]; + } + } group_param.packing = ESP_BLE_ISO_PACKING_SEQUENTIAL; - group_param.params_count = 1; - group_param.params = &pair_params; + group_param.params_count = ARRAY_SIZE(pair_params); + group_param.params = pair_params; err = esp_ble_audio_cap_unicast_group_create(&group_param, &unicast_group); if (err) { @@ -329,62 +637,85 @@ static int unicast_group_create(void) return err; } - ESP_LOGI(TAG, "Created unicast group"); + ESP_LOGI(TAG, "Created unicast group: %u CIS", (unsigned)ARRAY_SIZE(pair_params)); return 0; } -int unicast_group_delete(void) +/* Nothing is connected any more, or the session is being abandoned. The CIG only + * ever goes away here: while a member is merely absent it stays up, carrying the + * others and holding that member's slot. */ +void cap_initiator_reset(void) { int err; - /* The endpoint pointers become stale once the peer disconnects and are - * rediscovered on every reconnect. Clear them here so the next discovery - * does not find filled slots and warn "Unhandled endpoint". - */ - for (size_t i = 0; i < ARRAY_SIZE(unicast_sink_eps); i++) { - unicast_sink_eps[i] = NULL; - } - for (size_t i = 0; i < ARRAY_SIZE(unicast_source_eps); i++) { - unicast_source_eps[i] = NULL; - } + /* Endpoints go stale with the connection; stale slots warn "Unhandled endpoint". */ + peers_reset(); + + discover_pending = 0; if (unicast_group == NULL) { - return 0; + return; } + /* Every stream has to be released first, which the departing links did. */ err = esp_ble_audio_cap_unicast_group_delete(unicast_group); if (err) { ESP_LOGE(TAG, "Failed to delete unicast group, err %d", err); - return err; + return; } unicast_group = NULL; ESP_LOGI(TAG, "Deleted unicast group"); - - return 0; } -static int unicast_audio_start(esp_ble_conn_t *conn) +static int unicast_audio_start(void) { - esp_ble_audio_cap_unicast_audio_start_stream_param_t stream_param = {0}; + esp_ble_audio_cap_unicast_audio_start_stream_param_t stream_params[TMAP_CEN_CIS_COUNT + 1] = {0}; esp_ble_audio_cap_unicast_audio_start_param_t param = {0}; int err; - if (unicast_sink_eps[0] == NULL) { - ESP_LOGE(TAG, "No sink endpoint available"); - return -ENODEV; + param.type = set_type_get(); + + /* Endpoint and connection must match, or CAP reports an ep conn mismatch. */ + for (size_t i = 0; i < ARRAY_SIZE(bindings); i++) { + const struct peer_eps *peer = &peers[bindings[i].peer]; + + /* A member that never left is still on air in this slot. Its endpoint + * would be rejected as not startable, and restarting it would break + * the audio this whole path exists to preserve. */ + if (stream_is_live(&unicast_sink_streams[i])) { + ESP_LOGI(TAG, "[SNK #%zu] Already streaming, left alone", i); + continue; + } + + if (!set_member_fill(&stream_params[param.count].member, param.type, + peer->conn_handle)) { + ESP_LOGE(TAG, "No set member for handle %u", peer->conn_handle); + return -ENOENT; + } + + stream_params[param.count].stream = &unicast_sink_streams[i]; + stream_params[param.count].ep = peer->sink_eps[bindings[i].sink_idx]; + stream_params[param.count].codec_cfg = &sink_preset(i)->codec_cfg; + param.count++; + + if (bindings[i].with_rx && peer->source_ep != NULL) { + stream_params[param.count].member = stream_params[param.count - 1].member; + stream_params[param.count].stream = &unicast_source_streams[0]; + stream_params[param.count].ep = peer->source_ep; + stream_params[param.count].codec_cfg = &unicast_preset_mono.codec_cfg; + param.count++; + } } - stream_param.member.member = conn; - stream_param.stream = &unicast_sink_streams[0]; - stream_param.ep = unicast_sink_eps[0]; - stream_param.codec_cfg = &unicast_preset_48_2_1.codec_cfg; + if (param.count == 0) { + ESP_LOGW(TAG, "Nothing left to start, every stream is already up"); + return -EALREADY; + } - param.type = ESP_BLE_AUDIO_CAP_SET_TYPE_AD_HOC; - param.count = 1; - param.stream_params = &stream_param; + param.stream_params = stream_params; err = esp_ble_audio_cap_initiator_unicast_audio_start(¶m); if (err) { @@ -392,7 +723,8 @@ static int unicast_audio_start(esp_ble_conn_t *conn) return err; } - ESP_LOGI(TAG, "Started unicast audio"); + ESP_LOGI(TAG, "Started %u unicast stream(s) as %s set", (unsigned)param.count, + (param.type == ESP_BLE_AUDIO_CAP_SET_TYPE_CSIP) ? "a coordinated" : "an ad-hoc"); return 0; } @@ -401,7 +733,20 @@ static void location_cb(esp_ble_conn_t *conn, esp_ble_audio_dir_t dir, esp_ble_audio_location_t loc) { - ESP_LOGI(TAG, "Location, dir %u loc 0x%08lx", dir, loc); + struct peer_eps *peer; + + ESP_LOGI(TAG, "Location, handle %u dir %u loc 0x%08lx", conn->handle, dir, loc); + + if (dir != ESP_BLE_AUDIO_DIR_SINK) { + return; + } + + /* Remember which ear this peripheral is; it decides the channel allocation. */ + peer = peer_slot_alloc(conn->handle); + if (peer != NULL) { + peer->conn = conn; + peer->sink_loc = loc; + } } static void available_contexts_cb(esp_ble_conn_t *conn, @@ -475,40 +820,125 @@ static void release_cb(esp_ble_audio_bap_stream_t *stream, stream_dir_str(stream), stream_index(stream), rsp_code, reason); } +/* peers_reorder() must not move a member that is still on air into another + * slot: bindings[] would then aim its live CIS at the wrong peripheral. Rank + * ordering is stable, so this only ever fires on an unranked set. */ +static bool live_streams_match_peers(void) +{ + for (size_t i = 0; i < ARRAY_SIZE(bindings); i++) { + const struct peer_eps *peer = &peers[bindings[i].peer]; + + if (stream_is_live(&unicast_sink_streams[i]) && + unicast_sink_streams[i].bap_stream.conn != peer->conn) { + ESP_LOGE(TAG, "[SNK #%zu] Streaming member moved slots, cannot rebuild", i); + return false; + } + + if (!bindings[i].with_rx) { + continue; + } + + if (stream_is_live(&unicast_source_streams[0]) && + unicast_source_streams[0].bap_stream.conn != peer->conn) { + ESP_LOGE(TAG, "[SRC #0] Streaming member moved slots, cannot rebuild"); + return false; + } + } + + return true; +} + +/* One CIG covers every peripheral, so it is filled only once the last + * outstanding discovery is in. The set lock is held until the start procedure + * reports back, so every failure that returns early has to give it back. */ +static void peers_ready(void) +{ + /* A member can go away between cap_initiator_setup() dispatching the + * discoveries and the last one reporting back, leaving bindings[] pointing + * at an empty slot. The next member event runs this again. */ + if (set_member_count() < ARRAY_SIZE(peers)) { + ESP_LOGW(TAG, "Set no longer whole, %zu of %u member(s); not starting", + set_member_count(), TMAP_CEN_PEER_COUNT); + set_release_lock(); + + return; + } + + peers_reorder(); + + if (!live_streams_match_peers()) { + set_release_lock(); + return; + } + + if (unicast_group_create() != 0) { + set_release_lock(); + return; + } + + /* The group is left in place on failure: a member still on air owns a CIS + * in it, and the next member event reuses it rather than rebuilding. */ + if (unicast_audio_start() != 0) { + set_release_lock(); + } +} + +/* Single exit for every peer, whether its discovery succeeded or not: a peer that + * contributed no endpoint just makes the group build fail instead of stalling the + * others. */ +static void peer_discovery_done(void) +{ + if (discover_pending > 0) { + discover_pending--; + } + + if (discover_pending > 0) { + return; + } + + peers_ready(); +} + static void discover_cb(esp_ble_conn_t *conn, int err, esp_ble_audio_dir_t dir) { - if (err) { - ESP_LOGE(TAG, "Discovery failed, err %d", err); + struct peer_eps *peer; + + peer = peer_slot_alloc(conn->handle); + if (peer == NULL) { return; } - if (default_conn_handle_get() != conn->handle) { - ESP_LOGE(TAG, "%s, not connected", __func__); - return; - } + peer->conn = conn; if (dir == ESP_BLE_AUDIO_DIR_SINK) { - ESP_LOGI(TAG, "Sink discover complete"); + if (err) { + ESP_LOGE(TAG, "Sink discovery failed on handle %u, err %d", conn->handle, err); + peer_discovery_done(); + return; + } + + ESP_LOGI(TAG, "Sink discover complete: handle %u", conn->handle); err = esp_ble_audio_bap_unicast_client_discover(conn->handle, ESP_BLE_AUDIO_DIR_SOURCE); if (err) { ESP_LOGE(TAG, "Failed to discover source, err %d", err); - return; - } - } else if (dir == ESP_BLE_AUDIO_DIR_SOURCE) { - ESP_LOGI(TAG, "Source discover complete"); - - err = unicast_group_create(); - if (err) { - return; + peer_discovery_done(); } - err = unicast_audio_start(conn); - if (err) { - unicast_group_delete(); - return; - } + return; } + + if (dir != ESP_BLE_AUDIO_DIR_SOURCE) { + return; + } + + if (err) { + ESP_LOGE(TAG, "Source discovery failed on handle %u, err %d", conn->handle, err); + } else { + ESP_LOGI(TAG, "Source discover complete: handle %u", conn->handle); + } + + peer_discovery_done(); } static void pac_record_cb(esp_ble_conn_t *conn, esp_ble_audio_dir_t dir, @@ -521,25 +951,38 @@ static void endpoint_cb(esp_ble_conn_t *conn, esp_ble_audio_dir_t dir, esp_ble_audio_bap_ep_t *ep) { - if (dir == ESP_BLE_AUDIO_DIR_SOURCE) { - for (size_t i = 0; i < ARRAY_SIZE(unicast_source_eps); i++) { - if (unicast_source_eps[i] == NULL) { - ESP_LOGI(TAG, "[SRC #%zu] Endpoint discovered", i); - unicast_source_eps[i] = ep; - return; - } - } - } else if (dir == ESP_BLE_AUDIO_DIR_SINK) { - for (size_t i = 0; i < ARRAY_SIZE(unicast_sink_eps); i++) { - if (unicast_sink_eps[i] == NULL) { - ESP_LOGI(TAG, "[SNK #%zu] Endpoint discovered", i); - unicast_sink_eps[i] = ep; - return; - } - } + struct peer_eps *peer; + + /* Record every endpoint; bindings[] picks which ones are streamed on. */ + peer = peer_slot_alloc(conn->handle); + if (peer == NULL) { + ESP_LOGW(TAG, "[%s] Unhandled endpoint, peer table full", dir_str(dir)); + return; } - ESP_LOGW(TAG, "[%s] Unhandled endpoint", dir_str(dir)); + peer->conn = conn; + + if (dir == ESP_BLE_AUDIO_DIR_SOURCE) { + if (peer->source_ep == NULL) { + ESP_LOGI(TAG, "[%s #0] Endpoint discovered on handle %u", + dir_str(dir), conn->handle); + peer->source_ep = ep; + return; + } + } else if (dir == ESP_BLE_AUDIO_DIR_SINK) { + if (peer->sink_ep_count < ARRAY_SIZE(peer->sink_eps)) { + ESP_LOGI(TAG, "[%s #%u] Endpoint discovered on handle %u", + dir_str(dir), peer->sink_ep_count, conn->handle); + peer->sink_eps[peer->sink_ep_count++] = ep; + return; + } + } else { + ESP_LOGW(TAG, "Endpoint with unknown dir %u on handle %u", dir, conn->handle); + return; + } + + /* A peer may expose more ASEs than this topology streams on. */ + ESP_LOGI(TAG, "[%s] Spare endpoint on handle %u", dir_str(dir), conn->handle); } static esp_ble_audio_bap_unicast_client_cb_t unicast_client_cbs = { @@ -558,15 +1001,15 @@ static esp_ble_audio_bap_unicast_client_cb_t unicast_client_cbs = { .discover = discover_cb, }; -static void unicast_audio_tx(void) +static void unicast_audio_tx(struct sink_tx *tx) { esp_ble_audio_bap_ep_info_t ep_info = {0}; esp_ble_audio_cap_stream_t *cap_stream; esp_ble_audio_bap_stream_t *bap_stream; esp_err_t err; - cap_stream = &unicast_sink_streams[0]; - bap_stream = &unicast_sink_streams[0].bap_stream; + cap_stream = &unicast_sink_streams[tx->index]; + bap_stream = &cap_stream->bap_stream; if (bap_stream->ep == NULL) { return; @@ -582,38 +1025,41 @@ static void unicast_audio_tx(void) } if (bap_stream->qos == NULL || bap_stream->qos->sdu == 0) { - ESP_LOGE(TAG, "Invalid stream qos"); + ESP_LOGE(TAG, "[SNK #%u] Invalid stream qos", tx->index); return; } - if (iso_data == NULL) { - ESP_LOGE(TAG, "TX buffer unavailable, SDU %u", bap_stream->qos->sdu); + if (tx->data == NULL) { + ESP_LOGE(TAG, "[SNK #%u] TX buffer unavailable, SDU %u", + tx->index, bap_stream->qos->sdu); return; } - memset(iso_data, (uint8_t)tx_seq_num, bap_stream->qos->sdu); + memset(tx->data, (uint8_t)tx->seq_num, bap_stream->qos->sdu); - err = esp_ble_audio_cap_stream_send(cap_stream, iso_data, + err = esp_ble_audio_cap_stream_send(cap_stream, tx->data, bap_stream->qos->sdu, - tx_seq_num); + tx->seq_num); if (err) { - ESP_LOGD(TAG, "Failed to transmit data on streams, err %d", err); + /* Backpressure is normal; a stuck stream shows up as its TX count stalling. */ return; } - tx_seq_num++; + tx->seq_num++; } +/* Each stream has its own timer, so this only ever feeds its own stream. */ static void tx_scheduler_cb(void *arg) { - (void)arg; - unicast_audio_tx(); + unicast_audio_tx(arg); } int cap_initiator_init(void) { int err; + peers_reset(); + err = esp_ble_audio_cap_initiator_register_cb(&cap_cb); if (err) { ESP_LOGE(TAG, "Failed to register CAP callbacks (err %d)", err); @@ -628,43 +1074,68 @@ int cap_initiator_init(void) for (size_t i = 0; i < ARRAY_SIZE(unicast_sink_streams); i++) { esp_ble_audio_cap_stream_ops_register(&unicast_sink_streams[i], &unicast_stream_ops); + + /* Own timer per stream, fed with its own context. */ + sink_tx[i].index = (uint8_t)i; + + err = example_audio_tx_scheduler_init(&sink_tx[i].scheduler, + tx_scheduler_cb, + &sink_tx[i]); + if (err) { + ESP_LOGE(TAG, "Failed to init tx scheduler[%zu], err %d", i, err); + return err; + } } for (size_t i = 0; i < ARRAY_SIZE(unicast_source_streams); i++) { esp_ble_audio_cap_stream_ops_register(&unicast_source_streams[i], &unicast_stream_ops); } - err = example_audio_tx_scheduler_init(&tx_scheduler, - tx_scheduler_cb, - NULL); - if (err) { - ESP_LOGE(TAG, "Failed to init tx scheduler, err %d", err); - return err; - } - - ESP_LOGI(TAG, "CAP initiator initialized"); + ESP_LOGI(TAG, "CAP initiator initialized: %u peer(s), %u CIS", + TMAP_CEN_PEER_COUNT, TMAP_CEN_CIS_COUNT); return 0; } int cap_initiator_setup(void) { - uint16_t conn_handle; + size_t count; int err; - conn_handle = default_conn_handle_get(); - if (conn_handle == CONN_HANDLE_INIT) { + count = set_member_count(); + if (count == 0) { ESP_LOGE(TAG, "%s, not connected", __func__); return -ENOTCONN; } - err = esp_ble_audio_cap_initiator_unicast_discover(conn_handle); - if (err) { - ESP_LOGE(TAG, "Failed to discover CAS, err %d", err); - return err; + discover_pending = 0; + + /* Per member; the group is built when the last one reports back. A member + * that kept its link through a partial teardown kept its endpoints too, so + * only the one that came back is rediscovered. */ + for (size_t i = 0; i < count; i++) { + uint16_t conn_handle = set_member_handle(i); + + if (peer_is_ready(peer_by_handle(conn_handle))) { + continue; + } + + err = esp_ble_audio_cap_initiator_unicast_discover(conn_handle); + if (err) { + ESP_LOGE(TAG, "Failed to discover CAS on handle %u, err %d", conn_handle, err); + return err; + } + + discover_pending++; } - ESP_LOGI(TAG, "CAP initiator setup"); + ESP_LOGI(TAG, "CAP initiator setup for %zu member(s), %zu to discover", + count, discover_pending); + + /* Everything was already discovered, so nothing will report back. */ + if (discover_pending == 0) { + peers_ready(); + } return 0; } diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/csip_set_coordinator.c b/examples/bluetooth/esp_ble_audio/tmap/central/main/csip_set_coordinator.c new file mode 100644 index 00000000000..12c07c350fc --- /dev/null +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/csip_set_coordinator.c @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include + +#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 */ diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/main.c b/examples/bluetooth/esp_ble_audio/tmap/central/main/main.c index 40820630e56..4bb284e37e6 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/main/main.c +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/main.c @@ -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; diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/tmap_central.h b/examples/bluetooth/esp_ble_audio/tmap/central/main/tmap_central.h index a8b44f463af..be8d24bcfe8 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/main/tmap_central.h +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/tmap_central.h @@ -6,7 +6,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include +#include #include #include @@ -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); diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/main/vcp_vol_ctlr.c b/examples/bluetooth/esp_ble_audio/tmap/central/main/vcp_vol_ctlr.c index 8ef8aaccff3..694cf8e1d31 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/main/vcp_vol_ctlr.c +++ b/examples/bluetooth/esp_ble_audio/tmap/central/main/vcp_vol_ctlr.c @@ -7,17 +7,191 @@ */ #include +#include #include +#include + +#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(¶m); + 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(¶m); + 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; diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults index 959bab772bf..4e0e950604d 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults.nimble b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults.nimble index 615c7722d8d..f2740186c18 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults.nimble +++ b/examples/bluetooth/esp_ble_audio/tmap/central/sdkconfig.defaults.nimble @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/README.md b/examples/bluetooth/esp_ble_audio/tmap/peripheral/README.md index f5fb6895384..b685e5e1327 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/README.md +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/README.md @@ -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 1–2, 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 1–2, 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 TMAP_PER: vcp vol renderer, vocs_cnt aics_cnt 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 TMAP_PER: Read content control id succeeded, ccid ``` -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 ) TMAP_PER: [SNK #0] Stream enabled TMAP_PER: [SNK #0] Start request TMAP_PER: [SNK #0] Stream started +TMAP_PER: [SNK #0] RX: packets +TMAP_PER: [SRC #0] Stream started +TMAP_PER: [SRC #0] TX: packets TMAP_PER: [SNK #0] Stop request TMAP_PER: [SNK #0] Stream stopped, reason 0x 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

locked the lock` / `... released the lock`. diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/Kconfig.projbuild b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/Kconfig.projbuild index 6c72a87e40f..8208cbd5cb2 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/Kconfig.projbuild +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/Kconfig.projbuild @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bap_unicast_sr.c b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bap_unicast_sr.c index 6867bb769e9..83c674f9bef 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bap_unicast_sr.c +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bap_unicast_sr.c @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -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; } diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bluedroid/peripheral.c b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bluedroid/peripheral.c index 6f28f783052..7cd82c2bfc8 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bluedroid/peripheral.c +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/bluedroid/peripheral.c @@ -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; } diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/csip_set_member.c b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/csip_set_member.c index cc42a9d6819..a71f8fdcb95 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/csip_set_member.c +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/csip_set_member.c @@ -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, }; diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/nimble/peripheral.c b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/nimble/peripheral.c index b7c305bec42..02841d74db0 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/nimble/peripheral.c +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/nimble/peripheral.c @@ -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; diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/tmap_peripheral.h b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/tmap_peripheral.h index 6baa3058354..5b071949548 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/tmap_peripheral.h +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/main/tmap_peripheral.h @@ -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 diff --git a/examples/bluetooth/esp_ble_audio/tmap/peripheral/sdkconfig.defaults b/examples/bluetooth/esp_ble_audio/tmap/peripheral/sdkconfig.defaults index f37e7cf5b7b..2e9c2c2f928 100644 --- a/examples/bluetooth/esp_ble_audio/tmap/peripheral/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_audio/tmap/peripheral/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_iso/cis_central/README.md b/examples/bluetooth/esp_ble_iso/cis_central/README.md index 47dc7eddd0a..4a6f4d8a2f6 100644 --- a/examples/bluetooth/esp_ble_iso/cis_central/README.md +++ b/examples/bluetooth/esp_ble_iso/cis_central/README.md @@ -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 role peer XX:XX:XX:XX:XX:XX I CIS_CEN: Security: handle level bonded ``` -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: packets +I CIS_CEN: [CIS #1] TX: 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 +I CIS_CEN: [CIS #1] Disconnected, reason 0x I CIS_CEN: Disconnected: handle reason 0x ``` @@ -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: 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: packets` per CIS periodically. diff --git a/examples/bluetooth/esp_ble_iso/cis_central/main/main.c b/examples/bluetooth/esp_ble_iso/cis_central/main/main.c index 76ced1a872c..e882ecf1aed 100644 --- a/examples/bluetooth/esp_ble_iso/cis_central/main/main.c +++ b/examples/bluetooth/esp_ble_iso/cis_central/main/main.c @@ -5,6 +5,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include @@ -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(); diff --git a/examples/bluetooth/esp_ble_iso/cis_central/sdkconfig.defaults b/examples/bluetooth/esp_ble_iso/cis_central/sdkconfig.defaults index f46ae665e96..3b0a180854b 100644 --- a/examples/bluetooth/esp_ble_iso/cis_central/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_iso/cis_central/sdkconfig.defaults @@ -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 diff --git a/examples/bluetooth/esp_ble_iso/cis_peripheral/README.md b/examples/bluetooth/esp_ble_iso/cis_peripheral/README.md index d1da73f0924..8bb24898ad5 100644 --- a/examples/bluetooth/esp_ble_iso/cis_peripheral/README.md +++ b/examples/bluetooth/esp_ble_iso/cis_peripheral/README.md @@ -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 role peer XX:XX:XX:XX:XX:XX I CIS_PER: Security: handle level bonded ``` -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 I CIS_PER: [CIS #0] Connected +I CIS_PER: Incoming CIS request from handle +I CIS_PER: [CIS #1] Connected I CIS_PER: [CIS #0] RX: packets +I CIS_PER: [CIS #1] RX: 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 +I CIS_PER: [CIS #1] Disconnected, reason 0x I CIS_PER: Disconnected: handle reason 0x ``` @@ -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: 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 #] RX: packets` is logged periodically for each. diff --git a/examples/bluetooth/esp_ble_iso/cis_peripheral/main/main.c b/examples/bluetooth/esp_ble_iso/cis_peripheral/main/main.c index d4e6dd0286f..a94a18b08ce 100644 --- a/examples/bluetooth/esp_ble_iso/cis_peripheral/main/main.c +++ b/examples/bluetooth/esp_ble_iso/cis_peripheral/main/main.c @@ -6,6 +6,7 @@ */ #include +#include #include #include @@ -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); diff --git a/examples/bluetooth/esp_ble_iso/cis_peripheral/sdkconfig.defaults b/examples/bluetooth/esp_ble_iso/cis_peripheral/sdkconfig.defaults index d121d2fbbc7..cd5c479f421 100644 --- a/examples/bluetooth/esp_ble_iso/cis_peripheral/sdkconfig.defaults +++ b/examples/bluetooth/esp_ble_iso/cis_peripheral/sdkconfig.defaults @@ -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