All of lore.kernel.org
 help / color / mirror / Atom feed
From: Luiz Augusto von Dentz <luiz.dentz@gmail.com>
To: linux-bluetooth@vger.kernel.org
Subject: [PATCH BlueZ v1 11/11] test: functional: add the BAP coordinated set tests
Date: Tue, 15 Sep 2026 17:15:28 -0400	[thread overview]
Message-ID: <20260915211528.1223784-12-luiz.dentz@gmail.com> (raw)
In-Reply-To: <20260915211528.1223784-1-luiz.dentz@gmail.com>

From: Luiz Augusto von Dentz <luiz.von.dentz@intel.com>

Cover a stereo unicast stream to a coordinated set of two acceptors,
one taking the left channel and one the right, which is what a pair of
earbuds does.

The acceptors advertise the RSI of the set and are configured with the
same SIRK, so the initiator resolves them into a single DeviceSet and
connects the second one on its own once the first is paired.

The endpoints are not configured by the test, as the daemon configures
a stream for them by itself, and the transports of both members are
acquired, as the CIS of a CIG are only created once every one of them
is active.

Assisted-by: opencode:claude-opus-5
---
 client/scripts/bap-sink-lc3-left.bt  |  30 ++++
 client/scripts/bap-sink-lc3-right.bt |  30 ++++
 doc/functional-bap.rst               |  38 +++--
 test/functional/test_bap.py          | 229 ++++++++++++++++++++++++++-
 4 files changed, 309 insertions(+), 18 deletions(-)
 create mode 100644 client/scripts/bap-sink-lc3-left.bt
 create mode 100644 client/scripts/bap-sink-lc3-right.bt

diff --git a/client/scripts/bap-sink-lc3-left.bt b/client/scripts/bap-sink-lc3-left.bt
new file mode 100644
index 000000000000..9ad8aad97f7a
--- /dev/null
+++ b/client/scripts/bap-sink-lc3-left.bt
@@ -0,0 +1,30 @@
+power on
+#
+#
+# Register a PAC Sink endpoint with LC3 taking a single channel, to act
+# as the left member of a coordinated set. The members of the set are
+# programmed with the same SIRK, see [CSIS] in main.conf.
+#
+endpoint.register 00002bc9-0000-1000-8000-00805f9b34fb 0x06
+# Auto Accept: accept the configuration chosen by the peer
+y
+# Max Transports: no limit
+a
+# Locations: Front Left, so only its own channel is configured
+1
+# Supported Context: same as the contexts bluetoothctl registers with
+0x0fff
+# Context
+0x0fff
+# CIG: assigned automatically
+a
+# CIS: assigned automatically
+a
+#
+#
+# Advertise the Resolvable Set Identifier, so the initiator is able to
+# resolve this device as a member of the set, otherwise it is streamed
+# to on its own.
+#
+advertise.rsi on
+advertise on
diff --git a/client/scripts/bap-sink-lc3-right.bt b/client/scripts/bap-sink-lc3-right.bt
new file mode 100644
index 000000000000..a6107383adf2
--- /dev/null
+++ b/client/scripts/bap-sink-lc3-right.bt
@@ -0,0 +1,30 @@
+power on
+#
+#
+# Register a PAC Sink endpoint with LC3 taking a single channel, to act
+# as the right member of a coordinated set. The members of the set are
+# programmed with the same SIRK, see [CSIS] in main.conf.
+#
+endpoint.register 00002bc9-0000-1000-8000-00805f9b34fb 0x06
+# Auto Accept: accept the configuration chosen by the peer
+y
+# Max Transports: no limit
+a
+# Locations: Front Right, so only its own channel is configured
+2
+# Supported Context: same as the contexts bluetoothctl registers with
+0x0fff
+# Context
+0x0fff
+# CIG: assigned automatically
+a
+# CIS: assigned automatically
+a
+#
+#
+# Advertise the Resolvable Set Identifier, so the initiator is able to
+# resolve this device as a member of the set, otherwise it is streamed
+# to on its own.
+#
+advertise.rsi on
+advertise on
diff --git a/doc/functional-bap.rst b/doc/functional-bap.rst
index 78411f4dd47d..3fc3439c118e 100644
--- a/doc/functional-bap.rst
+++ b/doc/functional-bap.rst
@@ -316,6 +316,9 @@ each side, so the initiator has to discover both before it can stream:
 	both acceptors share the same SIRK, so they are resolved into a
 	single set, and one CIS per member carries its channel
 
+	connecting one member connects the rest of the set, so the
+	initiator only connects once
+
 	--> connection is initiated by      ==> audio flows towards
 
 The acceptors run `bluetoothd` with the same SIRK, so the initiator
@@ -356,24 +359,30 @@ test_bap_unicast_set_transport_created
 
 :Steps:
 	1. Start `bluetoothctl` with the scripts on the three hosts.
-	2. Pair the initiator with each acceptor over LE.
-	3. Initiator: ``endpoint.config <remote endpoint>
-	   /local/endpoint/ep0 16_2_1``, for the remote PAC Sink endpoint
-	   of each member.
+	2. Pair the initiator with one of the acceptors over LE.
 
 :Expected:
 	1. ``Endpoint /local/endpoint/ep0 registered`` on the three hosts.
-	2. ``Pairing successful`` for both, and the members are resolved
-	   into a single set, reported as
-	   ``[NEW] DeviceSet /org/bluez/hci0/set_<sirk>`` listing both
-	   devices.
+	2. ``Pairing successful``, the members are resolved into a single
+	   set, reported as
+	   ``[NEW] DeviceSet /org/bluez/hci0/set_<sirk>``, and the other
+	   member is bonded on its own, without the initiator connecting
+	   or pairing it.
 	3. A transport is created for each member, one per channel, i.e.
 	   one for the Front Left endpoint and one for the Front Right
 	   one.
 
-:Notes: Without the same SIRK, or without the RSI in the advertising,
-	the members are not resolved into a set and each is streamed to
-	on its own, which is not what this test covers.
+	The endpoints are not configured by the test: the daemon
+	configures a stream for them on its own, and configuring them
+	again would add a CIS to the CIG for every extra configuration.
+
+:Notes: Only one member is connected: finding a member of a set
+	triggers connecting the remaining ones, so a single
+	**org.bluez.Device(5)** ``Connect`` covers the whole set.
+
+	Without the same SIRK, or without the RSI in the advertising, the
+	members are not resolved into a set, each is connected on its own
+	and the set behaviour this test covers does not happen.
 
 test_bap_unicast_set_transport_acquire
 --------------------------------------
@@ -386,6 +395,7 @@ test_bap_unicast_set_transport_acquire
 :Expected: ``Acquire successful: fd <fd> MTU <read>:<write>`` for each
 	transport, and both move to ``State: active``.
 
-:Notes: As for a stereo stream to a single acceptor, the CIS are only
-	created once every CIS of the CIG is ready, so the transports of
-	both members have to be acquired.
+:Notes: As for a stereo stream to a single acceptor, the CIS of a CIG
+	are only created once every one of them is active, so the
+	transports of both members have to be acquired: acquiring only
+	one leaves the group incomplete and no CIS is created at all.
diff --git a/test/functional/test_bap.py b/test/functional/test_bap.py
index 6ffbf0371e0e..ce11dc38dc32 100644
--- a/test/functional/test_bap.py
+++ b/test/functional/test_bap.py
@@ -4,6 +4,7 @@
 Tests for BAP (LE Audio) using bluetoothctl in VM instances
 """
 
+import re
 import warnings
 
 import pytest
@@ -78,8 +79,18 @@ def script(name):
 
 def start_bluetoothctl(host, init_script):
     exe = find_exe("client", "bluetoothctl")
-    # -a auto: accept pairing and authorize services without prompting
-    ctl = host.pexpect.spawn([exe, "-a", "auto", "--init-script", script(init_script)])
+    # Accept pairing and authorize services without prompting, with a
+    # capability pairing Just Works, as there is no one to answer the
+    # entry of a passkey
+    ctl = host.pexpect.spawn(
+        [
+            exe,
+            "-a",
+            "auto:NoInputNoOutput",
+            "--init-script",
+            script(init_script),
+        ]
+    )
     ctl.expect("Endpoint /local/endpoint/ep0 registered")
     return ctl
 
@@ -99,10 +110,69 @@ def pair_le(host0, ctl0, host1, ctl1, advertise=True, services=False):
     if services:
         pending.append(f"Device {host1.bdaddr.upper()} ServicesResolved: yes")
 
+    pair_wait(ctl0, ctl1, pending)
+
+    ctl0.send("scan off\n")
+
+
+# Transport of the remote PAC Sink endpoint of a device
+TRANSPORT = r"Transport (/org/bluez/hci0/dev_{}/pac_sink\d+/fd\d+)"
+
+# What a command reports is printed as it runs, unlike what the peers
+# report over the air, so waiting the default timeout for it only makes
+# a failure slower
+REPLY_TIMEOUT = 5
+
+
+def list_transports(ctl0, hosts):
+    """
+    List the transports already available for the given hosts, giving
+    None for those that are not.
+
+    The listing has no end of its own, so a command with a known output
+    is issued after it to tell when everything has been listed.
+    """
+    patterns = [TRANSPORT.format(dev_addr(host)) for host in hosts]
+
+    ctl0.send("transport.list\nversion\n")
+
+    transports = [None] * len(patterns)
+
+    while True:
+        idx, m = ctl0.expect([r"Version \d"] + patterns, timeout=REPLY_TIMEOUT)
+        if not idx:
+            return transports
+
+        transports[idx - 1] = m[0].decode("utf-8")
+
+
+def remove_device(ctl0, host):
+    """
+    Remove the given host, so the test starts from a state where it is
+    neither paired nor connected.
+    """
+    addr = host.bdaddr.upper()
+
+    ctl0.send(f"remove {addr}\n")
+
+    # Not matched with expect_all: a device that is not available is
+    # simply one with nothing to remove, not a failure
+    ctl0.expect(
+        rf"Device has been removed|Device {addr} not available",
+        timeout=REPLY_TIMEOUT,
+    )
+
+
+def pair_wait(ctl0, ctl1, pending):
+    """
+    Wait for the given events while answering the pairing requests.
+    """
     # See test_bluetoothctl_pair_le: passkey confirmation is handled by
     # the auto agent, but legacy passkey entry still needs an answer
     legacy = r"\[agent\].*Passkey:.*m(\d+)"
 
+    pending = list(pending)
+
     while pending:
         idx, m = ctl0.expect(FAILURES + [legacy] + pending)
 
@@ -123,8 +193,6 @@ def pair_le(host0, ctl0, host1, ctl1, advertise=True, services=False):
 
         pending.pop(idx - 1)
 
-    ctl0.send("scan off\n")
-
 
 unicast_host_config = host_config(
     [Bluetoothd(conf=BAP_CONF), Pexpect()],
@@ -337,3 +405,156 @@ def test_bass_past_transport_acquire(hosts):
             f"Transport {transport} State: active",
         ],
     )
+
+
+# Key shared by the members of the set, see [CSIS] in main.conf
+SIRK = "861FAE703ED681F0C50B34155B6434FB"
+
+# A set is exposed under the SIRK of its members, in reverse byte order
+SET_PATH = "set_" + bytes.fromhex(SIRK)[::-1].hex()
+
+
+def csip_conf(rank):
+    """
+    Configuration of a member of a coordinated set: the key and the size
+    describe the set, so they are the same for every member, while the
+    rank identifies the member within it.
+    """
+    return BAP_CONF + f"""
+[CSIS]
+SIRK = {SIRK}
+Encryption = true
+Size = 2
+Rank = {rank}
+"""
+
+
+set_host_config = host_config(
+    [Bluetoothd(conf=BAP_CONF), Pexpect()],
+    [Bluetoothd(conf=csip_conf(1)), Pexpect()],
+    [Bluetoothd(conf=csip_conf(2)), Pexpect()],
+)
+
+
+@pytest.fixture
+def set_hosts(hosts):
+    """
+    Initiator (host0) and two acceptors forming a coordinated set, one
+    taking the left channel (host1) and one the right (host2), paired
+    over LE.
+    """
+    host0, host1, host2 = hosts
+
+    initiator = start_bluetoothctl(host0, "bap-source-lc3.bt")
+    left = start_bluetoothctl(host1, "bap-sink-lc3-left.bt")
+    right = start_bluetoothctl(host2, "bap-sink-lc3-right.bt")
+
+    # Every member has to be advertising before connecting, so the set
+    # can be resolved and the remaining members found. The acceptors
+    # advertise themselves, as they have to include the RSI.
+    #
+    # Matched before anything else is read from them, as an expect
+    # discards everything before what it matches.
+    left.expect("Advertising object registered", timeout=REPLY_TIMEOUT)
+    right.expect("Advertising object registered", timeout=REPLY_TIMEOUT)
+
+    # A previous test may have left everything in place, in which case
+    # there is nothing to set up and the transports are used as they
+    # are
+    transports = list_transports(initiator, (host1, host2))
+
+    if all(transports):
+        yield host0, host1, host2, initiator, left, right, transports
+        return
+
+    # Otherwise the setup starts from a clean state: a device kept from
+    # a previous test would already be paired, connected and part of
+    # the set, so none of the events waited for below would be
+    # reported again
+    remove_device(initiator, host1)
+    remove_device(initiator, host2)
+    remove_device(left, host0)
+    remove_device(right, host0)
+
+    initiator.send("scan on\n")
+    initiator.expect(
+        f"Controller {host0.bdaddr.upper()} Discovering: yes",
+        timeout=REPLY_TIMEOUT,
+    )
+
+    # Connect only once every member has been found: the set is resolved
+    # from the RSI of the members that are already known, so a member
+    # found later would not be part of it
+    expect_all(
+        initiator,
+        [
+            f"Device {host1.bdaddr.upper()}",
+            f"Device {host2.bdaddr.upper()}",
+        ],
+    )
+
+    # Stop scanning before connecting, so the discovery does not
+    # interfere with the connections to the members
+    initiator.send("scan off\n")
+    initiator.expect(
+        f"Controller {host0.bdaddr.upper()} Discovering: no",
+        timeout=REPLY_TIMEOUT,
+    )
+
+    initiator.send(f"pair {host1.bdaddr.upper()}\n")
+
+    # Finding a member of a set connects the remaining ones, so the
+    # other member is neither connected nor paired by the test: it is
+    # bonded as reading its services requires an encrypted link, which
+    # is what is waited for. Its connection is not, as the link of a
+    # previous test may still be up, in which case the device is
+    # reported as connected from the start and never changes.
+    #
+    # The streams are configured by the daemon, so the transports are
+    # created without the test configuring the endpoints.
+    #
+    # The two members report independently, so their events interleave
+    # and have to be matched in any order, in a single pass including
+    # the pairing: an expect only reports what it matches and discards
+    # everything before it, so waiting for one event at a time drops
+    # the ones that happen meanwhile, e.g. a transport created while
+    # the pairing is still being waited for.
+    groups = expect_all(
+        initiator,
+        [
+            "Pairing successful",
+            f"DeviceSet /org/bluez/hci0/{SET_PATH}",
+            f"Device {host2.bdaddr.upper()} Bonded: yes",
+            TRANSPORT.format(dev_addr(host1)),
+            TRANSPORT.format(dev_addr(host2)),
+        ],
+    )
+
+    transports = [m[0].decode("utf-8") for m in groups[-2:]]
+
+    yield host0, host1, host2, initiator, left, right, transports
+
+
+@set_host_config
+def test_bap_unicast_set_transport_created(set_hosts):
+    host0, host1, host2, initiator, left, right, transports = set_hosts
+
+    # One transport per member, each taking a single channel
+    left.expect(TRANSPORT_RE)
+    right.expect(TRANSPORT_RE)
+
+
+@set_host_config
+def test_bap_unicast_set_transport_acquire(set_hosts):
+    host0, host1, host2, initiator, left, right, transports = set_hosts
+
+    # The CIS of a CIG are only created once every one of them is
+    # active, so the transports of both members have to be acquired
+    initiator.send("transport.acquire {} {}\n".format(*transports))
+
+    acquired = r"Acquire successful: fd \d+ MTU \d+:\d+"
+    expect_all(
+        initiator,
+        [acquired, acquired]
+        + [f"Transport {transport} State: active" for transport in transports],
+    )
-- 
2.55.0


  parent reply	other threads:[~2026-09-15 21:15 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-15 21:15 [PATCH BlueZ v1 00/11] Cover a BAP stream to a coordinated set Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 01/11] shared/bass: add a ready callback Luiz Augusto von Dentz
2026-09-15 21:58   ` Cover a BAP stream to a coordinated set bluez.test.bot
2026-09-16  0:55   ` bluez.test.bot
2026-09-15 21:15 ` [PATCH BlueZ v1 02/11] bass: wait for the session to be ready before pushing a source Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 03/11] test: functional: report what a test is waiting for Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 04/11] test: functional: answer the notification registered by the victim Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 05/11] doc: describe the BAP coordinated set test cases Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 06/11] emulator: use the handle of the receiving side Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 07/11] emulator: allow the maximum number of CIS of a CIG Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 08/11] device: keep the long term key of both roles Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 09/11] client: allow a capability with the auto agent Luiz Augusto von Dentz
2026-09-15 21:15 ` [PATCH BlueZ v1 10/11] test: functional: fail as soon as an operation fails Luiz Augusto von Dentz
2026-09-15 21:15 ` Luiz Augusto von Dentz [this message]
2026-09-16 18:30 ` [PATCH BlueZ v1 00/11] Cover a BAP stream to a coordinated set patchwork-bot+bluetooth

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260915211528.1223784-12-luiz.dentz@gmail.com \
    --to=luiz.dentz@gmail.com \
    --cc=linux-bluetooth@vger.kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.