All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests
@ 2026-09-15  0:07 Pauli Virtanen
  2026-09-15  0:07 ` [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases Pauli Virtanen
                   ` (3 more replies)
  0 siblings, 4 replies; 6+ messages in thread
From: Pauli Virtanen @ 2026-09-15  0:07 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Pauli Virtanen

Add tests for:

MPRIS -> local player -> AVRCP / MCP -> remote player -> MPRIS

Connection and basic operations are tested on smoketest level.

The code is LLM-assisted, with manual design and details input. I
reviewed and tested the result.

Assisted-by: opencode:qwen3.8-flash-next

Pauli Virtanen (3):
  test: functional: add mpris-proxy BR/EDR test cases
  test: functional: deduce pairing bearer in shared fixtures
  test: functional: run mpris-proxy tests over LE via MCP

 Makefile.am                         |   1 +
 doc/functional-mpris-proxy.rst      | 180 +++++++++++
 doc/functional-testing.rst          |   7 +-
 test/functional/conftest.py         |  23 +-
 test/functional/le_utils.py         | 195 ++++++++++++
 test/functional/test_avrcp.py       |   4 +-
 test/functional/test_mpris_proxy.py | 467 ++++++++++++++++++++++++++++
 test/functional/test_obex.py        |   8 +-
 8 files changed, 873 insertions(+), 12 deletions(-)
 create mode 100644 doc/functional-mpris-proxy.rst
 create mode 100644 test/functional/le_utils.py
 create mode 100644 test/functional/test_mpris_proxy.py

-- 
2.55.0


^ permalink raw reply	[flat|nested] 6+ messages in thread

* [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases
  2026-09-15  0:07 [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests Pauli Virtanen
@ 2026-09-15  0:07 ` Pauli Virtanen
  2026-09-15  1:55   ` test: functional: add mpris-proxy end-to-end tests bluez.test.bot
  2026-09-15  0:07 ` [PATCH BlueZ 2/3] test: functional: deduce pairing bearer in shared fixtures Pauli Virtanen
                   ` (2 subsequent siblings)
  3 siblings, 1 reply; 6+ messages in thread
From: Pauli Virtanen @ 2026-09-15  0:07 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Pauli Virtanen

Add functional tests for mpris-proxy over BR/EDR: a dummy MPRIS player
on the server is bridged by mpris-proxy to an AVRCP Target, and a
second mpris-proxy on the client, run with --export, exports the
remote player back to the client session bus as an MPRIS player, which
the tests control as MPRIS clients.

test/functional/test_mpris_proxy.py::test_mpris_proxy_playback_control
test/functional/test_mpris_proxy.py::test_mpris_proxy_track_control

Assisted-by: opencode:qwen3.8-flash-next
---
 Makefile.am                         |   1 +
 doc/functional-mpris-proxy.rst      | 145 ++++++++++
 doc/functional-testing.rst          |   1 +
 test/functional/test_mpris_proxy.py | 428 ++++++++++++++++++++++++++++
 4 files changed, 575 insertions(+)
 create mode 100644 doc/functional-mpris-proxy.rst
 create mode 100644 test/functional/test_mpris_proxy.py

diff --git a/Makefile.am b/Makefile.am
index cef28673a..1d46b9b93 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -501,6 +501,7 @@ EXTRA_DIST += doc/assigned-numbers.rst doc/supported-features.txt \
 				doc/functional-a2dp.rst \
 				doc/functional-avrcp.rst \
 				doc/functional-bap.rst \
+				doc/functional-mpris-proxy.rst \
 				doc/functional-obex.rst \
 				doc/settings-storage.txt
 
diff --git a/doc/functional-mpris-proxy.rst b/doc/functional-mpris-proxy.rst
new file mode 100644
index 000000000..a6936275b
--- /dev/null
+++ b/doc/functional-mpris-proxy.rst
@@ -0,0 +1,145 @@
+======================
+functional-mpris-proxy
+======================
+
+DESCRIPTION
+===========
+
+mpris-proxy functional tests in `test/functional/test_mpris_proxy.py`.
+See **functional-testing(7)** for the conventions used here, and
+**test-functional(1)** for how to run the suite.
+
+`mpris-proxy` bridges MPRIS 2.2 players and **bluetoothd**: it
+registers a local session bus player with **bluetoothd** using
+``org.bluez.Media1.RegisterPlayer``, and, run with ``--export``,
+exports a remote ``org.bluez.MediaPlayer1`` object to the session bus
+as an MPRIS player.
+
+The upper tester drives two VM hosts, host0 as the client and host1 as
+the server, connected over BR/EDR, where the bridge is an AVRCP Target
+and Controller. The server presents a dummy MPRIS player. The client
+runs a second `mpris-proxy` with ``--export``, which exports that
+remote player on the client session bus, and the upper tester controls
+it there as an ordinary MPRIS client.
+
+SETUP
+=====
+
+.. code-block::
+
+	+--------------------------------+            +--------------------------------+
+	| host0 / client                 |   BR/EDR   | host1 / server                 |
+	| bluetoothd                     |   AVCTP    | bluetoothd                     |
+	| mpris-proxy --export           |  PSM 0x17  | mpris-proxy                    |
+	| MPRIS client (upper tester)    |  ------->  | dummy MPRIS player             |
+	| org.mpris.MediaPlayer2.        |            | org.mpris.MediaPlayer2.        |
+	|  <name> (session bus)          |            |  blueztest (session bus)       |
+	+--------------------------------+            +--------------------------------+
+
+The server host owns the session bus name
+``org.mpris.MediaPlayer2.blueztest`` and exports
+``/org/mpris/MediaPlayer2`` with the ``org.mpris.MediaPlayer2`` and
+``org.mpris.MediaPlayer2.Player`` interfaces. Its player starts
+stopped, at track 0. Every MPRIS method call it receives is reported
+to the upper tester. It changes its properties only when the upper
+tester tells it to, and then emits
+``org.freedesktop.DBus.Properties.PropertiesChanged``.
+
+The `mpris-proxy` on the server registers that player with its
+**bluetoothd** through ``org.bluez.Media1.RegisterPlayer``, so the
+server host is the AVRCP Target. The `mpris-proxy --export` on the
+client exports the ``org.bluez.MediaPlayer1`` object its bluetoothd
+creates for the remote player as ``org.mpris.MediaPlayer2.<name>``,
+where ``<name>`` is derived from the remote device alias, at
+``/org/mpris/MediaPlayer2`` on the client session bus. Both proxies use
+adapter index 0.
+
+Before each case the upper tester does the following, and checks each
+step:
+
+	1. Pair the hosts over BR/EDR, unless they are already paired, as
+	   the host setup is reused between cases: the client discovers
+	   the server and calls ``org.bluez.Device1.Pair``, and both
+	   agents confirm the passkey.
+	2. On both hosts, set ``Trusted`` to true with
+	   ``org.freedesktop.DBus.Properties.Set`` on the
+	   ``org.bluez.Device1`` object of the peer host.
+	3. Connect on the client with ``org.bluez.Device1.ConnectProfile``
+	   and the AVRCP Controller UUID
+	   (``0000110c-0000-1000-8000-00805f9b34fb``). The call must
+	   reply, or fail with ``AlreadyConnected`` since a previous case
+	   may still be connected.
+	4. Wait until the client session bus has exactly one name with the
+	   prefix ``org.mpris.MediaPlayer2.``, listed with
+	   ``org.freedesktop.DBus.ListNames``. That is the exported player.
+	5. Wait until its ``PlaybackStatus``, read with
+	   ``org.freedesktop.DBus.Properties.Get``, is ``Stopped``.
+
+The same connection and exported player are used by both cases.
+
+TEARDOWN
+========
+
+After each case, also on failure, the upper tester resets the dummy
+player on the server to stopped and track 0, which emits
+``PropertiesChanged`` for ``PlaybackStatus`` and ``Metadata``, and
+drops the method calls left over from the previous case. It then waits
+until the exported player on the client reads ``Stopped`` again. The
+hosts, both bluetoothd instances, both proxies and the connection are
+left running.
+
+TEST CASES
+==========
+
+test_mpris_proxy_playback_control
+---------------------------------
+
+:Setup: As above.
+
+:Steps: For each of ``Play``, ``Pause`` and ``Stop``, in that order:
+
+	1. The upper tester calls the method on the exported player
+	   ``org.mpris.MediaPlayer2.Player`` interface on the client
+	   session bus, and waits for the reply.
+	2. It waits until the dummy player on the server reports the same
+	   MPRIS method call.
+	3. It then lets the dummy player react as a player application
+	   would, setting ``PlaybackStatus`` to ``Playing``, ``Paused``
+	   and ``Stopped``, emitted as ``PropertiesChanged``.
+	4. It waits until the exported player on the client reports the
+	   expected ``PlaybackStatus``.
+
+:Expected:
+	1. Each call is answered on the client session bus.
+	2. The dummy player on the server receives ``Play``, ``Pause`` and
+	   ``Stop``.
+	3. The exported player on the client reports ``Playing``,
+	   ``Paused`` and ``Stopped``.
+
+test_mpris_proxy_track_control
+------------------------------
+
+:Setup: As above.
+
+:Steps:
+	1. The upper tester reads the current track number of the dummy
+	   player on the server.
+	2. It calls ``Next`` on the exported player on the client session
+	   bus and waits for the reply.
+	3. It waits until the dummy player reports ``Next``, and then has
+	   it increment the track number and emit the new ``Metadata``,
+	   whose ``xesam:title`` is ``Test Track <n>``.
+	4. It waits until the exported player on the client reports that
+	   title in ``Metadata``.
+	5. It repeats the same for ``Previous``, which decrements the
+	   track number.
+
+:Expected:
+	1. The dummy player on the server receives ``Next`` and
+	   ``Previous``.
+	2. The metadata of the new track reaches the exported player on
+	   the client: both its ``xesam:title`` and its
+	   ``xesam:trackNumber`` match the new track.
+
+:Notes: The dummy player changes its state only once the upper tester
+	has observed the corresponding call.
diff --git a/doc/functional-testing.rst b/doc/functional-testing.rst
index 60c6762de..6fdc17b9a 100644
--- a/doc/functional-testing.rst
+++ b/doc/functional-testing.rst
@@ -15,6 +15,7 @@ are documented separately:
 - **functional-a2dp(7)**: `test/functional/test_a2dp.py`
 - **functional-avrcp(7)**: `test/functional/test_avrcp.py`
 - **functional-bap(7)**: `test/functional/test_bap.py`
+- **functional-mpris-proxy(7)**: `test/functional/test_mpris_proxy.py`
 - **functional-obex(7)**: `test/functional/test_obex.py`
 
 Each test case is described as:
diff --git a/test/functional/test_mpris_proxy.py b/test/functional/test_mpris_proxy.py
new file mode 100644
index 000000000..54b888c79
--- /dev/null
+++ b/test/functional/test_mpris_proxy.py
@@ -0,0 +1,428 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""
+End-to-end tests for mpris-proxy.
+"""
+
+import logging
+import subprocess
+
+import dbus
+import dbus.exceptions
+import dbus.service
+import pytest
+
+from pytest_bluezenv import (
+    Agent,
+    Bluetoothd,
+    DbusSession,
+    EventPluginMixin,
+    HostPlugin,
+    LogStream,
+    dbus_service_event_method,
+    find_exe,
+    get_dbus,
+    host_config,
+    mainloop_assert,
+    mainloop_wrap,
+    quoted,
+    wait_until,
+)
+
+BLUEZ_BUS_NAME = "org.bluez"
+
+MPRIS_BUS_NAME = "org.mpris.MediaPlayer2.blueztest"
+MPRIS_PATH = "/org/mpris/MediaPlayer2"
+MPRIS_ROOT_INTERFACE = "org.mpris.MediaPlayer2"
+MPRIS_PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player"
+
+MPRIS_BUS_PREFIX = "org.mpris.MediaPlayer2."
+
+BLUEZ_DEVICE_INTERFACE = "org.bluez.Device1"
+PROPS_INTERFACE = "org.freedesktop.DBus.Properties"
+
+AVRCP_CONTROLLER_UUID = "0000110c-0000-1000-8000-00805f9b34fb"
+
+
+def mpris_player_method(*a, **kw):
+    return dbus_service_event_method(MPRIS_PLAYER_INTERFACE, *a, **kw)
+
+
+class MprisPlayerService(dbus.service.Object):
+    """
+    Dummy implementation of the MPRIS 2.2 root and player interfaces on
+    the session bus. Method calls are pushed as events to the owner
+    plugin event queue, like AgentObject does.
+    """
+
+    class UnknownProperty(dbus.exceptions.DBusException):
+        _dbus_error_name = "org.freedesktop.DBus.Error.UnknownProperty"
+
+    @mainloop_assert
+    def __init__(self, bus_name, owner):
+        self.events = owner.events
+        self.owner = owner
+        super().__init__(bus_name, MPRIS_PATH)
+
+    Play = mpris_player_method("Play")
+    Pause = mpris_player_method("Pause")
+    Stop = mpris_player_method("Stop")
+    Next = mpris_player_method("Next")
+    Previous = mpris_player_method("Previous")
+
+    @dbus.service.method(PROPS_INTERFACE, in_signature="ss", out_signature="v")
+    def Get(self, interface, prop):
+        props = self._properties(interface)
+        if prop not in props:
+            raise self.UnknownProperty(f"No such property {prop} on {interface}")
+        return props[prop]
+
+    @dbus.service.method(PROPS_INTERFACE, in_signature="s", out_signature="a{sv}")
+    def GetAll(self, interface):
+        return self._properties(interface)
+
+    @dbus.service.signal(PROPS_INTERFACE, signature="sa{sv}as")
+    def PropertiesChanged(self, interface, changed, invalidated):
+        pass
+
+    def _properties(self, interface):
+        if interface == MPRIS_ROOT_INTERFACE:
+            return self.owner.root_properties()
+        if interface == MPRIS_PLAYER_INTERFACE:
+            return self.owner.player_properties()
+        return dbus.Dictionary({}, signature="sv")
+
+
+class DummyMprisPlayer(HostPlugin, EventPluginMixin):
+    """
+    Host plugin providing a dummy MPRIS player on the session bus under the
+    name org.mpris.MediaPlayer2.blueztest. mpris-proxy picks it up and
+    registers it to BlueZ as a media player.
+
+    Method calls received by the player are pushed as asynchronous
+    events, named org.mpris.MediaPlayer2.Player.<method>. Tests use
+    expect() to observe them, and update the player state explicitly with
+    the setter methods, which emit MPRIS PropertiesChanged.
+    """
+
+    name = "dummy_player"
+    depends = [DbusSession()]
+
+    PLAYER_NAME = "BlueZ Test Player"
+
+    @mainloop_wrap
+    def setup(self, impl):
+        EventPluginMixin.setup(self, impl)
+
+        self.log = logging.getLogger(self.name)
+
+        self.status = "Stopped"
+        self.track_number = 0
+
+        self.bus = get_dbus(session=True)
+        self.bus_name = dbus.service.BusName(MPRIS_BUS_NAME, bus=self.bus)
+        self.service = MprisPlayerService(self.bus_name, self)
+
+        self.log.info(f"MPRIS player {MPRIS_BUS_NAME} exported")
+
+    @mainloop_wrap
+    def teardown(self):
+        self.service.remove_from_connection()
+        self.bus_name = None
+        self.service = None
+
+    @mainloop_wrap
+    def set_status(self, status):
+        # Emit unconditionally: the test relies on PropertiesChanged being
+        # sent for every update it requests
+        self.status = status
+        self._emit_properties_changed(PlaybackStatus=dbus.String(status))
+
+    @mainloop_wrap
+    def advance_track(self, delta):
+        self.track_number = max(0, self.track_number + delta)
+        self._emit_properties_changed(Metadata=self.metadata())
+
+    @mainloop_wrap
+    def get_track_number(self):
+        return self.track_number
+
+    @mainloop_wrap
+    def reset(self):
+        # Emit unconditionally so that the remote state follows the reset,
+        # then drop any events left over from the previous test
+        self.status = "Stopped"
+        self.track_number = 0
+        while self.get_event(block=False) is not None:
+            pass
+        self._emit_properties_changed(
+            PlaybackStatus=dbus.String(self.status), Metadata=self.metadata()
+        )
+
+    def _emit_properties_changed(self, **changed):
+        self.service.PropertiesChanged(
+            MPRIS_PLAYER_INTERFACE,
+            dbus.Dictionary(changed, signature="sv"),
+            dbus.Array([], signature="s"),
+        )
+
+    def metadata(self):
+        return dbus.Dictionary(
+            {
+                "xesam:title": dbus.String(f"Test Track {self.track_number}"),
+                "xesam:artist": dbus.Array([dbus.String("Test Artist")], signature="s"),
+                "xesam:album": dbus.String("Test Album"),
+                "xesam:trackNumber": dbus.Int32(self.track_number),
+                "mpris:trackid": dbus.ObjectPath(
+                    f"/org/mpris/MediaPlayer2/Track/{self.track_number}"
+                ),
+            },
+            signature="sv",
+        )
+
+    def root_properties(self):
+        return dbus.Dictionary(
+            {
+                "Identity": dbus.String(self.PLAYER_NAME),
+                "CanQuit": dbus.Boolean(False),
+                "CanRaise": dbus.Boolean(False),
+                "HasTrackList": dbus.Boolean(False),
+            },
+            signature="sv",
+        )
+
+    def player_properties(self):
+        return dbus.Dictionary(
+            {
+                "PlaybackStatus": dbus.String(self.status),
+                "LoopStatus": dbus.String("None"),
+                "Shuffle": dbus.Boolean(False),
+                "Rate": dbus.Double(1.0),
+                "MinimumRate": dbus.Double(1.0),
+                "MaximumRate": dbus.Double(1.0),
+                "Volume": dbus.Double(1.0),
+                "Metadata": self.metadata(),
+                "CanGoNext": dbus.Boolean(True),
+                "CanGoPrevious": dbus.Boolean(True),
+                "CanPlay": dbus.Boolean(True),
+                "CanPause": dbus.Boolean(True),
+                "CanSeek": dbus.Boolean(True),
+                "CanControl": dbus.Boolean(True),
+            },
+            signature="sv",
+        )
+
+
+class MprisProxy(HostPlugin):
+    """
+    Host plugin running the mpris-proxy tool.
+
+    Args:
+        args: extra command line arguments, e.g. ("--export",) to also
+            export the remote players found by BlueZ as MPRIS players on
+            the session bus
+    """
+
+    name = "mpris_proxy"
+    depends = [Bluetoothd(), DbusSession()]
+
+    def __init__(self, args=()):
+        self.extra_args = list(args)
+
+    def presetup(self, config):
+        try:
+            self.exe = find_exe("tools", "mpris-proxy")
+        except FileNotFoundError as exc:
+            pytest.skip(reason=f"mpris-proxy: {exc!r}")
+
+    @mainloop_wrap
+    def setup(self, impl):
+        self.log = logging.getLogger(self.name)
+        self.log_stream = LogStream(self.name)
+
+        cmd = [self.exe, "--index", "0"] + self.extra_args
+        self.log.info("Start mpris-proxy: {}".format(quoted(cmd)))
+
+        self.job = subprocess.Popen(
+            cmd,
+            stdin=subprocess.DEVNULL,
+            stdout=self.log_stream.stream,
+            stderr=subprocess.STDOUT,
+        )
+
+    def teardown(self):
+        self.log.info("Stop mpris-proxy")
+        self.job.terminate()
+
+
+class MprisClient(HostPlugin):
+    """
+    Host plugin used by the test on the client host to discover and
+    control the player of a remote device through the MPRIS client API,
+    as exported to the session bus by `mpris-proxy --export`.
+    """
+
+    name = "mpris_client"
+    depends = [DbusSession()]
+
+    _missing_errors = (
+        "org.freedesktop.DBus.Error.UnknownProperty",
+        "org.freedesktop.DBus.Error.InvalidArgs",
+    )
+
+    @mainloop_wrap
+    def exported_player(self):
+        """Return the bus name of the single exported remote player, or None."""
+        bus = get_dbus(session=True)
+        obj = bus.get_object("org.freedesktop.DBus", "/org/freedesktop/DBus")
+        names = [
+            str(name)
+            for name in dbus.Interface(obj, "org.freedesktop.DBus").ListNames()
+            if str(name).startswith(MPRIS_BUS_PREFIX)
+        ]
+        assert len(names) <= 1, f"unexpected exported players: {names}"
+        return names[0] if names else None
+
+    def _get(self, name, interface, prop):
+        bus = get_dbus(session=True)
+        props = dbus.Interface(bus.get_object(name, MPRIS_PATH), PROPS_INTERFACE)
+        try:
+            return props.Get(interface, prop)
+        except dbus.exceptions.DBusException as exc:
+            if exc.get_dbus_name() in self._missing_errors:
+                return None
+            raise
+
+    @mainloop_wrap
+    def get_status(self, name):
+        value = self._get(name, MPRIS_PLAYER_INTERFACE, "PlaybackStatus")
+        return str(value) if value is not None else None
+
+    @mainloop_wrap
+    def get_metadata(self, name):
+        metadata = self._get(name, MPRIS_PLAYER_INTERFACE, "Metadata")
+        if metadata is None:
+            return {}
+        return {str(key): str(value) for key, value in metadata.items()}
+
+    @mainloop_wrap
+    def call_method(self, name, method):
+        """Call given org.mpris.MediaPlayer2.Player method synchronously."""
+        bus = get_dbus(session=True)
+        player = dbus.Interface(
+            bus.get_object(name, MPRIS_PATH), MPRIS_PLAYER_INTERFACE
+        )
+        getattr(player, method)()
+
+
+@mainloop_wrap
+def vm_set_trusted(bdaddr):
+    """Mark the device with the given address as trusted."""
+    path = "/org/bluez/hci0/dev_" + bdaddr.upper().replace(":", "_")
+    bus = get_dbus()
+    props = dbus.Interface(bus.get_object(BLUEZ_BUS_NAME, path), PROPS_INTERFACE)
+    props.Set(BLUEZ_DEVICE_INTERFACE, "Trusted", dbus.Boolean(True))
+
+
+@pytest.fixture
+def mpris_player(paired_hosts_bredr):
+    """
+    Two paired and trusted hosts, the client connected to the dummy MPRIS
+    player of the server, exported on the client session bus by
+    mpris-proxy --export.
+
+    The dummy player is reset also on failure, so the tests are
+    order-independent.
+
+    Yields (client, server, exported player bus name).
+    """
+    client, server = paired_hosts_bredr
+
+    client.call(vm_set_trusted, server.bdaddr)
+    server.call(vm_set_trusted, client.bdaddr)
+
+    client.agent.device_method(server.bdaddr, "ConnectProfile", AVRCP_CONTROLLER_UUID)
+    event = client.agent.expect(
+        (
+            "org.bluez.Device1.ConnectProfile:reply",
+            "org.bluez.Device1.ConnectProfile:error",
+        )
+    )
+    if event.kind.endswith(":error"):
+        # OK if the player is already connected (reused host setup)
+        assert "AlreadyConnected" in str(
+            event.error
+        ), f"ConnectProfile failed: {event.error}"
+
+    wait_until(lambda: client.mpris_client.exported_player())
+    player_name = client.mpris_client.exported_player()
+
+    wait_until(lambda: client.mpris_client.get_status(player_name) == "Stopped")
+
+    yield client, server, player_name
+
+    server.dummy_player.reset()
+    wait_until(lambda: client.mpris_client.get_status(player_name) == "Stopped")
+
+
+mpris_proxy_host_config = host_config(
+    [MprisProxy(args=("--export",)), MprisClient(), Agent()],
+    [MprisProxy(), DummyMprisPlayer(), Agent()],
+    reuse=True,
+)
+
+
+def control_player(client, server, player_name, method, update, *args):
+    """
+    Call an MPRIS Player method on the remote player exported on the
+    client session bus, wait for the dummy player to receive the matching
+    MPRIS call, and run update(*args) to update the dummy player state, as
+    a player application would react to the call.
+    """
+    client.mpris_client.call_method(player_name, method)
+    server.dummy_player.expect(f"{MPRIS_PLAYER_INTERFACE}.{method}")
+    update(*args)
+
+
+@mpris_proxy_host_config
+def test_mpris_proxy_playback_control(mpris_player):
+    client, server, player_name = mpris_player
+
+    for method, status in (
+        ("Play", "Playing"),
+        ("Pause", "Paused"),
+        ("Stop", "Stopped"),
+    ):
+        control_player(
+            client,
+            server,
+            player_name,
+            method,
+            server.dummy_player.set_status,
+            status,
+        )
+        wait_until(lambda: client.mpris_client.get_status(player_name) == status)
+
+
+@mpris_proxy_host_config
+def test_mpris_proxy_track_control(mpris_player):
+    client, server, player_name = mpris_player
+
+    track_number = server.dummy_player.get_track_number()
+
+    for delta, method in ((1, "Next"), (-1, "Previous")):
+        track_number += delta
+        control_player(
+            client,
+            server,
+            player_name,
+            method,
+            server.dummy_player.advance_track,
+            delta,
+        )
+        wait_until(
+            lambda: client.mpris_client.get_metadata(player_name).get("xesam:title")
+            == f"Test Track {track_number}"
+        )
+        metadata = client.mpris_client.get_metadata(player_name)
+        assert int(metadata["xesam:trackNumber"]) == track_number
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH BlueZ 2/3] test: functional: deduce pairing bearer in shared fixtures
  2026-09-15  0:07 [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests Pauli Virtanen
  2026-09-15  0:07 ` [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases Pauli Virtanen
@ 2026-09-15  0:07 ` Pauli Virtanen
  2026-09-15  0:07 ` [PATCH BlueZ 3/3] test: functional: run mpris-proxy tests over LE via MCP Pauli Virtanen
  2026-09-15 20:50 ` [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests patchwork-bot+bluetooth
  3 siblings, 0 replies; 6+ messages in thread
From: Pauli Virtanen @ 2026-09-15  0:07 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Pauli Virtanen

Make paired_hosts deduce the pairing bearer from the host
configuration instead of using BR/EDR only, with the LE pairing helper
pair_le in le_utils. No behavior change.

Assisted-by: opencode:qwen3.8-flash-next
---
 doc/functional-testing.rst          |  6 ++---
 test/functional/conftest.py         | 23 ++++++++++++++++---
 test/functional/le_utils.py         | 35 +++++++++++++++++++++++++++++
 test/functional/test_avrcp.py       |  4 ++--
 test/functional/test_mpris_proxy.py |  4 ++--
 test/functional/test_obex.py        |  8 +++----
 6 files changed, 66 insertions(+), 14 deletions(-)
 create mode 100644 test/functional/le_utils.py

diff --git a/doc/functional-testing.rst b/doc/functional-testing.rst
index 6fdc17b9a..ca7bfa672 100644
--- a/doc/functional-testing.rst
+++ b/doc/functional-testing.rst
@@ -83,9 +83,9 @@ test_agent_pair_bredr[accept]
 	   passkey.
 	4. ``org.bluez.Device1.Pair`` replies successfully.
 
-:Notes: This test is also used as the ``paired_hosts_bredr`` fixture
-	(see `test/functional/conftest.py`), which other tests reuse to
-	get two already paired hosts.
+:Notes: This test is also used by the ``paired_hosts`` fixture (see
+	`test/functional/conftest.py`), which other tests reuse to get two
+	already paired hosts.
 
 test_agent_pair_bredr[reject]
 -----------------------------
diff --git a/test/functional/conftest.py b/test/functional/conftest.py
index 6f50efe0e..ce6301319 100644
--- a/test/functional/conftest.py
+++ b/test/functional/conftest.py
@@ -5,6 +5,8 @@ import re
 from pathlib import Path
 import pytest
 
+from .le_utils import host_setup_is_le, pair_le
+
 
 def pytest_addoption(parser):
     parser.addoption(
@@ -66,11 +68,26 @@ def pytest_collection_modifyitems(session, config, items):
 
 
 @pytest.fixture
-def paired_hosts_bredr(hosts):
+def is_le(host_setup):
+    """True if the current host configuration runs bluetoothd in LE-only mode."""
+    return host_setup_is_le(host_setup)
+
+
+@pytest.fixture
+def paired_hosts(hosts, is_le):
+    """
+    Two hosts, paired over the bearer their host configuration uses.
+    """
     from .test_agent import test_agent_pair_bredr
 
-    if hosts[0].agent.has_device(hosts[1].bdaddr):
+    host0, host1 = hosts
+
+    if host0.agent.has_device(host1.bdaddr):
         return hosts
 
-    test_agent_pair_bredr(hosts, True)
+    if is_le:
+        pair_le(host0, host1)
+    else:
+        test_agent_pair_bredr([host0, host1], True)
+
     return hosts
diff --git a/test/functional/le_utils.py b/test/functional/le_utils.py
new file mode 100644
index 000000000..03bbbfb24
--- /dev/null
+++ b/test/functional/le_utils.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""Generic LE helpers for functional tests."""
+
+from pytest_bluezenv import (
+    Bluetoothd,
+    wait_until,
+)
+
+
+def host_setup_is_le(host_setup):
+    """True if the hosts run bluetoothd in LE-only mode."""
+    return any(
+        "ControllerMode = le" in (p.conf or "")
+        for plugins in host_setup["setup"]
+        for p in plugins
+        if isinstance(p, Bluetoothd)
+    )
+
+
+def pair_le(client, server):
+    """
+    Pair Just Works over LE: client initiates and server authorizes, so
+    only a single RequestAuthorization is expected.
+    """
+    server.agent.adapter_set("Pairable", True)
+
+    client.agent.adapter_method("StartDiscovery")
+    client.agent.expect("org.bluez.Adapter1.StartDiscovery:reply")
+    wait_until(client.agent.has_device, server.bdaddr)
+
+    client.agent.device_method(server.bdaddr, "Pair")
+    server.agent.expect("org.bluez.Agent1.RequestAuthorization")
+    server.agent.reply()
+    client.agent.expect("org.bluez.Device1.Pair:reply")
diff --git a/test/functional/test_avrcp.py b/test/functional/test_avrcp.py
index 3b04c9615..f6ede96cb 100644
--- a/test/functional/test_avrcp.py
+++ b/test/functional/test_avrcp.py
@@ -43,8 +43,8 @@ class CheckAvrcpCrash_GHSA_m2vx_pw5f_rc8v(HostPlugin):
         Agent(),
     ],
 )
-def test_avrcp_GHSA_m2vx_pw5f_rc8v(paired_hosts_bredr):
-    client, server = paired_hosts_bredr
+def test_avrcp_GHSA_m2vx_pw5f_rc8v(paired_hosts):
+    client, server = paired_hosts
 
     client.agent.device_method(
         server.bdaddr, "ConnectProfile", "0000110c-0000-1000-8000-00805f9b34fb"
diff --git a/test/functional/test_mpris_proxy.py b/test/functional/test_mpris_proxy.py
index 54b888c79..15e775621 100644
--- a/test/functional/test_mpris_proxy.py
+++ b/test/functional/test_mpris_proxy.py
@@ -325,7 +325,7 @@ def vm_set_trusted(bdaddr):
 
 
 @pytest.fixture
-def mpris_player(paired_hosts_bredr):
+def mpris_player(paired_hosts):
     """
     Two paired and trusted hosts, the client connected to the dummy MPRIS
     player of the server, exported on the client session bus by
@@ -336,7 +336,7 @@ def mpris_player(paired_hosts_bredr):
 
     Yields (client, server, exported player bus name).
     """
-    client, server = paired_hosts_bredr
+    client, server = paired_hosts
 
     client.call(vm_set_trusted, server.bdaddr)
     server.call(vm_set_trusted, client.bdaddr)
diff --git a/test/functional/test_obex.py b/test/functional/test_obex.py
index 79a217eae..2153510c3 100644
--- a/test/functional/test_obex.py
+++ b/test/functional/test_obex.py
@@ -194,11 +194,11 @@ obex_host_config = host_config(
 
 
 @pytest.fixture
-def obex_hosts(paired_hosts_bredr):
-    host0, host1 = paired_hosts_bredr
+def obex_hosts(paired_hosts):
+    host0, host1 = paired_hosts
 
     if hasattr(host0, "session"):
-        return paired_hosts_bredr
+        return paired_hosts
 
     host0.obex.connect(host1.bdaddr)
 
@@ -208,7 +208,7 @@ def obex_hosts(paired_hosts_bredr):
 
     host0.obex.expect("org.bluez.obex.Client1.CreateSession:reply")
 
-    yield paired_hosts_bredr
+    yield paired_hosts
 
     host1.obex_agent.cleanup()
 
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* [PATCH BlueZ 3/3] test: functional: run mpris-proxy tests over LE via MCP
  2026-09-15  0:07 [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests Pauli Virtanen
  2026-09-15  0:07 ` [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases Pauli Virtanen
  2026-09-15  0:07 ` [PATCH BlueZ 2/3] test: functional: deduce pairing bearer in shared fixtures Pauli Virtanen
@ 2026-09-15  0:07 ` Pauli Virtanen
  2026-09-15 20:50 ` [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests patchwork-bot+bluetooth
  3 siblings, 0 replies; 6+ messages in thread
From: Pauli Virtanen @ 2026-09-15  0:07 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Pauli Virtanen

Also run the mpris-proxy tests over LE, where the player is exposed via
MCS instead of AVRCP.

Add an LE advertisement helper for connecting the service.

test/functional/test_mpris_proxy.py::test_mpris_proxy_playback_control[le-vm2]
test/functional/test_mpris_proxy.py::test_mpris_proxy_track_control[le-vm2]

Assisted-by: opencode:qwen3.8-flash-next
---
 doc/functional-mpris-proxy.rst      |  91 +++++++++++-----
 test/functional/le_utils.py         | 162 +++++++++++++++++++++++++++-
 test/functional/test_mpris_proxy.py |  93 +++++++++++-----
 3 files changed, 290 insertions(+), 56 deletions(-)

diff --git a/doc/functional-mpris-proxy.rst b/doc/functional-mpris-proxy.rst
index a6936275b..17d2cc3d9 100644
--- a/doc/functional-mpris-proxy.rst
+++ b/doc/functional-mpris-proxy.rst
@@ -16,17 +16,20 @@ exports a remote ``org.bluez.MediaPlayer1`` object to the session bus
 as an MPRIS player.
 
 The upper tester drives two VM hosts, host0 as the client and host1 as
-the server, connected over BR/EDR, where the bridge is an AVRCP Target
-and Controller. The server presents a dummy MPRIS player. The client
-runs a second `mpris-proxy` with ``--export``, which exports that
-remote player on the client session bus, and the upper tester controls
-it there as an ordinary MPRIS client.
+the server. The server presents a dummy MPRIS player. The client runs
+a second `mpris-proxy` with ``--export``, which exports that remote
+player on the client session bus, and the upper tester controls it
+there as an ordinary MPRIS client. Each case runs once over BR/EDR,
+where the bridge is an AVRCP Target and Controller, and once over LE,
+where it is a Generic Media Control Service (GMCS) server and client.
 
 SETUP
 =====
 
 .. code-block::
 
+	BR/EDR:
+
 	+--------------------------------+            +--------------------------------+
 	| host0 / client                 |   BR/EDR   | host1 / server                 |
 	| bluetoothd                     |   AVCTP    | bluetoothd                     |
@@ -36,6 +39,18 @@ SETUP
 	|  <name> (session bus)          |            |  blueztest (session bus)       |
 	+--------------------------------+            +--------------------------------+
 
+	LE:
+
+	+--------------------------------+            +--------------------------------+
+	| host0 / client                 |     LE     | host1 / server                 |
+	| bluetoothd                     |    GMCS    | bluetoothd                     |
+	| mpris-proxy --export           |   (MCP)    | mpris-proxy                    |
+	| MPRIS client (upper tester)    |  ------->  | dummy MPRIS player             |
+	| org.mpris.MediaPlayer2.        |            | LE advertisement (GMCS)        |
+	|  <name> (session bus)          |            | org.mpris.MediaPlayer2.        |
+	|                                |            |  blueztest (session bus)       |
+	+--------------------------------+            +--------------------------------+
+
 The server host owns the session bus name
 ``org.mpris.MediaPlayer2.blueztest`` and exports
 ``/org/mpris/MediaPlayer2`` with the ``org.mpris.MediaPlayer2`` and
@@ -47,33 +62,45 @@ tester tells it to, and then emits
 
 The `mpris-proxy` on the server registers that player with its
 **bluetoothd** through ``org.bluez.Media1.RegisterPlayer``, so the
-server host is the AVRCP Target. The `mpris-proxy --export` on the
-client exports the ``org.bluez.MediaPlayer1`` object its bluetoothd
-creates for the remote player as ``org.mpris.MediaPlayer2.<name>``,
-where ``<name>`` is derived from the remote device alias, at
-``/org/mpris/MediaPlayer2`` on the client session bus. Both proxies use
-adapter index 0.
+server host is the AVRCP Target over BR/EDR and the GMCS server over
+LE. The `mpris-proxy --export` on the client exports the
+``org.bluez.MediaPlayer1`` object its bluetoothd creates for the
+remote player as ``org.mpris.MediaPlayer2.<name>``, where ``<name>``
+is derived from the remote device alias, at
+``/org/mpris/MediaPlayer2`` on the client session bus. Both proxies
+use adapter index 0.
+
+In the LE configuration both hosts run bluetoothd with
+``ControllerMode = le`` and ``Experimental = true``, and both hosts
+register an ``org.bluez.Agent1`` with ``NoInputNoOutput`` capability.
+The server also registers a connectable ``org.bluez.LEAdvertisement1``
+advertising GMCS (``00001849-0000-1000-8000-00805f9b34fb``) through
+``org.bluez.LEAdvertisingManager1.RegisterAdvertisement``, to connect
+the service.
 
 Before each case the upper tester does the following, and checks each
 step:
 
-	1. Pair the hosts over BR/EDR, unless they are already paired, as
-	   the host setup is reused between cases: the client discovers
-	   the server and calls ``org.bluez.Device1.Pair``, and both
-	   agents confirm the passkey.
+	1. Pair the hosts over the bearer in use, unless they are already
+	   paired, as the host setup is reused between cases. Over BR/EDR
+	   the client discovers the server and calls ``Device1.Pair``, and
+	   both agents confirm the passkey. Over LE pairing is Just
+	   Works: the server agent authorizes it.
 	2. On both hosts, set ``Trusted`` to true with
 	   ``org.freedesktop.DBus.Properties.Set`` on the
 	   ``org.bluez.Device1`` object of the peer host.
-	3. Connect on the client with ``org.bluez.Device1.ConnectProfile``
-	   and the AVRCP Controller UUID
-	   (``0000110c-0000-1000-8000-00805f9b34fb``). The call must
-	   reply, or fail with ``AlreadyConnected`` since a previous case
-	   may still be connected.
+	3. Connect on the client: ``org.bluez.Device1.ConnectProfile``
+	   with the AVRCP Controller UUID
+	   (``0000110c-0000-1000-8000-00805f9b34fb``) over BR/EDR, or
+	   ``org.bluez.Device1.Connect`` over LE. The call must reply, or
+	   fail with ``AlreadyConnected`` since a previous case may still
+	   be connected.
 	4. Wait until the client session bus has exactly one name with the
 	   prefix ``org.mpris.MediaPlayer2.``, listed with
 	   ``org.freedesktop.DBus.ListNames``. That is the exported player.
 	5. Wait until its ``PlaybackStatus``, read with
-	   ``org.freedesktop.DBus.Properties.Get``, is ``Stopped``.
+	   ``org.freedesktop.DBus.Properties.Get``, is the idle status:
+	   ``Stopped`` over BR/EDR, ``Paused`` over LE.
 
 The same connection and exported player are used by both cases.
 
@@ -84,9 +111,9 @@ After each case, also on failure, the upper tester resets the dummy
 player on the server to stopped and track 0, which emits
 ``PropertiesChanged`` for ``PlaybackStatus`` and ``Metadata``, and
 drops the method calls left over from the previous case. It then waits
-until the exported player on the client reads ``Stopped`` again. The
-hosts, both bluetoothd instances, both proxies and the connection are
-left running.
+until the exported player on the client again reads the idle status.
+The hosts, both bluetoothd instances, both proxies and the connection
+are left running.
 
 TEST CASES
 ==========
@@ -114,7 +141,12 @@ test_mpris_proxy_playback_control
 	2. The dummy player on the server receives ``Play``, ``Pause`` and
 	   ``Stop``.
 	3. The exported player on the client reports ``Playing``,
-	   ``Paused`` and ``Stopped``.
+	   ``Paused`` and ``Stopped`` over BR/EDR. Over LE, ``Stop`` is
+	   reported as ``Paused``.
+
+:Notes: GMCS has no stopped state, so a stopped player is published by
+	the server in the paused state, and reads as ``Paused`` on the
+	client.
 
 test_mpris_proxy_track_control
 ------------------------------
@@ -138,8 +170,11 @@ test_mpris_proxy_track_control
 	1. The dummy player on the server receives ``Next`` and
 	   ``Previous``.
 	2. The metadata of the new track reaches the exported player on
-	   the client: both its ``xesam:title`` and its
-	   ``xesam:trackNumber`` match the new track.
+	   the client: the title matches over both bearers, and
+	   ``xesam:trackNumber`` matches over BR/EDR.
 
 :Notes: The dummy player changes its state only once the upper tester
-	has observed the corresponding call.
+	has observed the corresponding call. Over LE bluetoothd knows
+	only the GMCS object name of the remote player, so the client
+	sees the title but no track number, which is why the track number
+	is checked over BR/EDR only.
diff --git a/test/functional/le_utils.py b/test/functional/le_utils.py
index 03bbbfb24..066644d6f 100644
--- a/test/functional/le_utils.py
+++ b/test/functional/le_utils.py
@@ -1,12 +1,172 @@
 # -*- coding: utf-8; mode: python; eval: (blacken-mode); -*-
 # SPDX-License-Identifier: GPL-2.0-or-later
-"""Generic LE helpers for functional tests."""
+"""
+Generic LE helpers for functional tests.
+
+LeAdvertiser registers a connectable org.bluez.LEAdvertisement1: in
+ControllerMode = le bluetoothd does not advertise on its own, so a host has
+to advertise itself to be discovered and connected over LE.
+"""
+
+import logging
+
+import dbus
+import dbus.exceptions
+import dbus.service
 
 from pytest_bluezenv import (
     Bluetoothd,
+    HostPlugin,
+    get_dbus,
+    mainloop_assert,
+    mainloop_wrap,
     wait_until,
 )
 
+BLUEZ_BUS_NAME = "org.bluez"
+PROPS_INTERFACE = "org.freedesktop.DBus.Properties"
+LE_ADV_INTERFACE = "org.bluez.LEAdvertisement1"
+LE_ADV_MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1"
+
+
+class LeAdvertisement(dbus.service.Object):
+    """
+    Minimal generic org.bluez.LEAdvertisement1 implementation exporting the
+    given service UUIDs over the given advertisement type.
+    """
+
+    class UnknownProperty(dbus.exceptions.DBusException):
+        _dbus_error_name = "org.freedesktop.DBus.Error.UnknownProperty"
+
+    @mainloop_assert
+    def __init__(
+        self,
+        bus,
+        path,
+        adv_type="peripheral",
+        service_uuids=(),
+        discoverable=True,
+    ):
+        self.adv_path = path
+        self._props = dbus.Dictionary(
+            {
+                "Type": dbus.String(adv_type),
+                "ServiceUUIDs": dbus.Array(
+                    [dbus.String(uuid) for uuid in service_uuids], signature="s"
+                ),
+            },
+            signature="sv",
+        )
+        if discoverable:
+            self._props["Discoverable"] = dbus.Boolean(True)
+        super().__init__(bus, path)
+
+    @dbus.service.method(PROPS_INTERFACE, in_signature="s", out_signature="a{sv}")
+    def GetAll(self, interface):
+        if interface == LE_ADV_INTERFACE:
+            return self._props
+        return dbus.Dictionary({}, signature="sv")
+
+    @dbus.service.method(PROPS_INTERFACE, in_signature="ss", out_signature="v")
+    def Get(self, interface, prop):
+        props = self.GetAll(interface)
+        if prop not in props:
+            raise self.UnknownProperty(f"No such property {prop} on {interface}")
+        return props[prop]
+
+    @dbus.service.signal(PROPS_INTERFACE, signature="sa{sv}as")
+    def PropertiesChanged(self, interface, changed, invalidated):
+        pass
+
+    @dbus.service.method(LE_ADV_INTERFACE, in_signature="", out_signature="")
+    def Release(self):
+        pass
+
+
+class LeAdvertiser(HostPlugin):
+    """
+    Host plugin registering a connectable LE advertisement, so that a remote
+    host can discover and connect to it over LE. The advertised service
+    UUIDs are given as the service_uuids argument.
+    """
+
+    name = "le_advertiser"
+    depends = [Bluetoothd()]
+
+    PATH_BASE = "/org/bluez/test/advertisement"
+
+    def __init__(
+        self,
+        service_uuids=(),
+        adv_type="peripheral",
+        index=0,
+        adapter_path="/org/bluez/hci0",
+        discoverable=True,
+    ):
+        self.service_uuids = tuple(service_uuids)
+        self.adv_type = adv_type
+        self.index = index
+        self.adapter_path = adapter_path
+        self.discoverable = discoverable
+
+    def setup(self, impl):
+        # While registering, bluetoothd reads the advertisement properties
+        # back from us, so wait for the reply outside the mainloop thread to
+        # let the mainloop serve it
+        self.log = logging.getLogger(self.name)
+
+        self.registered = False
+        self.register_error = None
+
+        self._register()
+
+        wait_until(lambda: self.registered)
+        if self.register_error is not None:
+            raise self.register_error
+
+    @mainloop_wrap
+    def _register(self):
+        self.bus = get_dbus(private=True)
+        self.adv = LeAdvertisement(
+            self.bus,
+            f"{self.PATH_BASE}{self.index}",
+            self.adv_type,
+            self.service_uuids,
+            self.discoverable,
+        )
+        self.manager = dbus.Interface(
+            self.bus.get_object(BLUEZ_BUS_NAME, self.adapter_path),
+            LE_ADV_MANAGER_INTERFACE,
+        )
+
+        self.log.info(f"Register LE advertisement {self.adv.adv_path}")
+
+        self.manager.RegisterAdvertisement(
+            self.adv.adv_path,
+            dbus.Dictionary({}, signature="sv"),
+            reply_handler=self._registered,
+            error_handler=self._register_error,
+        )
+
+    def _registered(self):
+        self.log.info(f"LE advertisement {self.adv.adv_path} registered")
+        self.registered = True
+
+    def _register_error(self, err):
+        self.log.error(f"LE advertisement {self.adv.adv_path} failed: {err}")
+        self.register_error = err
+        self.registered = True
+
+    @mainloop_wrap
+    def teardown(self):
+        self.log.info(f"Unregister LE advertisement {self.adv.adv_path}")
+        self.manager.UnregisterAdvertisement(
+            self.adv.adv_path,
+            reply_handler=lambda: None,
+            error_handler=lambda err: self.log.debug(f"Unregister: {err}"),
+        )
+        self.adv.remove_from_connection()
+
 
 def host_setup_is_le(host_setup):
     """True if the hosts run bluetoothd in LE-only mode."""
diff --git a/test/functional/test_mpris_proxy.py b/test/functional/test_mpris_proxy.py
index 15e775621..06bcae748 100644
--- a/test/functional/test_mpris_proxy.py
+++ b/test/functional/test_mpris_proxy.py
@@ -22,13 +22,15 @@ from pytest_bluezenv import (
     dbus_service_event_method,
     find_exe,
     get_dbus,
-    host_config,
     mainloop_assert,
     mainloop_wrap,
+    parametrized_host_config,
     quoted,
     wait_until,
 )
 
+from .le_utils import LeAdvertiser
+
 BLUEZ_BUS_NAME = "org.bluez"
 
 MPRIS_BUS_NAME = "org.mpris.MediaPlayer2.blueztest"
@@ -43,6 +45,10 @@ PROPS_INTERFACE = "org.freedesktop.DBus.Properties"
 
 AVRCP_CONTROLLER_UUID = "0000110c-0000-1000-8000-00805f9b34fb"
 
+GMCS_UUID = "00001849-0000-1000-8000-00805f9b34fb"
+
+LE_BLUETOOTHD_CONF = "[General]\nControllerMode = le\nExperimental = true\n"
+
 
 def mpris_player_method(*a, **kw):
     return dbus_service_event_method(MPRIS_PLAYER_INTERFACE, *a, **kw)
@@ -257,9 +263,9 @@ class MprisProxy(HostPlugin):
 
 class MprisClient(HostPlugin):
     """
-    Host plugin used by the test on the client host to discover and
-    control the player of a remote device through the MPRIS client API,
-    as exported to the session bus by `mpris-proxy --export`.
+    Host plugin used by the test on the controlling host to discover and
+    control the remote player through the MPRIS client API, as exported
+    to the session bus by `mpris-proxy --export`.
     """
 
     name = "mpris_client"
@@ -324,8 +330,13 @@ def vm_set_trusted(bdaddr):
     props.Set(BLUEZ_DEVICE_INTERFACE, "Trusted", dbus.Boolean(True))
 
 
+def idle_status(le):
+    """Playback status a stopped player reads as, MCP has no stopped state."""
+    return "Paused" if le else "Stopped"
+
+
 @pytest.fixture
-def mpris_player(paired_hosts):
+def mpris_player(paired_hosts, is_le):
     """
     Two paired and trusted hosts, the client connected to the dummy MPRIS
     player of the server, exported on the client session bus by
@@ -341,33 +352,59 @@ def mpris_player(paired_hosts):
     client.call(vm_set_trusted, server.bdaddr)
     server.call(vm_set_trusted, client.bdaddr)
 
-    client.agent.device_method(server.bdaddr, "ConnectProfile", AVRCP_CONTROLLER_UUID)
+    if is_le:
+        method, args = "Connect", ()
+    else:
+        method, args = "ConnectProfile", (AVRCP_CONTROLLER_UUID,)
+
+    client.agent.device_method(server.bdaddr, method, *args)
     event = client.agent.expect(
-        (
-            "org.bluez.Device1.ConnectProfile:reply",
-            "org.bluez.Device1.ConnectProfile:error",
-        )
+        (f"org.bluez.Device1.{method}:reply", f"org.bluez.Device1.{method}:error")
     )
     if event.kind.endswith(":error"):
         # OK if the player is already connected (reused host setup)
-        assert "AlreadyConnected" in str(
-            event.error
-        ), f"ConnectProfile failed: {event.error}"
+        assert "AlreadyConnected" in str(event.error), f"{method} failed: {event.error}"
 
     wait_until(lambda: client.mpris_client.exported_player())
     player_name = client.mpris_client.exported_player()
 
-    wait_until(lambda: client.mpris_client.get_status(player_name) == "Stopped")
+    wait_until(
+        lambda: client.mpris_client.get_status(player_name) == idle_status(is_le)
+    )
 
     yield client, server, player_name
 
     server.dummy_player.reset()
-    wait_until(lambda: client.mpris_client.get_status(player_name) == "Stopped")
+    wait_until(
+        lambda: client.mpris_client.get_status(player_name) == idle_status(is_le)
+    )
 
 
-mpris_proxy_host_config = host_config(
-    [MprisProxy(args=("--export",)), MprisClient(), Agent()],
-    [MprisProxy(), DummyMprisPlayer(), Agent()],
+mpris_proxy_host_config = parametrized_host_config(
+    [
+        # BR/EDR
+        (
+            [MprisProxy(args=("--export",)), MprisClient(), Agent()],
+            [MprisProxy(), DummyMprisPlayer(), Agent()],
+        ),
+        # LE
+        (
+            [
+                Bluetoothd(conf=LE_BLUETOOTHD_CONF),
+                MprisProxy(args=("--export",)),
+                MprisClient(),
+                Agent(capability="NoInputNoOutput"),
+            ],
+            [
+                Bluetoothd(conf=LE_BLUETOOTHD_CONF),
+                MprisProxy(),
+                DummyMprisPlayer(),
+                LeAdvertiser(service_uuids=[GMCS_UUID]),
+                Agent(capability="NoInputNoOutput"),
+            ],
+        ),
+    ],
+    ids=["bredr", "le"],
     reuse=True,
 )
 
@@ -385,13 +422,13 @@ def control_player(client, server, player_name, method, update, *args):
 
 
 @mpris_proxy_host_config
-def test_mpris_proxy_playback_control(mpris_player):
+def test_mpris_proxy_playback_control(mpris_player, is_le):
     client, server, player_name = mpris_player
 
-    for method, status in (
-        ("Play", "Playing"),
-        ("Pause", "Paused"),
-        ("Stop", "Stopped"),
+    for method, status, expected in (
+        ("Play", "Playing", "Playing"),
+        ("Pause", "Paused", "Paused"),
+        ("Stop", "Stopped", idle_status(is_le)),
     ):
         control_player(
             client,
@@ -401,11 +438,11 @@ def test_mpris_proxy_playback_control(mpris_player):
             server.dummy_player.set_status,
             status,
         )
-        wait_until(lambda: client.mpris_client.get_status(player_name) == status)
+        wait_until(lambda: client.mpris_client.get_status(player_name) == expected)
 
 
 @mpris_proxy_host_config
-def test_mpris_proxy_track_control(mpris_player):
+def test_mpris_proxy_track_control(mpris_player, is_le):
     client, server, player_name = mpris_player
 
     track_number = server.dummy_player.get_track_number()
@@ -424,5 +461,7 @@ def test_mpris_proxy_track_control(mpris_player):
             lambda: client.mpris_client.get_metadata(player_name).get("xesam:title")
             == f"Test Track {track_number}"
         )
-        metadata = client.mpris_client.get_metadata(player_name)
-        assert int(metadata["xesam:trackNumber"]) == track_number
+        if not is_le:
+            # Over LE, bluetoothd knows only the GMCS object name, no track number
+            metadata = client.mpris_client.get_metadata(player_name)
+            assert int(metadata["xesam:trackNumber"]) == track_number
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 6+ messages in thread

* RE: test: functional: add mpris-proxy end-to-end tests
  2026-09-15  0:07 ` [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases Pauli Virtanen
@ 2026-09-15  1:55   ` bluez.test.bot
  0 siblings, 0 replies; 6+ messages in thread
From: bluez.test.bot @ 2026-09-15  1:55 UTC (permalink / raw)
  To: linux-bluetooth, pav

[-- Attachment #1: Type: text/plain, Size: 5141 bytes --]

This is automated email and please do not reply to this email!

Dear submitter,

Thank you for submitting the patches to the linux bluetooth mailing list.
This is a CI test results with your patch series:
PW Link:https://patchwork.kernel.org/series/1164934/

---Test result---

Test Summary:
CheckPatch                    PASS      1.42 seconds
GitLint                       PASS      0.98 seconds
BuildEll                      PASS      19.69 seconds
BluezMake                     PASS      377.40 seconds
MakeCheck                     PASS      14.22 seconds
MakeDistcheck                 PASS      141.53 seconds
CheckValgrind                 PASS      236.33 seconds
CheckSmatch                   PASS      314.14 seconds
bluezmakeextell               PASS      97.81 seconds
TestFunctional                FAIL      1081.13 seconds
IncrementalBuild              PASS      386.98 seconds
ScanBuild                     PASS      1170.83 seconds

Details
##############################
Test: TestFunctional - FAIL
Desc: Run test-functional
Output:
FAIL functional.test_kernel_testers::test_kernel_selftest[hosts11-vm1-noc]: failed on setup with "ConnectionResetError: [Errno 104] Connection reset by peer"
FAIL functional.test_adv_monitor::test_adv_monitor_GHSA_hhgc_hfgf_8m4x[hosts1-vm1]: failed on setup with "ConnectionResetError: [Errno 104] Connection reset by peer"
FAIL functional.test_btmgmt::test_btmgmt_info[hosts10-vm1]: failed on setup with "pytest_bluezenv.rpc.RemoteTimeoutError: Failed to establish connection"
FAIL functional.test_bluetoothctl::test_bluetoothctl_show[hosts7-vm1]: failed on setup with "ConnectionResetError: [Errno 104] Connection reset by peer"
FAIL functional.test_bluetoothctl::test_bluetoothctl_list[hosts7-vm1]: failed on setup with "pytest_bluezenv.rpc.RemoteTimeoutError: Failed to establish connection"
FAIL functional.test_bluetoothctl::test_bluetoothctl_script_show[hosts7-vm1]: failed on setup with "ConnectionResetError: [Errno 104] Connection reset by peer"
FAIL functional.test_bluetoothctl::test_bluetoothctl_script_list[hosts7-vm1]: failed on setup with "pytest_bluezenv.rpc.RemoteTimeoutError: Failed to establish connection"
FAIL functional.test_a2dp::test_a2dp_transport_created[hosts0-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_a2dp::test_a2dp_transport_acquire[hosts0-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_mpris_proxy::test_mpris_proxy_playback_control[bredr-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_mpris_proxy::test_mpris_proxy_track_control[bredr-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_obex::test_obex_ftp_list[hosts14-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_obex::test_obex_ftp_get[hosts14-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_obex::test_obexctl_list[hosts14-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_agent::test_agent_pair_bredr[accept-hosts2-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_agent::test_agent_pair_bredr[reject-hosts2-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_avrcp::test_avrcp_GHSA_m2vx_pw5f_rc8v[hosts3-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_unicast_transport_created[hosts4-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_unicast_transport_acquire[hosts4-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_broadcast_transport_created[lc3-hosts5-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_broadcast_transport_created[pbp-hosts5-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_broadcast_transport_acquire[lc3-hosts5-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bap_broadcast_transport_acquire[pbp-hosts5-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bap::test_bass_past_transport_acquire[hosts6-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bluetoothctl::test_bluetoothctl_pair_bredr[hosts8-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_bluetoothctl::test_bluetoothctl_pair_le[hosts9-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_mpris_proxy::test_mpris_proxy_playback_control[le-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"
FAIL functional.test_mpris_proxy::test_mpris_proxy_track_control[le-vm2]: failed on setup with "RuntimeError: Process exited unexpectedly"


https://github.com/bluez/bluez/pull/2529

---
Regards,
Linux Bluetooth


^ permalink raw reply	[flat|nested] 6+ messages in thread

* Re: [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests
  2026-09-15  0:07 [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests Pauli Virtanen
                   ` (2 preceding siblings ...)
  2026-09-15  0:07 ` [PATCH BlueZ 3/3] test: functional: run mpris-proxy tests over LE via MCP Pauli Virtanen
@ 2026-09-15 20:50 ` patchwork-bot+bluetooth
  3 siblings, 0 replies; 6+ messages in thread
From: patchwork-bot+bluetooth @ 2026-09-15 20:50 UTC (permalink / raw)
  To: Pauli Virtanen; +Cc: linux-bluetooth

Hello:

This series was applied to bluetooth/bluez.git (master)
by Luiz Augusto von Dentz <luiz.von.dentz@intel.com>:

On Tue, 15 Sep 2026 03:07:17 +0300 you wrote:
> Add tests for:
> 
> MPRIS -> local player -> AVRCP / MCP -> remote player -> MPRIS
> 
> Connection and basic operations are tested on smoketest level.
> 
> The code is LLM-assisted, with manual design and details input. I
> reviewed and tested the result.
> 
> [...]

Here is the summary with links:
  - [BlueZ,1/3] test: functional: add mpris-proxy BR/EDR test cases
    https://git.kernel.org/pub/scm/bluetooth/bluez.git/?id=3e7fb4ac2e28
  - [BlueZ,2/3] test: functional: deduce pairing bearer in shared fixtures
    https://git.kernel.org/pub/scm/bluetooth/bluez.git/?id=acab65d06795
  - [BlueZ,3/3] test: functional: run mpris-proxy tests over LE via MCP
    https://git.kernel.org/pub/scm/bluetooth/bluez.git/?id=e2ec6827f7be

You are awesome, thank you!
-- 
Deet-doot-dot, I am a bot.
https://korg.docs.kernel.org/patchwork/pwbot.html



^ permalink raw reply	[flat|nested] 6+ messages in thread

end of thread, other threads:[~2026-09-15 20:51 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-15  0:07 [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests Pauli Virtanen
2026-09-15  0:07 ` [PATCH BlueZ 1/3] test: functional: add mpris-proxy BR/EDR test cases Pauli Virtanen
2026-09-15  1:55   ` test: functional: add mpris-proxy end-to-end tests bluez.test.bot
2026-09-15  0:07 ` [PATCH BlueZ 2/3] test: functional: deduce pairing bearer in shared fixtures Pauli Virtanen
2026-09-15  0:07 ` [PATCH BlueZ 3/3] test: functional: run mpris-proxy tests over LE via MCP Pauli Virtanen
2026-09-15 20:50 ` [PATCH BlueZ 0/3] test: functional: add mpris-proxy end-to-end tests patchwork-bot+bluetooth

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.