From 5de55f217f8efd89ab800b0bd49264148bc6fc54 Mon Sep 17 00:00:00 2001
From: Apertis CI <devel@lists.apertis.org>
Date: Tue, 18 Jan 2022 07:50:54 +0000
Subject: [PATCH] Import Upstream version 0.4.7

---
 .gitlab-ci.yml                                |  15 +-
 NEWS.rst                                      |  73 ++-
 docs/rst/configuration.rst                    |  34 ++
 docs/rst/configuration/access.rst             |  63 +++
 docs/rst/configuration/alsa.rst               | 456 ++++++++++++++++++
 docs/rst/configuration/bluetooth.rst          | 160 ++++++
 docs/rst/configuration/config_lua.rst         | 170 +++++++
 docs/rst/configuration/locations.rst          |  94 ++++
 docs/rst/configuration/main.rst               | 126 +++++
 docs/rst/configuration/meson.build            |  11 +
 docs/rst/configuration/multi_instance.rst     |  45 ++
 docs/rst/configuration/policy.rst             | 116 +++++
 docs/rst/daemon-configuration.rst             | 440 -----------------
 docs/rst/daemon-logging.rst                   |  11 +-
 docs/rst/index.rst                            |   2 +-
 docs/rst/installing-wireplumber.rst           |   6 +-
 docs/rst/lua_api/lua_introduction.rst         |  10 +-
 docs/rst/meson.build                          |   3 +-
 docs/rst/running-wireplumber-daemon.rst       | 122 +++--
 lib/wp/core.c                                 |  51 +-
 lib/wp/core.h                                 |   4 +
 lib/wp/device.c                               |   3 +-
 lib/wp/endpoint.c                             |   1 -
 lib/wp/global-proxy.c                         |  12 +-
 lib/wp/iterator.c                             |   5 +-
 lib/wp/log.c                                  |  20 +-
 lib/wp/metadata.c                             |   4 +-
 lib/wp/node.c                                 |   1 -
 lib/wp/object-manager.c                       |  14 +-
 lib/wp/object.c                               |  41 +-
 lib/wp/object.h                               |   3 +
 lib/wp/private/pipewire-object-mixin.c        |  33 +-
 lib/wp/private/pipewire-object-mixin.h        |   6 +-
 lib/wp/proxy.c                                |  67 +--
 lib/wp/proxy.h                                |   3 -
 lib/wp/spa-pod.c                              |   8 +
 lib/wp/transition.c                           |   2 +-
 lib/wp/wp.c                                   |   3 +
 meson.build                                   |  17 +-
 modules/module-default-nodes-api.c            |  37 +-
 modules/module-default-nodes.c                | 450 +++++++++++++----
 modules/module-default-nodes/common.h         |  14 +-
 modules/module-default-profile.c              |  87 ++--
 modules/module-device-activation.c            | 293 +++++++++--
 modules/module-lua-scripting.c                |   1 +
 modules/module-mixer-api.c                    |   5 +-
 modules/module-reserve-device/plugin.c        |   2 +-
 modules/module-route-settings-api.c           |   5 +-
 modules/module-si-audio-adapter.c             |  51 +-
 modules/module-si-node.c                      |  11 +
 modules/module-si-standard-link.c             | 106 +++-
 src/config/bluetooth.conf                     |  22 +
 src/config/main.lua.d/40-device-defaults.lua  |   3 +
 src/config/main.lua.d/50-alsa-config.lua      |  15 +
 .../main.lua.d/50-default-access-config.lua   |   8 +
 .../policy.lua.d/50-endpoints-config.lua      |  84 +++-
 src/config/wireplumber.conf                   |  22 +
 src/main.c                                    |   6 +-
 src/scripts/access/access-default.lua         |   2 +-
 src/scripts/create-item.lua                   |  14 +-
 src/scripts/default-routes.lua                |  46 +-
 src/scripts/monitors/alsa-midi.lua            |   2 +-
 src/scripts/monitors/alsa.lua                 |  26 +-
 src/scripts/monitors/bluez.lua                |   9 +-
 src/scripts/monitors/libcamera.lua            |  12 +-
 src/scripts/monitors/v4l2.lua                 |  10 +-
 src/scripts/policy-endpoint-client-links.lua  |   2 +-
 src/scripts/policy-endpoint-client.lua        |  56 ++-
 src/scripts/policy-endpoint-device.lua        |  52 +-
 src/scripts/policy-node.lua                   | 243 ++++++++--
 src/scripts/suspend-node.lua                  |   6 +-
 71 files changed, 2946 insertions(+), 1011 deletions(-)
 create mode 100644 docs/rst/configuration.rst
 create mode 100644 docs/rst/configuration/access.rst
 create mode 100644 docs/rst/configuration/alsa.rst
 create mode 100644 docs/rst/configuration/bluetooth.rst
 create mode 100644 docs/rst/configuration/config_lua.rst
 create mode 100644 docs/rst/configuration/locations.rst
 create mode 100644 docs/rst/configuration/main.rst
 create mode 100644 docs/rst/configuration/meson.build
 create mode 100644 docs/rst/configuration/multi_instance.rst
 create mode 100644 docs/rst/configuration/policy.rst
 delete mode 100644 docs/rst/daemon-configuration.rst

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f753ba99..8b1c81c3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -103,6 +103,11 @@ include:
     - export BUILD_ID="$CI_JOB_ID"
     - export PREFIX="$PWD/prefix-$BUILD_ID"
     - export PW_BUILD_DIR="$PWD/build-pipewire-$BUILD_ID"
+    - |
+      if [ -n "$FDO_CI_CONCURRENT" ]; then
+         NINJA_ARGS="-j$FDO_CI_CONCURRENT $NINJA_ARGS"
+         export NINJA_ARGS
+      fi
     # Build pipewire
     # Fedora also ships that, but without the test plugins that we need...
     - git clone --depth=1 --branch="$PIPEWIRE_HEAD"
@@ -115,7 +120,7 @@ include:
         -Dsdl2=disabled -Dsndfile=disabled -Dlibpulse=disabled -Davahi=disabled
         -Decho-cancel-webrtc=disabled -Dsession-managers=[]
         -Dvideotestsrc=enabled -Daudiotestsrc=enabled -Dtest=enabled
-    - ninja -C "$PW_BUILD_DIR" install
+    - ninja $NINJA_ARGS -C "$PW_BUILD_DIR" install
     # misc environment only for wireplumber
     - export WP_BUILD_DIR="$PWD/build-wireplumber-$BUILD_ID"
     - export PKG_CONFIG_PATH="$(dirname $(find "$PREFIX" -name 'libpipewire-*.pc')):$PKG_CONFIG_PATH"
@@ -123,9 +128,9 @@ include:
     # Build wireplumber
     - meson "$WP_BUILD_DIR" . --prefix="$PREFIX" $BUILD_OPTIONS
     - cd "$WP_BUILD_DIR"
-    - ninja
-    - ninja test
-    - ninja install
+    - ninja $NINJA_ARGS
+    - ninja $NINJA_ARGS test
+    - ninja $NINJA_ARGS install
   artifacts:
     name: wireplumber-$CI_COMMIT_SHA
     when: always
@@ -213,7 +218,7 @@ build_with_coverity:
         --comptype gcc --compiler cc --template
         --xml-option=append_arg@C:--ppp_translator
         --xml-option=append_arg@C:"replace/GLIB_(DEPRECATED|AVAILABLE)_ENUMERATOR_IN_\d_\d\d(_FOR\(\w+\)|)\s+=/ ="
-    - cov-build --dir cov-int --config coverity_conf.xml ninja -C "$WP_BUILD_DIR"
+    - cov-build --dir cov-int --config coverity_conf.xml ninja $NINJA_ARGS -C "$WP_BUILD_DIR"
     - tar caf wireplumber.tar.gz cov-int
     - curl https://scan.coverity.com/builds?project=$COVERITY_SCAN_PROJECT_NAME
         --form token=$COVERITY_SCAN_TOKEN --form email=$GITLAB_USER_EMAIL
diff --git a/NEWS.rst b/NEWS.rst
index 0c5b8e9f..fd03c5cf 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,6 +1,74 @@
-WirePlumber 0.4.5
+WirePlumber 0.4.7
 ~~~~~~~~~~~~~~~~~
 
+Fixes:
+
+  - Fixed a regression in 0.4.6 that caused the selection of the default audio
+    sources and sinks to be delayed until some event, which effectively caused
+    losing audio output in many circumstances (#148, #150, #151, #153)
+
+  - Fixed a regression in 0.4.6 that caused the echo-cancellation pipewire
+    module (and possibly others) to not work
+
+  - A default sink or source is now not selected if there is no available route
+    for it (#145)
+
+  - Fixed an issue where some clients would wait for a bit while seeking (#146)
+
+  - Fixed audio capture in the endpoints-based policy
+
+  - Fixed an issue that would cause certain lua scripts to error out with older
+    configuration files (#158)
+
+Past releases
+~~~~~~~~~~~~~
+
+WirePlumber 0.4.6
+.................
+
+Changes:
+
+  - Fixed a lot of race condition bugs that would cause strange crashes or
+    many log messages being printed when streaming clients would connect and
+    disconnect very fast (#128, #78, ...)
+
+  - Improved the logic for selecting a default target device (#74)
+
+  - Fixed switching to headphones when the wired headphones are plugged in (#98)
+
+  - Fixed an issue where ``udevadm trigger`` would break wireplumber (#93)
+
+  - Fixed an issue where switching profiles of a device could kill client nodes
+
+  - Fixed briefly switching output to a secondary device when switching device
+    profiles (#85)
+
+  - Fixed ``wpctl status`` showing default device selections when dealing with
+    module-loopback virtual sinks and sources (#130)
+
+  - WirePlumber now ignores hidden files from the config directory (#104)
+
+  - Fixed an interoperability issue with jackdbus (pipewire#1846)
+
+  - Fixed an issue where pulseaudio tcp clients would not have permissions to
+    connect to PipeWire (pipewire#1863)
+
+  - Fixed a crash in the journald logger with NULL debug messages (#124)
+
+  - Enabled real-time priority for the bluetooth nodes to run in RT (#132)
+
+  - Made the default stream volume configurable
+
+  - Scripts are now also looked up in $XDG_CONFIG_HOME/wireplumber/scripts
+
+  - Updated documentation on configuring WirePlumber and fixed some more
+    documentation issues (#68)
+
+  - Added support for using strings as log level selectors in WIREPLUMBER_DEBUG
+
+WirePlumber 0.4.5
+.................
+
 Fixes:
 
   - Fixed a crash that could happen after a node linking error (#76)
@@ -34,9 +102,6 @@ API:
   - The file-monitor-api plugin can now watch files for changes in addition
     to directories
 
-Past releases
-~~~~~~~~~~~~~
-
 WirePlumber 0.4.4
 .................
 
diff --git a/docs/rst/configuration.rst b/docs/rst/configuration.rst
new file mode 100644
index 00000000..d4e051de
--- /dev/null
+++ b/docs/rst/configuration.rst
@@ -0,0 +1,34 @@
+ .. _configuration:
+
+Configuration
+=============
+
+WirePlumber is a heavily modular daemon. By itself, it doesn't do anything
+except load the configured modules. All the rest of the logic is implemented
+inside those modules.
+
+Modular design ensures that it is possible to swap the implementation of
+specific functionality without having to re-implement the rest of it, allowing
+flexibility on target-sensitive parts, such as policy management and
+making use of non-standard hardware.
+
+At startup, WirePlumber first reads its **main** configuration file.
+This file configures the operation context (properties of the daemon,
+modules to be loaded, etc). This file may also specify additional, secondary
+configuration files which will be loaded as well at the time of parsing the
+main file.
+
+All files and modules are specified relative to their standard search locations,
+which are documented later in this chapter.
+
+.. toctree::
+   :maxdepth: 1
+
+   configuration/locations.rst
+   configuration/main.rst
+   configuration/config_lua.rst
+   configuration/multi_instance.rst
+   configuration/alsa.rst
+   configuration/bluetooth.rst
+   configuration/policy.rst
+   configuration/access.rst
diff --git a/docs/rst/configuration/access.rst b/docs/rst/configuration/access.rst
new file mode 100644
index 00000000..d96d8e23
--- /dev/null
+++ b/docs/rst/configuration/access.rst
@@ -0,0 +1,63 @@
+.. _config_access:
+
+Access configuration
+====================
+
+main.lua.d/50-default-access-config.lua
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Using a similar format as the :ref:`ALSA monitor <config_alsa>`, this
+configuration file is charged to configure the client objects created by
+PipeWire.
+
+* *default_access.properties*
+
+  A Lua object that contains generic client configuration properties in the
+  for of key pairs.
+
+  Example:
+
+  .. code-block:: lua
+
+    default_access.properties = {
+      ["enable-flatpak-portal"] = true,
+    }
+
+  The above example sets to ``true`` the ``enable-flatpak-portal`` property.
+
+  The list of valid properties are:
+
+  .. code-block:: lua
+
+    ["enable-flatpak-portal"] = true,
+
+  Whether to enable the flatpak portal or not.
+
+* *default_access.rules*
+
+  This is a Lua array that can contain objects with rules for a client object.
+  Those Lua objects have 2 properties. Similar to the
+  :ref:`ALSA configuration <config_alsa>`, the first property is ``matches``,
+  which allow users to define rules to match a client object.
+  The second property is ``default_permissions``, and it is used to set
+  permissions on the matched client object.
+
+  Example:
+
+  .. code-block:: lua
+
+    {
+      matches = {
+        {
+          { "pipewire.access", "=", "flatpak" },
+        },
+      },
+      default_permissions = "rx",
+    }
+
+  This grants read and execute permissions to all clients that have the
+  ``pipewire.access`` property set to ``flatpak``.
+
+  Possible permissions are any combination of ``r``, ``w`` and ``x`` for read,
+  write and execute; or ``all`` for all kind of permissions.
+
diff --git a/docs/rst/configuration/alsa.rst b/docs/rst/configuration/alsa.rst
new file mode 100644
index 00000000..122c8593
--- /dev/null
+++ b/docs/rst/configuration/alsa.rst
@@ -0,0 +1,456 @@
+.. _config_alsa:
+
+ALSA configuration
+==================
+
+Modifying the default configuration
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+ALSA devices are created and managed by the session manager with the *alsa.lua*
+monitor script. In the default configuration, this script is loaded by
+``main.lua.d/30-alsa-monitor.lua``, which also specifies an ``alsa_monitor``
+global table that can be filled in with properties and rules in subsequent
+config files. By default, these are filled in ``main.lua.d/50-alsa-config.lua``.
+
+The ``alsa_monitor`` global table has 2 sub-tables:
+
+* *alsa_monitor.properties*
+
+  This is a simple Lua table that has key value pairs used as properties.
+
+  Example:
+
+  .. code-block:: lua
+
+    alsa_monitor.properties = {
+      ["alsa.jack-device"] = false,
+      ["alsa.reserve"] = true,
+    }
+
+  The above example will configure the ALSA monitor to not enable the JACK
+  device, and do ALSA device reservation using the mentioned DBus interface.
+
+  A list of valid properties are:
+
+  .. code-block:: lua
+
+    ["alsa.jack-device"] = false
+
+  Creates a JACK device if set to ``true``. This is not enabled by default because
+  it requires that the PipeWire JACK replacement libraries are not used by the
+  session manager, in order to be able to connect to the real JACK server.
+
+  .. code-block:: lua
+
+    ["alsa.reserve"] = true
+
+  Reserve ALSA devices via *org.freedesktop.ReserveDevice1* on D-Bus.
+
+  .. code-block:: lua
+
+    ["alsa.reserve.priority"] = -20
+
+  The used ALSA device reservation priority.
+
+  .. code-block:: lua
+
+    ["alsa.reserve.application-name"] = "WirePlumber"
+
+  The used ALSA device reservation application name.
+
+
+* *alsa_monitor.rules*
+
+  This is a Lua array that can contain objects with rules for a device or node.
+  Those objects have 2 properties. The first one is ``matches``, which allow
+  users to define rules to match a device or node. The second property is
+  ``apply_properties``, and it is used to apply properties on the matched object.
+
+  Example:
+
+  .. code-block:: lua
+
+    alsa_monitor.rules = {
+        matches = {
+          {
+            { "device.name", "matches", "alsa_card.*" },
+          },
+        },
+        apply_properties = {
+          ["api.alsa.use-acp"] = true,
+        }
+    }
+
+  This sets the API ALSA use ACP property to all devices with a name that
+  matches the ``alsa_card.*`` pattern.
+
+  The ``matches`` section is an array of arrays. On the first level, the rules
+  are ORed together, so any rule match is going to apply the properties. On
+  the second level, the rules are merged with AND, so they must all match.
+
+  Example:
+
+  .. code-block:: lua
+
+    matches = {
+      {
+        { "node.name", "matches", "alsa_input.*" },
+        { "alsa.driver_name", "equals", "snd_hda_intel" },
+      },
+      {
+        { "node.name", "matches", "alsa_output.*" },
+      },
+    },
+
+  This is equivalent to the following logic, in pseudocode:
+
+  .. code-block::
+
+    if ("node.name" MATCHES "alsa_input.*" AND "alsa.driver_name" EQUALS "snd_hda_intel" )
+       OR
+       ("node.name" MATCHES "alsa_output.*")
+    then
+       ... apply the properties ...
+    end
+
+  As you can notice, the individual rules are themselves also lua arrays. The
+  first element is a property name (ex "node.name"), the second element is a
+  verb and the third element is an expected value, which depends on the verb.
+  Internally, this uses the ``Constraint`` API, which is documented in the
+  :ref:`Object Interet API <lua_object_interest_api>` section. All the verbs
+  that you can use on ``Constraint`` are also allowed here.
+
+  .. note::
+
+    When using the "matches" verb, the values are not complete regular expressions.
+    They are wildcard patterns, which means that '*' matches an arbitrary,
+    possibly empty, string and '?' matches an arbitrary character.
+
+  All the possible properties that you can apply to devices and nodes of the
+  ALSA monitor are described in the sections below.
+
+Device properties
+^^^^^^^^^^^^^^^^^
+
+PipeWire devices correspond to the ALSA cards.
+The following properties can be configured on devices created by the monitor:
+
+.. code-block:: lua
+
+  ["api.alsa.use-acp"] = true
+
+Use the ACP (alsa card profile) code to manage the device. This will probe the
+device and configure the available profiles, ports and mixer settings. The
+code to do this is taken directly from PulseAudio and provides devices that
+look and feel exactly like the PulseAudio devices.
+
+.. code-block:: lua
+
+  ["api.alsa.use-ucm"] = true
+
+By default, the UCM configuration is used when it is available for your device.
+With this option you can disable this and use the ACP profiles instead.
+
+.. code-block:: lua
+
+  ["api.alsa.soft-mixer"] = false
+
+Setting this option to true will disable the hardware mixer for volume control
+and mute. All volume handling will then use software volume and mute, leaving
+the hardware mixer untouched. The hardware mixer will still be used to mute
+unused audio paths in the device.
+
+.. code-block:: lua
+
+  ["api.alsa.ignore-dB"] = false
+
+Setting this option to true will ignore the decibel setting configured by the
+driver. Use this when the driver reports wrong settings.
+
+.. code-block:: lua
+
+  ["device.profile-set"] = "profileset-name"
+
+This option can be used to select a custom profile set name for the device.
+Usually this is configured in Udev rules but it can also be specified here.
+
+.. code-block:: lua
+
+  ["device.profile"] = "default profile name"
+
+The default active profile name.
+
+.. code-block:: lua
+
+  ["api.acp.auto-profile"] = false
+
+Automatically select the best profile for the device. Normally this option is
+disabled because the session manager will manage the profile of the device.
+The session manager can save and load previously selected profiles. Enable
+this if your session manager does not handle this feature.
+
+.. code-block:: lua
+
+  ["api.acp.auto-port"] = false
+
+Automatically select the highest priority port that is available. This is by
+default disabled because the session manager handles the task of selecting and
+restoring ports. It can, for example, restore previously saved volumes. Enable
+this here when the session manager does not handle port restore.
+
+Some of the other properties that might be configured on devices:
+
+.. code-block:: lua
+
+  ["device.nick"] = "My Device",
+  ["device.description"] = "My Device"
+
+``device.description`` will show up in most apps when a device name is shown.
+
+Node Properties
+^^^^^^^^^^^^^^^
+
+Nodes are sinks or sources in the PipeWire graph. They correspond to the ALSA
+devices. In addition to the generic stream node configuration options, there are
+some alsa specific options as well:
+
+.. code-block:: lua
+
+    ["priority.driver"] = 2000
+
+This configures the node driver priority. Nodes with higher priority will be
+used as a driver in the graph. Other nodes with lower priority will have to
+resample to the driver node when they are joined in the same graph. The default
+value is set based on some heuristics.
+
+.. code-block:: lua
+
+    ["priority.session"] = 100
+
+This configures the priority of the device when selecting a default device.
+Higher priority devices will be more likely candidates as a default device.
+
+.. code-block:: lua
+
+    ["node.pause-on-idle"] = false
+
+Pause-on-idle will stop the node when nothing is linked to it anymore.
+This is by default false because some devices cause a pop when they are
+opened/closed. The node will, normally, pause and suspend after a timeout
+(see suspend-node.lua).
+
+.. code-block:: lua
+
+    ["session.suspend-timeout-seconds"] = 5  -- 0 disables suspend
+
+This option configures a different suspend timeout on the node.
+By default this is 5 seconds. For some devices (HiFi amplifiers, for example)
+it might make sense to set a higher timeout because they might require some
+time to restart after being idle.
+
+A value of 0 disables suspend for a node and will leave the ALSA device busy.
+The device can then manually be suspended with ``pactl suspend-sink|source``.
+
+**The following properties can be used to configure the format used by the
+ALSA device:**
+
+.. code-block:: lua
+
+    ["audio.format"] = "S16LE"
+
+By default, PipeWire will use a 32 bits sample format but a different format
+can be set here.
+
+The Audio rate of a device can be set here:
+
+.. code-block:: lua
+
+    ["audio.rate"] = 44100
+
+By default, the ALSA device will be configured with the same samplerate as the
+global graph. If this is not supported, or a custom values is set here,
+resampling will be used to match the graph rate.
+
+.. code-block:: lua
+
+    ["audio.channels"] = 2
+    ["audio.position"] = "FL,FR"
+
+By default the channels and their position are determined by the selected
+Device profile. You can override this setting here and optionally swap or
+reconfigure the channel positions.
+
+.. code-block:: lua
+
+    ["api.alsa.use-chmap"] = false
+
+Use the channel map as reported by the driver. This is disabled by default
+because it is often wrong and the ACP code handles this better.
+
+.. code-block:: lua
+
+    ["api.alsa.disable-mmap"]  = true
+
+PipeWire will by default access the memory of the device using mmap.
+This can be disabled and force the usage of the slower read and write access
+modes in case the mmap support of the device is not working properly.
+
+.. code-block:: lua
+
+    ["channelmix.normalize"] = true
+
+Makes sure that during such mixing & resampling original 0 dB level is
+preserved, so nothing sounds wildly quieter/louder.
+
+.. code-block:: lua
+
+    ["channelmix.mix-lfe"] = true
+
+Creates "center" channel for X.0 recordings from front stereo on X.1 setups and
+pushes some low-frequency/bass from "center" from X.1 recordings into front
+stereo on X.0 setups.
+
+.. code-block:: lua
+
+    ["monitor.channel-volumes"] = false
+
+By default, the volume of the sink/source does not influence the volume on the
+monitor ports. Set this option to true to change this. PulseAudio has
+inconsistent behaviour regarding this option, it applies channel-volumes only
+when the sink/source is using software volumes.
+
+ALSA buffer properties
+^^^^^^^^^^^^^^^^^^^^^^
+
+PipeWire uses a timer to consume and produce samples to/from ALSA devices.
+After every timeout, it queries the device hardware pointers of the device and
+uses this information to set a new timeout. See also this example program.
+
+By default, PipeWire handles ALSA batch devices differently from non-batch
+devices. Batch devices only get their hardware pointers updated after each
+hardware interrupt. Non-batch devices get updates independent of the interrupt.
+This means that for batch devices we need to set the interrupt at a sufficiently
+high frequency (at the cost of CPU usage) while for non-batch devices we want to
+set the interrupt frequency as low as possible (to save CPU).
+
+For batch devices we also need to take the extra buffering into account caused
+by the delayed updates of the hardware pointers.
+
+Most USB devices are batch devices and will be handled as such by PipeWire by
+default.
+
+There are 2 tunable parameters to control the buffering and timeouts in a
+device
+
+.. code-block:: lua
+
+    ["api.alsa.period-size"] = 1024
+
+This sets the device interrupt to every period-size samples for non-batch
+devices and to half of this for batch devices. For batch devices, the other
+half of the period-size is used as extra buffering to compensate for the delayed
+update. So, for batch devices, there is an additional period-size/2 delay.
+It makes sense to lower the period-size for batch devices to reduce this delay.
+
+.. code-block:: lua
+
+    ["api.alsa.headroom"] = 0
+
+This adds extra delay between the hardware pointers and software pointers.
+In most cases this can be set to 0. For very bad devices or emulated devices
+(like in a VM) it might be necessary to increase the headroom value.
+In summary, this is the overview of buffering and timings:
+
+
+  ============== ========================================== =========
+  Property       Batch                                      Non-Batch
+  ============== ========================================== =========
+  IRQ Frequency  api.alsa.period-size/2                     api.alsa.period-size
+  Extra Delay    api.alsa.headroom + api.alsa.period-size/2 api.alsa.headroom
+  ============== ========================================== =========
+
+It is possible to disable the batch device tweaks with:
+
+.. code-block:: lua
+
+    ["api.alsa.disable-batch"] = true
+
+It removes the extra delay added of period-size/2 if the device can support this.
+For batch devices it is also a good idea to lower the period-size
+(and increase the IRQ frequency) to get smaller batch updates and lower latency.
+
+ALSA extra latency properties
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Extra internal delay in the DAC and ADC converters of the device itself can be
+set with the ``latency.internal.*`` properties:
+
+.. code-block:: lua
+
+    ["latency.internal.rate"] = 256
+    ["latency.internal.ns"] = 0
+
+You can configure a latency in samples (relative to rate with
+``latency.internal.rate``) or in nanoseconds (``latency.internal.ns``).
+This value will be added to the total reported latency by the node of the device.
+
+You can use a tool like ``jack_iodelay`` to get the number of samples of
+internal latency of your device.
+
+This property is also adjustable at runtime with the ``ProcessLatency`` param.
+You will need to find the id of the Node you want to change. For example:
+Query the current internal latency of an ALSA node with id 58:
+
+.. code-block:: console
+
+    $ pw-cli e 58 ProcessLatency
+    Object: size 80, type Spa:Pod:Object:Param:ProcessLatency (262156), id Spa:Enum:ParamId:ProcessLatency (16)
+      Prop: key Spa:Pod:Object:Param:ProcessLatency:quantum (1), flags 00000000
+        Float 0.000000
+      Prop: key Spa:Pod:Object:Param:ProcessLatency:rate (2), flags 00000000
+        Int 0
+      Prop: key Spa:Pod:Object:Param:ProcessLatency:ns (3), flags 00000000
+        Long 0
+
+Set the internal latency to 256 samples:
+
+.. code-block:: console
+
+    $ pw-cli s 58 ProcessLatency '{ rate = 256 }'
+    Object: size 32, type Spa:Pod:Object:Param:ProcessLatency (262156), id Spa:Enum:ParamId:ProcessLatency (16)
+      Prop: key Spa:Pod:Object:Param:ProcessLatency:rate (2), flags 00000000
+        Int 256
+    remote 0 node 58 changed
+    remote 0 port 70 changed
+    remote 0 port 72 changed
+    remote 0 port 74 changed
+    remote 0 port 76 changed
+
+Startup tweaks
+^^^^^^^^^^^^^^
+
+Some devices need some time before they can report accurate hardware pointer
+positions. In those cases, an extra start delay can be added that is used to
+compensate for this startup delay:
+
+.. code-block:: lua
+
+    ["api.alsa.start-delay"] = 0
+
+It is unsure when this tunable should be used.
+
+IEC958 (S/PDIF) passthrough
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+S/PDIF passthrough will only be enabled when the accepted codecs are configured
+on the ALSA device.
+
+This can be done in 3 different ways:
+
+  1. Use pavucontrol and toggle the codecs in the output advanced section
+
+  2. Modify the ``["iec958.codecs"] = "[ PCM DTS AC3 MPEG MPEG2-AAC EAC3 TrueHD DTS-HD ]"``
+     node property to something.
+
+  3. Use ``pw-cli s <node-id> Props '{ iec958Codecs : [ PCM ] }'`` to modify
+     the codecs at runtime.
diff --git a/docs/rst/configuration/bluetooth.rst b/docs/rst/configuration/bluetooth.rst
new file mode 100644
index 00000000..d9290204
--- /dev/null
+++ b/docs/rst/configuration/bluetooth.rst
@@ -0,0 +1,160 @@
+.. _config_bluetooth:
+
+Bluetooth configuration
+=======================
+
+Using the same format as the :ref:`ALSA monitor <config_alsa>`, the
+configuration file ``bluetooth.lua.d/50-bluez-config.lua`` is charged
+to configure the Bluetooth devices and nodes created by WirePlumber.
+
+* *bluez_monitor.properties*
+
+  A Lua object that contains generic client configuration properties in the
+  for of key pairs.
+
+  Example:
+
+  .. code-block:: lua
+
+    bluez_monitor.properties = {
+      ["bluez5.enable-msbc"] = true,
+    }
+
+  This example will enable the MSBC codec in connected Bluetooth devices that
+  support it.
+
+  The list of valid properties are:
+
+  .. code-block:: lua
+
+    ["bluez5.enable-sbc-xq"] = true
+
+  Enables the SBC-XQ codec in connected Blueooth devices that support it
+
+  .. code-block:: lua
+
+    ["bluez5.enable-msbc"] = true
+
+  Enables the MSBC codec in connected Blueooth devices that support it
+
+  .. code-block:: lua
+
+    ["bluez5.enable-hw-volume"] = true
+
+  Enables hardware volume controls in Bluetooth devices that support it
+
+  .. code-block:: lua
+
+    ["bluez5.headset-roles"] = "[ hsp_hs hsp_ag hfp_hf hfp_ag ]"
+
+  Enabled headset roles (default: [ hsp_hs hfp_ag ]), this property only applies
+  to native backend. Currently some headsets (Sony WH-1000XM3) are not working
+  with both hsp_ag and hfp_ag enabled, disable either hsp_ag or hfp_ag to work
+  around it.
+
+  Supported headset roles: ``hsp_hs`` (HSP Headset), ``hsp_ag`` (HSP Audio Gateway),
+  ``hfp_hf`` (HFP Hands-Free) and ``hfp_ag`` (HFP Audio Gateway)
+
+  .. code-block:: lua
+
+    ["bluez5.codecs"] = "[ sbc sbc_xq aac ]"
+
+  Enables ``sbc``, ``sbc_zq`` and ``aac`` A2DP codecs.
+
+  Supported codecs: ``sbc``, ``sbc_xq``, ``aac``, ``ldac``, ``aptx``,
+  ``aptx_hd``, ``aptx_ll``, ``aptx_ll_duplex``, ``faststream``, ``faststream_duplex``.
+
+  All codecs are supported by default.
+
+  .. code-block:: lua
+
+    ["bluez5.hfphsp-backend"] = "native"
+
+  HFP/HSP backend (default: native). Available values: ``any``, ``none``,
+  ``hsphfpd``, ``ofono`` or ``native``.
+
+  .. code-block:: lua
+
+    ["bluez5.default.rate"] = 48000
+
+  The bluetooth default audio rate.
+
+  .. code-block:: lua
+
+    ["bluez5.default.channels"] = 2
+
+  The bluetooth default number of channels.
+
+* *bluez_monitor.rules*
+
+  Like in the :ref:`ALSA configuration <config_alsa>`, this is a Lua array that
+  can contain objects with rules for a Bluetooth device or node.
+  Those objects have 2 properties. The first one is ``matches``, which allows
+  users to define rules to match a Bluetooth device or node.
+  The second property is ``apply_properties``, and it is used to apply
+  properties on the matched Bluetooth device or node.
+
+  Example:
+
+  .. code-block:: lua
+
+    {
+      matches = {
+        {
+          { "device.name", "matches", "bluez_card.*" },
+        },
+      },
+      apply_properties = {
+         ["bluez5.auto-connect"]  = "[ hfp_hf hsp_hs a2dp_sink ]"
+      }
+    }
+
+  This will set the auto-connect property to ``hfp_hf``, ``hsp_hs`` and ``a2dp_sink``
+  on bluetooth devices whose name matches the ``bluez_card.*`` pattern.
+
+  A list of valid properties are:
+
+  .. code-block:: lua
+
+    ["bluez5.auto-connect"] = "[ hfp_hf hsp_hs a2dp_sink ]"
+
+  Auto-connect device profiles on start up or when only partial profiles have
+  connected. Disabled by default if the property is not specified.
+
+  Supported values are: ``hfp_hf``, ``hsp_hs``, ``a2dp_sink``, ``hfp_ag``,
+  ``hsp_ag`` and ``a2dp_source``.
+
+  .. code-block:: lua
+
+    ["bluez5.hw-volume"] = "[ hfp_ag hsp_ag a2dp_source ]"
+
+  Hardware volume controls (default: ``hfp_ag``, ``hsp_ag``, and ``a2dp_source``)
+
+  Supported values are: ``hfp_hf``, ``hsp_hs``, ``a2dp_sink``, ``hfp_ag``,
+  ``hsp_ag`` and ``a2dp_source``.
+
+  .. code-block:: lua
+
+    ["bluez5.a2dp.ldac.quality"] = "auto"
+
+  LDAC encoding quality.
+
+  Available values: ``auto`` (Adaptive Bitrate, default),
+  ``hq`` (High Quality, 990/909kbps), ``sq`` (Standard Quality, 660/606kbps) and
+  ``mq`` (Mobile use Quality, 330/303kbps).
+
+  .. code-block:: lua
+
+    ["bluez5.a2dp.aac.bitratemode"] = 0
+
+  AAC variable bitrate mode.
+
+  Available values: 0 (cbr, default), 1-5 (quality level).
+
+  .. code-block:: lua
+
+    ["device.profile"] = "a2dp-sink"
+
+  Profile connected first.
+
+  Available values: ``a2dp-sink`` (default) or ``headset-head-unit``.
diff --git a/docs/rst/configuration/config_lua.rst b/docs/rst/configuration/config_lua.rst
new file mode 100644
index 00000000..d3558c88
--- /dev/null
+++ b/docs/rst/configuration/config_lua.rst
@@ -0,0 +1,170 @@
+.. _config_lua:
+
+Lua configuration files
+=======================
+
+Lua configuration files are similar to the main configuration file, but they
+leverage the lua language to enable advanced configuration of module arguments
+and allow split-file configuration.
+
+There is only one global section that WirePlumber reads from these files: the
+**components** table. This table is equivalent to the **context.components**
+object on the main configuration file. Its purpose is to list components that
+WirePlumber should load on startup.
+
+Every line on the **components** table should be another table that contains
+information about the loaded component::
+
+  {
+    "component-name",
+    type = "component-type",
+    args = { additional arguments },
+    optional = true/false,
+  }
+
+* **"component-name"** should be the name of the component to load
+  (ex. *"libwireplumber-module-mixer-api"*)
+
+* **"component-type"** should be the type of the component.
+  Valid component types include:
+
+  * ``module``: A WirePlumber shared object module
+  * ``script/lua``: A WirePlumber Lua script
+  * ``pw_module``: A PipeWire shared object module (loaded on WirePlumber,
+    not on the PipeWire daemon)
+
+* **args** is an optional table that can contain additional arguments to be
+  passed down to the module or script. Scripts can retrieve these arguments
+  by declaring a line that reads ``local config = ...`` at the top of the script.
+  Modules receive these arguments as a GVariant ``a{sv}`` table.
+
+* **optional** is a boolean value that specifies whether loading of this
+  component is optional. The default value is ``false``. If set to ``true``,
+  then WirePlumber will not fail loading if the component is not found.
+
+Split-file configuration
+------------------------
+
+When a Lua configuration file is loaded, the engine also looks for additional
+files in a directory that has the same name as the configuration file and a
+``.d`` suffix.
+
+A Lua directory can contain a list of Lua configuration files. Those files are
+loaded alphabetically by filename so that user can control the order in which
+Lua configuration files are executed.
+
+Lua files in the directory are always loaded *after* the configuration file
+that is out of the directory. However, it is perfectly valid to not have any
+configuration file out of the directory.
+
+Example hierarchy with files both in and out of the directory
+(in the order of loading)::
+
+  config.lua
+  config.lua.d/00-functions.lua
+  config.lua.d/01-alsa.lua
+  config.lua.d/10-policy.lua
+  config.lua.d/99-misc.lua
+
+Example hierarchy with files only in the directory
+(in the order of loading)::
+
+  config.lua.d/00-functions.lua
+  config.lua.d/01-alsa.lua
+  config.lua.d/10-policy.lua
+  config.lua.d/99-misc.lua
+
+Multi-path merging
+------------------
+
+WirePlumber looks for configuration files in 3 different places, as described
+in the :ref:`Locations of files <config_locations>` section. When a split-file
+configuration scheme is used, files will be merged from these different locations.
+
+For example, consider these files exist on the filesystem::
+
+  /usr/share/wireplumber/config.lua.d/00-functions.lua
+  /usr/share/wireplumber/config.lua.d/01-alsa.lua
+  /usr/share/wireplumber/config.lua.d/10-policy.lua
+  /usr/share/wireplumber/config.lua.d/99-misc.lua
+  ...
+  /etc/wireplumber/config.lua.d/01-alsa.lua
+  ...
+  /home/user/.config/wireplumber/config.lua.d/11-policy-extras.lua
+
+In this case, loading ``config.lua`` will result in loading these files
+(in this order)::
+
+  /usr/share/wireplumber/config.lua.d/00-functions.lua
+  /etc/wireplumber/config.lua.d/01-alsa.lua
+  /usr/share/wireplumber/config.lua.d/10-policy.lua
+  /home/user/.config/wireplumber/config.lua.d/11-policy-extras.lua
+  /usr/share/wireplumber/config.lua.d/99-misc.lua
+
+This is useful to keep the default configuration in /usr and override it
+with host-specific and user-specific parts in /etc and /home respectively.
+
+As an exception to this rule, if the configuration path is overridden with
+the ``WIREPLUMBER_CONFIG_DIR`` environment variable, then configuration files
+will only be loaded from this path and no merging will happen.
+
+Functions
+---------
+
+Because of the nature of these files (they are scripts!), it is more convenient
+to manage the **components** table through functions. In the default
+configuration files shipped with WirePlumber, there is a file called
+``00-functions.lua`` that defines some helper functions to load components.
+
+When loading components through these functions, *duplicate calls are ignored*,
+so it is possible to call a function to load a specific component multiple times
+and it will only be loaded once.
+
+.. function:: load_module(module, args)
+
+   Loads a WirePlumber shared object module.
+
+   :param string module: the module name, without the "libwireplumber-module-"
+      prefix (ex specify "mixer-api" to load "libwireplumber-module-mixer-api")
+   :param table args: optional module arguments table
+
+.. function:: load_optional_module(module, args)
+
+   Loads an optional WirePlumber shared object module. Optional in this case
+   means that if the module is not present on the filesystem, it will be ignored.
+
+   :param string module: the module name, without the "libwireplumber-module-"
+      prefix (ex specify "mixer-api" to load "libwireplumber-module-mixer-api")
+   :param table args: optional module arguments table
+
+.. function:: load_pw_module(module)
+
+   Loads a PipeWire shared object module
+
+   :param string module: the module name, without the "libpipewire-module-"
+      prefix (ex specify "adapter" to load "libpipewire-module-adapter")
+
+.. function:: load_script(script, args)
+
+   Loads a Lua script (a functionality script, not a lua configuration file)
+
+   :param string script: the script's filename (ex. "policy-node.lua")
+   :param table args: optional script arguments table
+
+.. function:: load_monitor(monitor, args)
+
+   Loads a Lua monitor script. Monitors are scripts found in the ``monitors/``
+   directory and their purpose is to monitor and load devices.
+
+   :param string monitor: the scripts's name without the directory or the .lua
+      extension (ex. "alsa" will load "monitors/alsa.lua")
+   :param table args: optional script arguments table
+
+.. function:: load_access(access, args)
+
+   Loads a Lua access script. Access scripts are ones found in the ``access/``
+   directory and their purpose is to manage application permissions.
+
+   :param string access: the scripts's name without the directory or the .lua
+      extension (ex. "flatpak" will load "access/access-flatpak.lua")
+   :param table args: optional script arguments table
diff --git a/docs/rst/configuration/locations.rst b/docs/rst/configuration/locations.rst
new file mode 100644
index 00000000..15c53400
--- /dev/null
+++ b/docs/rst/configuration/locations.rst
@@ -0,0 +1,94 @@
+.. _config_locations:
+
+Locations of files
+==================
+
+Location of configuration files
+-------------------------------
+
+WirePlumber's default locations of its configuration files are determined at
+compile time by the build system. Typically, those end up being
+``$XDG_CONFIG_DIR/wireplumber``, ``/etc/wireplumber``, and
+``/usr/share/wireplumber``, in that order of priority.
+
+In more detail, the latter two are controlled by the ``--sysconfdir`` and ``--datadir``
+meson options. When those are set to an absolute path, such as ``/etc``, the
+location of the configuration files is set to be ``$sysconfdir/wireplumber``.
+When set to a relative path, such as ``etc``, then the installation prefix (``--prefix``)
+is prepended to the path: ``$prefix/$sysconfdir/wireplumber``
+
+The three locations are intended for custom user configuration,
+host-specific configuration and distribution-provided configuration,
+respectively. At runtime, WirePlumber will search the directories
+for the highest-priority directory to contain the needed configuration file.
+This allows a user or system administrator to easily override the distribution
+provided configuration files by placing an equally named file in the respective
+directory.
+
+It is also possible to override the configuration directory by setting the
+``WIREPLUMBER_CONFIG_DIR`` environment variable::
+
+  WIREPLUMBER_CONFIG_DIR=src/config wireplumber
+
+If ``WIREPLUMBER_CONFIG_DIR`` is set, the default locations are ignored and
+configuration files are *only* looked up in this directory.
+
+
+Location of scripts
+-------------------
+
+WirePlumber's default locations of its scripts are the same ones as for the
+configuration files, but with the ``scripts`` directory appended.
+Typically, these end up being ``$XDG_CONFIG_DIR/wireplumber/scripts``,
+``/etc/wireplumber/scripts``, and ``/usr/share/wireplumber/scripts``,
+in that order of priority.
+
+The three locations are intended for custom user scripts,
+host-specific scripts and distribution-provided scripts, respectively.
+At runtime, WirePlumber will search the directories for the highest-priority
+directory to contain the needed script.
+
+It is also possible to override the scripts directory by setting the
+``WIREPLUMBER_DATA_DIR`` environment variable::
+
+  WIREPLUMBER_DATA_DIR=src wireplumber
+
+The "data" directory is a somewhat more generic path that may be used for
+other kinds of data files in the future. For scripts, WirePlumber still expects
+to find a ``scripts`` subdirectory in this "data" directory, so in the above
+example the scripts would be in ``src/scripts``.
+
+If ``WIREPLUMBER_DATA_DIR`` is set, the default locations are ignored and
+scripts are *only* looked up in this directory.
+
+Location of modules
+-------------------
+
+WirePlumber modules
+^^^^^^^^^^^^^^^^^^^
+
+Like with configuration files, WirePlumber's default location of its modules is
+determined at compile time by the build system. Typically, it ends up being
+``/usr/lib/wireplumber-0.4`` (or ``/usr/lib/<arch-triplet>/wireplumber-0.4`` on
+multiarch systems)
+
+In more detail, this is controlled by the ``--libdir`` meson option. When
+this is set to an absolute path, such as ``/lib``, the location of the
+modules is set to be ``$libdir/wireplumber-$abi_version``. When this is set
+to a relative path, such as ``lib``, then the installation prefix (``--prefix``)
+is prepended to the path: ``$prefix/$libdir/wireplumber-$abi_version``.
+
+It is possible to override this directory at runtime by setting the
+``WIREPLUMBER_MODULE_DIR`` environment variable::
+
+  WIREPLUMBER_MODULE_DIR=build/modules wireplumber
+
+PipeWire and SPA modules
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+PipeWire and SPA modules are not loaded from the same location as WirePlumber's
+modules. They are loaded from the location that PipeWire loads them.
+
+It is also possible to override these locations by using environment variables:
+``SPA_PLUGIN_DIR`` and ``PIPEWIRE_MODULE_DIR``. For more details, refer to
+PipeWire's documentation.
diff --git a/docs/rst/configuration/main.rst b/docs/rst/configuration/main.rst
new file mode 100644
index 00000000..93f06596
--- /dev/null
+++ b/docs/rst/configuration/main.rst
@@ -0,0 +1,126 @@
+.. _config_main:
+
+Main configuration file
+=======================
+
+The main configuration file is by default called ``wireplumber.conf``. This can
+be changed on the command line by passing the ``--config-file`` or ``-c`` option::
+
+  wireplumber --config-file=bluetooth.conf
+
+The ``--config-file`` option is useful to run multiple instances of wireplumber
+that do separate tasks each. For more information on this subject, see the
+:ref:`Multiple Instances <config_multi_instance>` section.
+
+The format of this configuration file is the variant of JSON that is also
+used in PipeWire configuration files. Note that this is subject to change
+in the future.
+
+All sections are essentially JSON objects. Lines starting with *#* are treated
+as comments and ignored. The list of all possible section JSON objects are:
+
+* *context.properties*
+
+  Used to define properties to configure the PipeWire context and some modules.
+
+  Example::
+
+    context.properties = {
+      application.name = WirePlumber
+      log.level = 2
+    }
+
+  This sets the daemon's name to *WirePlumber* and the log level to *2*, which
+  only displays errors and warnings. See the
+  :ref:`Debug Logging <logging>` section for more details.
+
+* *context.spa-libs*
+
+  Used to find spa factory names. It maps a spa factory name regular expression
+  to a library name that should contain that factory. The object property names
+  are the regular expression, and the object property values are the actual
+  library name::
+
+    <factory-name regex> = <library-name>
+
+  Example::
+
+    context.spa-libs = {
+      api.alsa.*      = alsa/libspa-alsa
+      audio.convert.* = audioconvert/libspa-audioconvert
+    }
+
+  In this example, we instruct wireplumber to only any *api.alsa.** factory name
+  from the *libspa-alsa* library, and also any *audio.convert.** factory name
+  from the *libspa-audioconvert* library.
+
+* *context.modules*
+
+  Used to load PipeWire modules. This does not affect the PipeWire daemon by any
+  means. It exists simply to allow loading *libpipewire* modules in the PipeWire
+  core that runs inside WirePlumber. This is usually useful to load PipeWire
+  protocol extensions, so that you can export custom objects to PipeWire and
+  other clients.
+
+  Users can also pass key-value pairs if the specific module has arguments, and
+  a combination of 2 flags: ``ifexists`` flag is given, the module is ignored when
+  not found; if ``nofail`` is given, module initialization failures are ignored::
+
+    {
+      name = <module-name>
+      [ args = { <key> = <value> ... } ]
+      [ flags = [ [ ifexists ] [ nofail ] ]
+    }
+
+  Example::
+
+    context.modules = [
+      { name = libpipewire-module-adapter }
+      {
+        name = libpipewire-module-metadata,
+        flags = [ ifexists ]
+      }
+    ]
+
+  The above example loads both PipeWire adapter and metadata modules. The
+  metadata module will be ignored if not found because of its ``ifexists`` flag.
+
+* *context.components*
+
+  Used to load WirePlumber components. Components can be either WirePlumber
+  modules written in C, WirePlumber scripts or other configuration
+  files::
+
+    { name = <component-name>, type = <component-type> }
+
+  Valid component types include:
+
+  * ``module``: A WirePlumber shared object module
+  * ``script/lua``: A WirePlumber Lua script
+    (requires ``libwireplumber-module-lua-scripting``)
+  * ``config/lua``: A WirePlumber Lua configuration file
+    (requires ``libwireplumber-module-lua-scripting``)
+
+  Example::
+
+    context.components = [
+      { name = libwireplumber-module-lua-scripting, type = module }
+      { name = main.lua, type = config/lua }
+    ]
+
+  This will load the WirePlumber lua-scripting module, dynamically, and then
+  it will also load any components specified in the ``main.lua`` file.
+
+  .. note::
+
+    When loading lua configuration files, WirePlumber will also look for
+    additional files in the directory suffixed with ``.d`` and will load
+    all of them as well. For example, loading ``example.lua`` will also load
+    any ``.lua`` files under ``example.lua.d/``. In addition, the presence of the
+    main file is optional, so it is valid to specify ``example.lua`` in the
+    component name, while ``example.lua`` doesn't exist, but ``example.lua.d/``
+    exists instead and has ``.lua`` files to load.
+
+    For more information about lua configuration files, see the
+    :ref:`Lua configuration files <config_lua>` section.
+
diff --git a/docs/rst/configuration/meson.build b/docs/rst/configuration/meson.build
new file mode 100644
index 00000000..a4c73e09
--- /dev/null
+++ b/docs/rst/configuration/meson.build
@@ -0,0 +1,11 @@
+# you need to add here any files you add to the toc directory as well
+sphinx_files += files(
+  'locations.rst',
+  'main.rst',
+  'config_lua.rst',
+  'multi_instance.rst',
+  'alsa.rst',
+  'bluetooth.rst',
+  'policy.rst',
+  'access.rst',
+)
diff --git a/docs/rst/configuration/multi_instance.rst b/docs/rst/configuration/multi_instance.rst
new file mode 100644
index 00000000..b8a0c8ae
--- /dev/null
+++ b/docs/rst/configuration/multi_instance.rst
@@ -0,0 +1,45 @@
+.. _config_multi_instance:
+
+Running multiple instances
+==========================
+
+WirePlumber has the ability to run either as a single instance daemon or as
+multiple instances, meaning that there can be multiple processes, each one
+doing a different task.
+
+In the default configuration, both setups are supported. The default is to run
+in single-instance mode.
+
+In single-instance mode, WirePlumber reads ``wireplumber.conf``, which is the
+default configuration file, and from there it loads ``main.lua``, ``policy.lua``
+and ``bluetooth.lua``, which are lua configuration files (deployed as directories)
+that enable all the relevant functionality.
+
+In multi-instance mode, WirePlumber is meant to be started with the
+``--config-file`` command line option 3 times:
+
+.. code-block:: console
+
+  $ wireplumber --config-file=main.conf
+  $ wireplumber --config-file=policy.conf
+  $ wireplumber --config-file=bluetooth.conf
+
+That loads one process which reads ``main.conf``, which then loads ``main.lua``
+and enables core functionality. Then another process that reads ``policy.conf``,
+which then loads ``policy.lua`` and enables policy functionality... and so on.
+
+To make this easier to work with, a template systemd unit is provided, which is
+meant to be started with the name of the main configuration file as a
+template argument:
+
+.. code-block:: console
+
+  $ systemctl --user disable wireplumber # disable the single instance
+
+  $ systemctl --user enable wireplumber@main
+  $ systemctl --user enable wireplumber@policy
+  $ systemctl --user enable wireplumber@bluetooth
+
+It is obviously possible to start as many instances as desired, with manually
+crafted configuration files, as long as it is ensured that these instances
+serve a different purpose and they do not conflict with each other.
diff --git a/docs/rst/configuration/policy.rst b/docs/rst/configuration/policy.rst
new file mode 100644
index 00000000..84cf49d3
--- /dev/null
+++ b/docs/rst/configuration/policy.rst
@@ -0,0 +1,116 @@
+.. _config_policy:
+
+Policy Configuration
+====================
+
+policy.lua.d/10-default-policy.lua
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This file contains generic default policy properties that can be configured.
+
+* *default_policy.policy*
+
+  This is a Lua object that contains several properties that change the
+  behavior of the default WirePlumber policy.
+
+  Example:
+
+  .. code-block:: lua
+
+    default_policy.policy = {
+      ["move"] = true,
+    }
+
+  The above example will set the ``move`` policy property to ``true``.
+
+  The list of supported properties are:
+
+  .. code-block:: lua
+
+    ["move"] = true
+
+  Moves session items when metadata ``target.node`` changes.
+
+  .. code-block:: lua
+
+    ["follow"] = true
+
+  Moves session items to the default device when it has changed.
+
+  .. code-block:: lua
+
+    ["audio.no-dsp"] = false
+
+  Set to ``true`` to disable channel splitting & merging on nodes and enable
+  passthrough of audio in the same format as the format of the device. Note that
+  this breaks JACK support; it is generally not recommended.
+
+  .. code-block:: lua
+
+    ["duck.level"] = 0.3
+
+  How much to lower the volume of lower priority streams when ducking. Note that
+  this is a linear volume modifier (not cubic as in PulseAudio).
+
+policy.lua.d/50-endpoints-config.lua
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+  Endpoints are objects that can group multiple clients into different groups or
+  roles. This is useful if a user wants to apply specific actions when a client
+  is connected to a particular role/endpoint. This configuration file allows
+  users to configure those endpoints and their actions.
+
+* *default_policy.policy.roles*
+
+  This is a Lua array with objects defining the actions of each role.
+
+  Example:
+
+  .. code-block:: lua
+
+    default_policy.policy.roles = {
+      ["Multimedia"] = {
+        ["alias"] = { "Movie", "Music", "Game" },
+        ["priority"] = 10,
+        ["action.default"] = "mix",
+      }
+      ["Notification"] = {
+        ["priority"] = 20,
+        ["action.default"] = "duck",
+        ["action.Notification"] = "mix",
+      }
+    }
+
+  The above example defines actions for both ``Multimedia`` and ``Notification``
+  roles. Since the Notification role has more priority than the Multimedia
+  role, when a client connects to the Notification endpoint, it will ``duck``
+  the volume of all Multimedia clients. If Multiple Notification clients want
+  to play audio, only the Notifications audio will be mixed.
+
+  Possible values of actions are: ``mix`` (Mixes audio),
+  ``duck`` (Mixes and lowers the audio volume) or ``cork`` (Pauses audio).
+
+* *default_policy.policy.endpoints*
+
+  This is a Lua array with objects defining the endpoints that the user wants
+  to create.
+
+  Example:
+
+  .. code-block:: lua
+
+    default_policy.endpoints = {
+      ["endpoint.multimedia"] = {
+        ["media.class"] = "Audio/Sink",
+        ["role"] = "Multimedia",
+      }
+    },
+    ["endpoint.notifications"] = {
+      ["media.class"] = "Audio/Sink",
+      ["role"] = "Notification",
+    }
+
+  This example creates 2 endpoints, with names ``endpoint.multimedia`` and
+  ``endpoint.notifications``; and assigned roles ``Multimedia`` and ``Notification``
+  respectively. Both endpoints have ``Audio/Sink`` media class, and so are only
+  used for playback.
diff --git a/docs/rst/daemon-configuration.rst b/docs/rst/daemon-configuration.rst
deleted file mode 100644
index fdb76af1..00000000
--- a/docs/rst/daemon-configuration.rst
+++ /dev/null
@@ -1,440 +0,0 @@
- .. _daemon-configuration:
-
-Configuration
-=============
-
-WirePlumber is a heavily modular daemon. By itself, it doesn't do anything
-except load the configured modules. All the rest of the logic is implemented
-inside those modules.
-
-Modular design ensures that it is possible to swap the implementation of
-specific functionality without having to re-implement the rest of it, allowing
-flexibility on target-sensitive parts, such as policy management and
-making use of non-standard hardware.
-
-*wireplumber.conf*
-------------------
-
-This is WirePlumber's main configuration file. It is read at startup, before
-connecting to the PipeWire daemon. Its purpose is to list all the modules
-that need to be loaded by WirePlumber.
-
-The format of this file is custom and resembles a script with commands::
-
-  *comment*
-  command parameter1 parameter2 ...
-
-Lines are executed in the order they appear and each of them executes an
-action defined by the command. Lines starting with *#* are treated as comments
-and ignored. Possible commands are
-
-* *add-spa-lib*
-
-  Associates SPA plugin names with the names of the SPA modules that they
-  can be loaded from. This takes 2 parameters - a name pattern and a library name.
-
-  This actually does not load the SPA plugin, it only calls *pw_core_add_spa_lib*
-  with the 2 paramteres given as arguments. As a consequence, it is safe to
-  call this even if the SPA module is not actually installed on the system.
-
-  Example:
-  ::
-
-    add-spa-lib api.alsa.* alsa/libspa-alsa
-
-  In this example, we let *libpipewire* know that any SPA plugin whose name
-  starts with *api.alsa.* can be loaded from the SPA module
-  *alsa/libspa-alsa.so* (relative to the standard SPA modules directory).
-
-* *load-pipewire-module*
-
-  Loads a *libpipewire* module. This is similar to the *load-module* commands
-  that would appear on *pipewire.conf*, the configuration file of the PipeWire
-  daemon.
-
-  This takes at least 1 parameter, the module name, and optionally any module
-  arguments, in the format that they would be given in *pipewire.conf*
-
-  Format:
-  ::
-  
-    load-pipewire-module module-name some-argument some-property=value
-  
-  Example:
-  ::
-
-    load-pipewire-module libpipewire-module-client-device
-
-  This command does not affect the PipeWire daemon by any means. It exists
-  simply to allow loading *libpipewire* modules in the pipewire core that
-  runs inside WirePlumber. This is usually useful to load pipewire protocol
-  extensions, so that you can export custom objects to PipeWire and other
-  clients.
-
-* *load-module*
-
-  Loads a WirePlumber module. This takes 2 arguments and an optional parameter
-  block.
-
-  Format:
-  ::
-
-    load-module ABI module-name {
-    "parameter" : <"value">
-    }
-
-  The *ABI* parameter specifies the binary interface that WirePlumber shall use
-  to load this module. Currently, the only supported ABI is *C*. It exists to
-  allow future expansion, writing modules in other languages.
-
-  The *module-name* should be the name of the *.so* file without the *.so*
-  extension.
-
-  Optionally, if the `load-module` line ends with a `{`, the next lines up to
-  and including the next matching `}` are treated as a parameter block.
-  This block essentially is a
-  `GVariant <https://developer.gnome.org/glib/stable/glib-GVariant.html>`_
-  of type
-  `GVariant format strings <https://developer.gnome.org/glib/stable/gvariant-format-strings.html>`_
-  in the
-  `GVariant Text Format <https://developer.gnome.org/glib/stable/gvariant-text.html>`_.
-  As a rule of thumb, parameter names in this block must always be strings
-  enclosed in double quotes, the separation between names and values is done
-  with the `:` character and values, regardless of their inner type, must always
-  be enclosed in `<` `>`.
-
-  Note that starting the parameter block on the next line is an error. The
-  starting brace (`{`) must always be on the `load-module` line.
-
-  Example:
-  ::
-
-    load-module C libwireplumber-module-monitor {
-      "alsa": <{"factory": <"api.alsa.enum.udev">, "flags": <["use-adapter"]>}>
-    }
-
-  Parameters are module-dependent. They are passed as a GVariant in the
-  module's initialization function and it is up to the module to interpret
-  their meaning. WirePlumber does not have any reserved parameters.
-
-Location of configuration files
--------------------------------
-
-WirePlumber's default locations of its configuration files are determined at
-compile time by the build system. Typically, those end up being
-`XDG_CONFIG_DIR/wireplumber`, `/etc/wireplumber`, and
-`/usr/share/wireplumber`, in that order of priority.
-
-In more detail, the latter two are controlled by the `--sysconfdir` and `--datadir`
-meson options. When those are set to an absolute path, such as `/etc`, the
-location of the configuration files is set to be `$sysconfdir/wireplumber`.
-When set to a relative path, such as `etc`, then the installation prefix (`--prefix`)
-is prepended to the path: `$prefix/$sysconfdir/wireplumber`
-
-The three locations are intended for custom user configuration,
-host-specific configuration and distribution-provided configuration,
-respectively. At runtime, WirePlumber will search the directories
-for the highest-priority directory to contain the `wireplumber.conf`
-configuration file. This allows a user or system administrator to easily
-override the distribution provided configuration files by placing an equally
-named file in the respective directory.
-
-It is possible to override the lookup path at runtime by passing the
-`--config-file` or `-c` option::
-
-  wireplumber --config-file=src/config/wireplumber.conf
-
-It is also possible to override the whole configuration directory, so that
-all other configuration files are being read from a different location as well,
-by setting the `WIREPLUMBER_CONFIG_DIR` environment variable::
-
-  WIREPLUMBER_CONFIG_DIR=src/config wireplumber
-
-If `WIREPLUMBER_CONFIG_DIR` is set, the default locations are ignored.
-
-Location of modules
--------------------
-
-WirePlumber modules
-^^^^^^^^^^^^^^^^^^^
-
-Like with configuration files, WirePlumber's default location of its modules is
-determined at compile time by the build system. Typically, it ends up being
-`/usr/lib/wireplumber-0.1` (or `/usr/lib/<arch-triplet>/wireplumber-0.1` on
-multiarch systems)
-
-In more detail, this is controlled by the `--libdir` meson option. When
-this is set to an absolute path, such as `/lib`, the location of the
-modules is set to be `$libdir/wireplumber-$abi_version`. When this is set
-to a relative path, such as `lib`, then the installation prefix (`--prefix`)
-is prepended to the path\: `$prefix/$libdir/wireplumber-$abi_version`.
-
-It is possible to override this directory at runtime by setting the
-`WIREPLUMBER_MODULE_DIR` environment variable::
-
-  WIREPLUMBER_MODULE_DIR=build/modules wireplumber
-
-PipeWire and SPA modules
-^^^^^^^^^^^^^^^^^^^^^^^^
-
-PipeWire and SPA modules are not loaded from the same location as WirePlumber's
-modules. They are loaded from the location that PipeWire loads them.
-
-It is also possible to override these locations by using environment variables:
-`SPA_PLUGIN_DIR` and `PIPEWIRE_MODULE_DIR`. For more details, refer to
-PipeWire's documentation.
-
-module-monitor
-""""""""""""""
-
-This module internally loads a SPA "device" object which enumerates all the
-devices of a certain subsystem. Then it listens for "node" objects that are
-being created by this device and exports them to PipeWire, after adjusting
-their properties to provide enough context.
-
-`module-monitor` does not read any configuration files, however, it supports
-configuration through parameters defined in the main `wireplumber.conf`.
-
-At the top level, each parameter is creating a monitor instance. The paramter
-key is considered to be a friendly name for this instance and can be any string.
-The value of each such parameter is meant to be a dictionary with parameters
-for this instance. Possible instance parameters are
-
-* `factory`
-
-  A string that specifies the name of the SPA factory that loads the intial
-  "device" object.
-
-  Well-known factories are
-
-  * "api.alsa.enum.udev" - Discovers ALSA devices via udev
-  * "api.v4l2.enum.udev" - Discovers V4L2 devices via udev
-  * "api.bluez5.enum.dbus" - Discovers bluetooth devices by calling bluez5 API via D-Bus
-
- * `flags`
-
-    An array of strings that enable specific functionality in the monitor.
-    Possible flags include
-
-    * "use-adapter"
-
-      Instructs the monitor to wrap all the created nodes in an "adapter"
-      SPA node, which provides automatic port splitting/merging and format/rate
-      conversion. This should be always enabled for audio device nodes.
-
-    * "local-nodes"
-
-      Instructs the monitor to run all the created nodes locally in in the
-      WirePlumber process, instead of the default behavior which is to create
-      the nodes in the PipeWire process. This is useful for bluetooth nodes,
-      which should run outside of the main PipeWire process for performance
-      reasons.
-
-    * "activate-devices"
-
-      Instructs the monitor to automatically set the device profile to "On",
-      so that the nodes are created. If not specified, the profile must be
-      set externally by the user before any nodes appear.
-
-module-config-endpoint
-""""""""""""""""""""""
-
-This module creates endpoints when WirePlumber detects new nodes in the
-pipewire graph. Nodes themselves can be created in two ways.
-Device modes are being created by "monitors" that watch a specific subsystem
-(udev, bluez, etc...) for devices. Client nodes are being created by client
-applications that try to stream to/from pipewire. As soon as a node is created,
-the `module-config-endpoint` iterates through all the `.endpoint` configuration
-files, in the order that is determined by the filename, and tries to match the
-node to the node description in the `[match-node]` table. Upon a successful
-match, a new endpoint that follows the description in the `[endpoint]` table is
-created.
-
-`*.endpoint` configuration files
-""""""""""""""""""""""""""""""""
-
-These files are TOML v0.5 files. At the top-level, they must contain exactly
-2 tables: `[match-node]` and `[endpoint]`
-
-The `[match-node]` table contains properties that match a pipewire node that
-exists on the graph. Possible fields of this table are
-
-* `properties`
-
-  This is a TOML array of tables, where each table must contain two fields:
-  `name` and `value`, both being strings. Each table describes a match against
-  one of the pipewire properties of the node. For a successful node match, all
-  the described properties must match with the node.
-
-  The value of the `name` field must match exactly the name of the pipewire
-  property, while the value of the `value` field can contain '*' (wildcard)
-  and '?' (joker), adhering to the rules of the
-  [GLib g_pattern_match() function](https://developer.gnome.org/glib/stable/glib-Glob-style-pattern-matching.html).
-
-  When writing `.endpoint` files, a useful utility that you can use to list
-  device node properties is::
-
-    $ wireplumber-cli device-node-props
-
-  Another way to figure out some of these properties *for ALSA nodes* is
-  by parsing the aplay/arecord output. For example, this line from `aplay -l`
-  is interpreted as follows::
-
-    card 0: PCH [HDA Intel PCH], device 2: ALC3246 [ALC3246 Analog]
-
-    { name = "api.alsa.path", value = "hw:0,2" },
-    { name = "api.alsa.card", value = "0" },
-    { name = "api.alsa.card.id", value = "PCH" },
-    { name = "api.alsa.card.name", value = "HDA Intel PCH" },
-    { name = "api.alsa.pcm.device", value = "2" },
-    { name = "api.alsa.pcm.id", value = "ALC3246" },
-    { name = "api.alsa.pcm.name", value = "ALC3246 Analog" }
-
-  The `[endpoint]` table contains a description of the endpoint to be created.
-  Possible fields of this table are
-
-* `session`
-
-  Required. A String representing the session name to be used when exporting the
-  endpoint.
-
-* `type`
-
-  Required. Specifies the factory to be used for construction.
-  The only well-known factories at the moment of writing is `si-adapter` and
-  `si-simple-node-edpoint`.
-
-* `streams`
-
-  Optional. Specifies the name of a `.streams` file that contains the
-  descriptions of the streams to create for this endpoint. This currently
-  specific to the implementation of the `pw-audio-softdsp-endpoint` and might
-  change in the future.
-
-* `config`
-
-  Optional. Specifies the configuration table used to configure the endpoint.
-  This table can have the following entries
-
-    * `name`
-
-      Optional. The name of the newly created endpoint. If not specified, the
-      endpoint is named after the node (from the `node.name` property of the
-      node).
-
-    * `media_class`
-
-      Optional. A string that specifies an override for the `media.class`
-      property of the node. It can be used in special circumstances to declare
-      that an endpoint is dealing with a different type of data. This is only
-      useful in combination with a policy implementation that is aware of this
-      media class.
-
-    * `role`
-
-      Optional. A string representing the role of the endpoint.
-
-    * `priority`
-
-      Optional. An unsigned integer that specifies the order in which endpoints
-      are chosen by the policy.
-
-      If not specified, the default priority of an endpoint is equal to zero
-      (i.e. the lowest priority).
-
-    * `enable-control-port`
-
-      Optional. A boolean representing whether the control port should be
-      enabled on the endpoint or not.
-
-    * `enable-monitor`
-
-      Optional. A boolean representing whether the monitor ports should be
-      enabled on the endpoint or not.
-
-    * `preferred-n-channels`
-
-      Optional. An unsigned integer that specifies a preference in the number
-      of audio channels that an audio node should be configured with. Note that
-      if the node does not support this many channels, it will be configured
-      with the closest possible number of channels. This is only available
-      with the `si-adapter` factory.
-
-`*.streams` configuration file
-""""""""""""""""""""""""""""""
-
-These files contain lists of streams with their names and priorities.
-They are TOML v0.5 files.
-
-Each `.streams` file must contain exactly one top-level array of tables,
-called `streams`. Every table must contain a mandatory `name` field, and 2
-optional fields: `priority` and `enable_control_port`.
-
-The `name` of each stream is used to create the streams on new endpoints.
-
-The `priority` of each stream is being interpreted by the policy module to
-apply restrictions on which app can use the stream at a given time.
-
-The `enable_control_port` is used to enable the control port of the stream.
-
-module-config-policy
-""""""""""""""""""""
-
-This module implements demo-quality policy management that is partly driven
-by configuration files. The configuration files that this module reads are
-described below:
-
-`*.endpoint-link`
-"""""""""""""""""
-
-These files contain rules to link endpoints with each other.
-They are TOML v0.5 files.
-
-Endpoints are normally created by another module, such
-as `module-config-endpoint` which is described above.
-As soon as an endpoint is created, the `module-config-policy` uses the
-information gathered from the `.endpoint-link` files in order to create a
-link to another endpoint.
-
-`.endpoint-link` files can contain 3 top-level tables
-* `[match-endpoint]`, required
-* `[target-endpoint]`, optional
-
-The `[match-endpoint]` table contains properties that match an endpoint that
-exists on the graph. Possible fields of this table are
-
-* `name`
-
-  Optional. The name of the endpoint. It is possible to use wildcards here to
-  match only parts of the name.
-
-* `media_class`
-
-  Optional. A string that specifies the `media.class` that the endpoint
-  must have in order to match.
-
-* `properties`
-
-  This is a TOML array of tables, where each table must contain two fields:
-  `name` and `value`, both being strings. Each table describes a match against
-  one of the pipewire properties of the endpoint. For a successful endpoint
-  match, all the described properties must match with the endpoint.
-
-The `[target-endpoint]` table contains properties that match an endpoint that
-exists on the graph. The purpose of this table is to match a second endpoint
-that the original matching endpoint from `[match-endpoint]` will be linked to.
-If not specified, `module-config-policy` will look for the session "default"
-endpoint for the type of media that the matching endpoint produces or consumes
-and will use that as a target. Possible fields of this table are
-
-* `name`, `media_class`, `properties`
-
-  All these fields are permitted and behave exactly as described above for the
-  `[match-endpoint]` table.
-
-* `stream`
-
-  This field specifies a stream name that the link will use on the target
-  endpoint. If it is not specified, the stream name is acquired from the
-  `media.role` property of the matching endpoint. If specified, the value of
-  this field overrides the `media.role`.
diff --git a/docs/rst/daemon-logging.rst b/docs/rst/daemon-logging.rst
index fe01d974..76f1a496 100644
--- a/docs/rst/daemon-logging.rst
+++ b/docs/rst/daemon-logging.rst
@@ -10,7 +10,8 @@ Getting debug messages on the command line is a matter of setting the
 
    WIREPLUMBER_DEBUG=level:category1,category2,...
 
-``level`` can be a number from 1 to 5 and defines the minimum debug level to show:
+``level`` can be one of ``CEWMIDT`` or a numerical log level as listed below.
+In either case it defines the minimum debug level to show:
 
   0. critical warnings and fatal errors (``C`` & ``E`` in the log)
   1. warnings (``W``)
@@ -50,13 +51,13 @@ Show all messages:
 
 .. code::
 
-   WIREPLUMBER_DEBUG=5
+   WIREPLUMBER_DEBUG=D
 
 Show all messages up to the *debug* level (E, C, W, M, I & D), excluding *trace*:
 
 .. code::
 
-   WIREPLUMBER_DEBUG=4
+   WIREPLUMBER_DEBUG=M
 
 Show all messages up to the *message* level (E, C, W & M),
 excluding *info*, *debug* & *trace*
@@ -70,13 +71,13 @@ Show all messages from the wireplumber library:
 
 .. code::
 
-   WIREPLUMBER_DEBUG=5:wp-*
+   WIREPLUMBER_DEBUG=D:wp-*
 
 Show all messages from ``wp-registry``, libpipewire and all modules:
 
 .. code::
 
-   WIREPLUMBER_DEBUG=5:wp-registry,pw,m-*
+   WIREPLUMBER_DEBUG=D:wp-registry,pw,m-*
 
 Relationship with the GLib log handler & G_MESSAGES_DEBUG
 ---------------------------------------------------------
diff --git a/docs/rst/index.rst b/docs/rst/index.rst
index 874f4d58..615a9abb 100644
--- a/docs/rst/index.rst
+++ b/docs/rst/index.rst
@@ -9,7 +9,7 @@ Table of Contents
 
    installing-wireplumber.rst
    running-wireplumber-daemon.rst
-   daemon-configuration.rst
+   configuration.rst
    daemon-logging.rst
 
 .. toctree::
diff --git a/docs/rst/installing-wireplumber.rst b/docs/rst/installing-wireplumber.rst
index 55ca48dd..5fb76603 100644
--- a/docs/rst/installing-wireplumber.rst
+++ b/docs/rst/installing-wireplumber.rst
@@ -8,9 +8,9 @@ Dependencies
 
 In order to compile WirePlumber you will need:
 
-* GLib >= 2.58
-* PipeWire 0.3 (>= 0.3.26)
-* Lua 5.3
+* GLib >= 2.62
+* PipeWire 0.3 (>= 0.3.43)
+* Lua 5.3 or 5.4
 
 Lua is optional in the sense that if it is not found in the system, a bundled
 version will be built and linked statically with WirePlumber. This is controlled
diff --git a/docs/rst/lua_api/lua_introduction.rst b/docs/rst/lua_api/lua_introduction.rst
index 606cc816..44b749b4 100644
--- a/docs/rst/lua_api/lua_introduction.rst
+++ b/docs/rst/lua_api/lua_introduction.rst
@@ -6,15 +6,13 @@ Introduction
 `Lua <https://www.lua.org/>`_ is a powerful, efficient, lightweight,
 embeddable scripting language.
 
-WirePlumber uses `Lua version 5.3 <https://www.lua.org/versions.html>`_ to
-implement its engine. Another, more recent, version may be considered
-in the future, but do note that different Lua versions are not API-compatible
-and that will likely also affect WirePlumber's Lua API.
+WirePlumber uses `Lua version 5.4 <https://www.lua.org/versions.html>`_ to
+implement its engine. For older systems, Lua 5.3 is also supported.
 
 There are currently two uses for Lua in WirePlumber:
 
   - To implement the scripting engine
-  - To implement lua-based :ref:`config files <daemon-configuration>`
+  - To implement lua-based :ref:`config files <config_lua>`
 
 This section is only documenting the API of the **scripting engine**
 
@@ -22,7 +20,7 @@ Lua Reference
 -------------
 
 If you are not familiar with the Lua language and its API, please refer to
-the `Lua 5.3 Reference Manual <https://www.lua.org/manual/5.3/manual.html>`_
+the `Lua 5.4 Reference Manual <https://www.lua.org/manual/5.4/manual.html>`_
 
 Sandbox
 -------
diff --git a/docs/rst/meson.build b/docs/rst/meson.build
index 5509d878..cf9a25b2 100644
--- a/docs/rst/meson.build
+++ b/docs/rst/meson.build
@@ -3,7 +3,7 @@ sphinx_files += files(
   'index.rst',
   'installing-wireplumber.rst',
   'running-wireplumber-daemon.rst',
-  'daemon-configuration.rst',
+  'configuration.rst',
   'daemon-logging.rst',
   'contributing.rst',
   'community.rst',
@@ -15,3 +15,4 @@ sphinx_files += files(
 
 subdir('c_api')
 subdir('lua_api')
+subdir('configuration')
diff --git a/docs/rst/running-wireplumber-daemon.rst b/docs/rst/running-wireplumber-daemon.rst
index 2d5dde7f..db500a15 100644
--- a/docs/rst/running-wireplumber-daemon.rst
+++ b/docs/rst/running-wireplumber-daemon.rst
@@ -3,11 +3,92 @@
 Running the WirePlumber daemon
 ==============================
 
+Systemd
+-------
+
+WirePlumber comes with a systemd unit, ``wireplumber.service``, which should
+be enabled on your user session:
+
+.. code:: console
+
+   $ systemctl --user --now enable wireplumber
+
+.. note::
+
+   On non-systemd systems, you just need to ensure that wireplumber is started
+   after pipewire.
+
+Run from the PipeWire source tree
+---------------------------------
+
+PipeWire's build system comes with an option to build WirePlumber together
+with PipeWire and allows executing them together without installing either of
+them.
+
+To make this work, configure PipeWire with the
+``-Dsession-managers="[ 'wireplumber' ]"`` option on the meson command line.
+
+When compiling PipeWire, the build system will now also clone and compile
+WirePlumber as a subproject.
+
+To execute the whole stack without installing, simply execute ``make run``
+after compiling.
+
+Synopsis:
+
+.. code:: console
+
+   $ meson -Dsession-managers="[ 'wireplumber' ]" build
+   $ ninja -C build
+   $ make run
+
+Run independently or without installing
+---------------------------------------
+
+If you wish to debug WirePlumber, it may be useful to run it separately from
+PipeWire or run it directly from the source tree without installing.
+To do so:
+
+  1. Ensure that neither *WirePlumber* nor *pipewire-media-session*
+     are running or started together with PipeWire
+
+     - If any of those is started by systemd,
+
+       - Stop the relevant systemd service, ``wireplumber.service``
+         or ``pipewire-media-session.service``
+       - Disable that service as well if you intend to restart PipeWire
+         (so that the session manager is not restarted with it)
+
+     - If any of those is started from pipewire.conf,
+
+       - Kill it, in order to stop it temporarily: ``killall wireplumber``
+         or ``killall pipewire-media-session``
+       - Comment out with ``#`` the relevant ``{ path = "..."  args = "" }``
+         line from the ``context.exec`` section in ``pipewire.conf``,
+         if you intend to restart PipeWire
+
+  2. Ensure that PipeWire is running
+
+  3. Without stopping PipeWire, run WirePlumber.
+
+     - if it is installed, execute ``wireplumber``
+     - if it is **not** installed, execute ``make run`` in the source tree,
+       or use the ``wp-uninstalled.sh`` script:
+
+       .. code:: console
+
+          $ ./wp-uninstalled.sh wireplumber
+
 Replacing pipewire-media-session
 --------------------------------
 
-PipeWire 0.3 comes with an example session manager (pipewire-media-session)
-that you will need to disable and replace with WirePlumber.
+Older versions of PipeWire used to be distributed with an example session
+manager (pipewire-media-session) that you needed to disable and replace with
+WirePlumber.
+
+.. warning::
+
+  These instructions are only relevant to older versions of PipeWire
 
 systemd
 ^^^^^^^
@@ -69,40 +150,3 @@ and change the appropriate line in the ``exec`` section:
         # but it is better to start it as a systemd service.
 
 This setup assumes that WirePlumber is *installed* on the target system.
-
-Run independently or without installing
----------------------------------------
-
-If you wish to debug WirePlumber, it may be useful to run it separately from
-PipeWire or run it directly from the source tree without installing.
-To do so:
-
-  1. Ensure that neither *WirePlumber* nor *pipewire-media-session*
-     are running or started together with PipeWire
-
-     - If any of those is started by systemd,
-
-       - Stop the relevant systemd service, ``wireplumber.service``
-         or ``pipewire-media-session.service``
-       - Disable that service as well if you intend to restart PipeWire
-         (so that the session manager is not restarted with it)
-
-     - If any of those is started from pipewire.conf,
-
-       - Kill it, in order to stop it temporarily: ``killall wireplumber``
-         or ``killall pipewire-media-session``
-       - Comment out with ``#`` the relevant ``{ path = "..."  args = "" }``
-         line from the ``exec`` section in ``pipewire.conf``,
-         if you intend to restart PipeWire
-
-  2. Ensure that PipeWire is running
-
-  3. Without stopping PipeWire, run WirePlumber.
-
-     - if it is installed, execute ``wireplumber``
-     - if it is **not** installed, execute ``make run`` in the source tree,
-       or use the ``wp-uninstalled.sh`` script:
-
-       .. code:: console
-
-          $ ./wp-uninstalled.sh wireplumber
diff --git a/lib/wp/core.c b/lib/wp/core.c
index 27467067..23f6f35f 100644
--- a/lib/wp/core.c
+++ b/lib/wp/core.c
@@ -881,13 +881,62 @@ wp_core_timeout_add_closure (WpCore * self, GSource **source, guint timeout_ms,
 gboolean
 wp_core_sync (WpCore * self, GCancellable * cancellable,
     GAsyncReadyCallback callback, gpointer user_data)
+{
+  return wp_core_sync_closure (self, cancellable,
+      g_cclosure_new (G_CALLBACK (callback), user_data, NULL));
+}
+
+static void
+invoke_closure (GObject * obj, GAsyncResult * res, gpointer data)
+{
+  GClosure *closure = data;
+  GValue values[2] = { G_VALUE_INIT, G_VALUE_INIT };
+  g_value_init (&values[0], G_TYPE_OBJECT);
+  g_value_init (&values[1], G_TYPE_OBJECT);
+  g_value_set_object (&values[0], obj);
+  g_value_set_object (&values[1], res);
+  g_closure_invoke (closure, NULL, 2, values, NULL);
+  g_value_unset (&values[0]);
+  g_value_unset (&values[1]);
+  g_closure_unref (closure);
+}
+
+/*!
+ * \brief Asks the PipeWire server to invoke the \a closure via an event.
+ *
+ * Since methods are handled in-order and events are delivered
+ * in-order, this can be used as a barrier to ensure all previous
+ * methods and the resulting events have been handled.
+ *
+ * In both success and error cases, \a closure is always invoked.
+ * Use wp_core_sync_finish() from within the \a closure to determine whether
+ * the operation completed successfully or if an error occurred.
+ *
+ * \ingroup wpcore
+ * \since 0.4.6
+ * \param self the core
+ * \param cancellable (nullable): a GCancellable to cancel the operation
+ * \param closure (transfer floating): a closure to invoke when the operation
+ *    is done
+ * \returns TRUE if the sync operation was started, FALSE if an error
+ *   occurred before returning from this function
+ */
+gboolean
+wp_core_sync_closure (WpCore * self, GCancellable * cancellable,
+    GClosure * closure)
 {
   g_autoptr (GTask) task = NULL;
   int seq;
 
   g_return_val_if_fail (WP_IS_CORE (self), FALSE);
+  g_return_val_if_fail (closure, FALSE);
+
+  closure = g_closure_ref (closure);
+  g_closure_sink (closure);
+  if (G_CLOSURE_NEEDS_MARSHAL (closure))
+    g_closure_set_marshal (closure, g_cclosure_marshal_VOID__OBJECT);
 
-  task = g_task_new (self, cancellable, callback, user_data);
+  task = g_task_new (self, cancellable, invoke_closure, closure);
 
   if (G_UNLIKELY (!self->pw_core)) {
     g_warn_if_reached ();
diff --git a/lib/wp/core.h b/lib/wp/core.h
index 903b54a3..07a6125b 100644
--- a/lib/wp/core.h
+++ b/lib/wp/core.h
@@ -107,6 +107,10 @@ WP_API
 gboolean wp_core_sync (WpCore * self, GCancellable * cancellable,
     GAsyncReadyCallback callback, gpointer user_data);
 
+WP_API
+gboolean wp_core_sync_closure (WpCore * self, GCancellable * cancellable,
+    GClosure * closure);
+
 WP_API
 gboolean wp_core_sync_finish (WpCore * self, GAsyncResult * res,
     GError ** error);
diff --git a/lib/wp/device.c b/lib/wp/device.c
index 9a0b995f..680e4371 100644
--- a/lib/wp/device.c
+++ b/lib/wp/device.c
@@ -414,7 +414,6 @@ wp_spa_device_activate_execute_step (WpObject * object,
     struct pw_core *pw_core = wp_core_get_pw_core (core);
     g_return_if_fail (pw_core);
 
-    wp_proxy_watch_bind_error (WP_PROXY (self), WP_TRANSITION (transition));
     wp_proxy_set_pw_proxy (WP_PROXY (self),
         pw_core_export (pw_core, SPA_TYPE_INTERFACE_Device,
             wp_properties_peek_dict (self->properties),
@@ -507,7 +506,7 @@ wp_spa_device_deactivate (WpObject * object, WpObjectFeatures features)
  * Flags: G_SIGNAL_RUN_FIRST
  * \endparblock
  *
- * \par remove-object
+ * \par object-removed
  * \parblock
  * \code
  * void
diff --git a/lib/wp/endpoint.c b/lib/wp/endpoint.c
index cf39fe1e..07ab47c6 100644
--- a/lib/wp/endpoint.c
+++ b/lib/wp/endpoint.c
@@ -559,7 +559,6 @@ wp_impl_endpoint_activate_execute_step (WpObject * object,
     }
 
     /* bind */
-    wp_proxy_watch_bind_error (WP_PROXY (self), WP_TRANSITION (transition));
     wp_proxy_set_pw_proxy (WP_PROXY (self), pw_core_export (pw_core,
             PW_TYPE_INTERFACE_Endpoint,
             wp_properties_peek_dict (self->immutable_props),
diff --git a/lib/wp/global-proxy.c b/lib/wp/global-proxy.c
index 33ae03d9..e32a75d1 100644
--- a/lib/wp/global-proxy.c
+++ b/lib/wp/global-proxy.c
@@ -162,8 +162,6 @@ wp_global_proxy_step_bind (WpObject * object,
   WpGlobalProxy *self = WP_GLOBAL_PROXY (object);
   WpGlobalProxyPrivate *priv = wp_global_proxy_get_instance_private (self);
 
-  wp_proxy_watch_bind_error (WP_PROXY (self), WP_TRANSITION (transition));
-
   /* Create the pipewire object if global is NULL */
   if (priv->global == NULL && priv->factory_name[0] != '\0') {
     g_autoptr (WpCore) core = NULL;
@@ -390,6 +388,16 @@ wp_global_proxy_bind (WpGlobalProxy * self)
   if (!priv->global || !priv->global->proxy)
     return FALSE;
 
+  /*
+   * We can bind only if the WpGlobal will unbind this proxy on removal, so
+   * assert here that this is so. Why: pw_registry_bind can race with the global
+   * id being replaced with a different object on server side, so we must rely
+   * on the registry_global messages to know if the object was replaced.  If the
+   * race occurs, and a wrong object is going to be bound here, what will then
+   * happen is that WpGlobal will dispose this proxy and no problem arises.
+   */
+  g_return_val_if_fail (priv->global->proxy == self, FALSE);
+
   p = wp_global_bind (priv->global);
   if (!p)
     return FALSE;
diff --git a/lib/wp/iterator.c b/lib/wp/iterator.c
index 6cbbd12d..f86f72d7 100644
--- a/lib/wp/iterator.c
+++ b/lib/wp/iterator.c
@@ -96,7 +96,6 @@ wp_iterator_new (const WpIteratorMethods *methods, size_t user_size)
  * \brief Gets the implementation-specific storage of an iterator
  * \note this only for use by implementations of WpIterator
  *
- * \protected
  * \ingroup wpiterator
  * \param self an iterator object
  * \returns a pointer to the implementation-specific storage area
@@ -174,7 +173,7 @@ wp_iterator_next (WpIterator *self, GValue *item)
 }
 
 /*!
- * \brief Iterates over all items of the iterator calling a function.
+ * \brief Fold a function over the items of the iterator.
  *
  * \ingroup wpiterator
  * \param self the iterator
@@ -196,7 +195,7 @@ wp_iterator_fold (WpIterator *self, WpIteratorFoldFunc func, GValue *ret,
 }
 
 /*!
- * \brief Fold a function over the items of the iterator.
+ * \brief Iterates over all items of the iterator calling a function.
  *
  * \ingroup wpiterator
  * \param self the iterator
diff --git a/lib/wp/log.c b/lib/wp/log.c
index 4b9a1509..96d192db 100644
--- a/lib/wp/log.c
+++ b/lib/wp/log.c
@@ -379,6 +379,19 @@ wp_log_level_is_enabled (GLogLevelFlags log_level)
   return log_level_index (log_level) <= enabled_level;
 }
 
+static gint
+level_index_from_string (const char *str)
+{
+  g_return_val_if_fail (str != NULL, 0);
+
+  for (guint i = 0; i < G_N_ELEMENTS (log_level_info); i++) {
+    if (g_str_equal (str, log_level_info[i].name))
+      return i;
+  }
+
+  return level_index_from_spa (atoi (str));
+}
+
 /*!
  * \brief Configures the log level and enabled categories
  * \ingroup wplog
@@ -406,7 +419,7 @@ wp_log_set_level (const gchar * level_str)
     tokens = pw_split_strv (level_str, ":", 2, &n_tokens);
 
     /* set the log level */
-    enabled_level = level_index_from_spa (atoi (tokens[0]));
+    enabled_level = level_index_from_string (tokens[0]);
 
     /* enable filtering of debug categories */
     if (n_tokens > 1) {
@@ -496,8 +509,11 @@ wp_log_writer_default (GLogLevelFlags log_level,
   if (!is_category_enabled(cf.log_domain))
     return G_LOG_WRITER_UNHANDLED;
 
+  if (G_UNLIKELY (!cf.message))
+    cf.message_field->value = cf.message = "(null)";
+
   /* format the message to include the object */
-  if (cf.object_type && cf.message) {
+  if (cf.object_type) {
     cf.message_field->value = cf.message = full_message =
         format_message (&cf);
   }
diff --git a/lib/wp/metadata.c b/lib/wp/metadata.c
index 41095875..fc1dd7bb 100644
--- a/lib/wp/metadata.c
+++ b/lib/wp/metadata.c
@@ -35,7 +35,7 @@
  * \code
  * void
  * changed_callback (WpMetadata * self,
- *                   guint object,
+ *                   guint subject,
  *                   gchar * key,
  *                   gchar * type,
  *                   gchar * value,
@@ -472,6 +472,7 @@ wp_metadata_find (WpMetadata * self, guint32 subject, const gchar * key,
     if (g_strcmp0 (k, key) == 0) {
       if (type)
         *type = t;
+      g_value_unset (&val);
       return v;
     }
   }
@@ -663,7 +664,6 @@ wp_impl_metadata_activate_execute_step (WpObject * object,
       prop_impl = SPA_DICT_INIT_ARRAY(items);
       props = &prop_impl;
     }
-    wp_proxy_watch_bind_error (WP_PROXY (self), WP_TRANSITION (transition));
     wp_proxy_set_pw_proxy (WP_PROXY (self), pw_core_export (pw_core,
             PW_TYPE_INTERFACE_Metadata, props, priv->iface, 0)
     );
diff --git a/lib/wp/node.c b/lib/wp/node.c
index e052596f..03163948 100644
--- a/lib/wp/node.c
+++ b/lib/wp/node.c
@@ -763,7 +763,6 @@ wp_impl_node_activate_execute_step (WpObject * object,
     struct pw_core *pw_core = wp_core_get_pw_core (core);
     g_return_if_fail (pw_core);
 
-    wp_proxy_watch_bind_error (WP_PROXY (self), WP_TRANSITION (transition));
     wp_proxy_set_pw_proxy (WP_PROXY (self),
         pw_core_export (pw_core, PW_TYPE_INTERFACE_Node, NULL,
             self->pw_impl_node, 0));
diff --git a/lib/wp/object-manager.c b/lib/wp/object-manager.c
index 0f859582..bcb34533 100644
--- a/lib/wp/object-manager.c
+++ b/lib/wp/object-manager.c
@@ -46,6 +46,11 @@
  * that match the interests of this WpObjectManager will immediately become
  * available to get through wp_object_manager_new_iterator() and the
  * WpObjectManager \c object-added signal will be emitted for all of them.
+ * However, note that if these objects need to be prepared (to activate some
+ * features on them), the emission of \c object-added will be delayed. To know
+ * when it is safe to access the initial set of objects, wait until the
+ * \c installed signal has been emitted. That signal is emitted asynchronously
+ * after all the initial objects have been prepared.
  *
  * \gproperties
  *
@@ -106,11 +111,11 @@
  * Flags: G_SIGNAL_RUN_FIRST
  * \endparblock
  *
- * \par object-changed
+ * \par objects-changed
  * \parblock
  * \code
  * void
- * object_changed_callback (WpObjectManager * self,
+ * objects_changed_callback (WpObjectManager * self,
  *                          gpointer user_data)
  * \endcode
  *
@@ -1094,7 +1099,7 @@ expose_tmp_globals (WpCore *core, GAsyncResult *res, WpRegistry *self)
       WpGlobal *g = g_ptr_array_index (tmp_globals, i);
 
       /* if global was already removed, drop it */
-      if (g->flags == 0)
+      if (g->flags == 0 || g->id == SPA_ID_INVALID)
         continue;
 
       wp_object_manager_add_global (om, g);
@@ -1347,6 +1352,9 @@ wp_global_rm_flag (WpGlobal *global, guint rm_flag)
       /* remove FEATURE_BOUND to destroy the underlying pw_proxy */
       wp_object_deactivate (WP_OBJECT (proxy), WP_PROXY_FEATURE_BOUND);
 
+      /* stop all in-progress activations */
+      wp_object_abort_activation (WP_OBJECT (proxy), "PipeWire proxy removed");
+
       /* if the proxy is not owning the global, unref it */
       if (global->flags == 0)
         g_object_unref (proxy);
diff --git a/lib/wp/object.c b/lib/wp/object.c
index 3b56d6bf..396fff36 100644
--- a/lib/wp/object.c
+++ b/lib/wp/object.c
@@ -11,6 +11,7 @@
 #include "object.h"
 #include "log.h"
 #include "core.h"
+#include "error.h"
 
 /*! \defgroup wpfeatureactivationtransition WpFeatureActivationTransition */
 /*!
@@ -466,6 +467,45 @@ wp_object_deactivate (WpObject * self, WpObjectFeatures features)
   WP_OBJECT_GET_CLASS (self)->deactivate (self, features & priv->ft_active);
 }
 
+/*!
+ * \brief Aborts the current object activation by returning a transition error
+ * if any transitions are pending.
+ *
+ * This is usually used to stop any pending activation if an error happened.
+ *
+ * \ingroup wpobject
+ * \param self the object
+ * \param msg the message used in the transition error
+ * \since 0.4.6
+ */
+void
+wp_object_abort_activation (WpObject * self, const gchar *msg)
+{
+  WpObjectPrivate *priv;
+  g_autoptr (WpTransition) t = NULL;
+
+  g_return_if_fail (WP_IS_OBJECT (self));
+
+  priv =  wp_object_get_instance_private (self);
+
+  g_clear_pointer (&priv->idle_advnc_source, g_source_unref);
+
+  /* abort ongoing transition if any */
+  t = g_weak_ref_get (&priv->ongoing_transition);
+  if (t)
+    wp_transition_return_error (t, g_error_new (WP_DOMAIN_LIBRARY,
+            WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "Object activation aborted: %s", msg));
+
+  /* abort queued transitions */
+  while (!g_queue_is_empty (priv->transitions)) {
+    WpTransition *next = g_queue_pop_head (priv->transitions);
+    wp_transition_return_error (next, g_error_new (WP_DOMAIN_LIBRARY,
+            WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "Object activation aborted: %s", msg));
+  }
+}
+
 /*!
  * \brief Allows subclasses to update the currently active features.
  *
@@ -475,7 +515,6 @@ wp_object_deactivate (WpObject * self, WpObjectFeatures features)
  *
  * \remark Private method to be called by subclasses only.
  *
- * \protected
  * \ingroup wpobject
  * \param self the object
  * \param activated the features that were activated, or 0
diff --git a/lib/wp/object.h b/lib/wp/object.h
index 18173f0e..21d60660 100644
--- a/lib/wp/object.h
+++ b/lib/wp/object.h
@@ -106,6 +106,9 @@ void wp_object_deactivate (WpObject * self, WpObjectFeatures features);
 
 /* for subclasses only */
 
+WP_API
+void wp_object_abort_activation (WpObject * self, const gchar *msg);
+
 WP_API
 void wp_object_update_features (WpObject * self, WpObjectFeatures activated,
     WpObjectFeatures deactivated);
diff --git a/lib/wp/private/pipewire-object-mixin.c b/lib/wp/private/pipewire-object-mixin.c
index 25dbc957..16041802 100644
--- a/lib/wp/private/pipewire-object-mixin.c
+++ b/lib/wp/private/pipewire-object-mixin.c
@@ -647,8 +647,9 @@ wp_pw_object_mixin_cache_params (WpObject * object, WpObjectFeatures missing)
 
   g_object_set_qdata (G_OBJECT (object),
       activated_features_quark (), GUINT_TO_POINTER (activated));
-  wp_core_sync (core, NULL,
-      (GAsyncReadyCallback) param_cache_features_enabled, object);
+  wp_core_sync_closure (core, NULL, g_cclosure_new_object (
+          G_CALLBACK (param_cache_features_enabled),
+          G_OBJECT (object)));
 }
 
 void
@@ -722,13 +723,14 @@ wp_pw_object_mixin_handle_event_info (gpointer instance, gconstpointer update)
   WpPwObjectMixinData *d = wp_pw_object_mixin_get_data (instance);
   WpPwObjectMixinPrivInterface *iface =
       WP_PW_OBJECT_MIXIN_PRIV_GET_IFACE (instance);
-  guint32 change_mask =
-      G_STRUCT_MEMBER (guint32, update, iface->change_mask_offset);
-  guint32 process_info_change_mask =
+  guint64 change_mask =
+      G_STRUCT_MEMBER (guint64, update, iface->change_mask_offset);
+  guint64 process_info_change_mask =
       change_mask & ~(iface->CHANGE_MASK_PROPS | iface->CHANGE_MASK_PARAMS);
   gpointer old_info = NULL;
 
-  wp_debug_object (instance, "info, change_mask:0x%x [%s%s]", change_mask,
+  wp_debug_object (instance, "info, change_mask:0x%"G_GINT64_MODIFIER"x [%s%s]",
+      change_mask,
       (change_mask & iface->CHANGE_MASK_PROPS) ? "props," : "",
       (change_mask & iface->CHANGE_MASK_PARAMS) ? "params," : "");
 
@@ -736,7 +738,7 @@ wp_pw_object_mixin_handle_event_info (gpointer instance, gconstpointer update)
   if (iface->process_info && d->info && process_info_change_mask) {
     /* copy everything that changed except props and params, for efficiency;
        process_info() is only interested in variables that are not PROPS & PARAMS */
-    G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) =
+    G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) =
         process_info_change_mask;
     old_info = iface->update_info (NULL, d->info);
   }
@@ -772,8 +774,6 @@ wp_pw_object_mixin_handle_event_info (gpointer instance, gconstpointer update)
 
   /* update our info struct */
   d->info = iface->update_info (d->info, update);
-  wp_object_update_features (WP_OBJECT (instance),
-      WP_PIPEWIRE_OBJECT_FEATURE_INFO, 0);
 
   /* update properties */
   if (change_mask & iface->CHANGE_MASK_PROPS) {
@@ -794,6 +794,9 @@ wp_pw_object_mixin_handle_event_info (gpointer instance, gconstpointer update)
     iface->process_info (instance, old_info, d->info);
     g_clear_pointer (&old_info, iface->free_info);
   }
+
+  wp_object_update_features (WP_OBJECT (instance),
+      WP_PIPEWIRE_OBJECT_FEATURE_INFO, 0);
 }
 
 static gint
@@ -844,9 +847,9 @@ wp_pw_object_mixin_impl_add_listener (gpointer instance,
 
   spa_hook_list_isolate (&d->hooks, &save, listener, events, data);
 
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) = iface->CHANGE_MASK_ALL;
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) = iface->CHANGE_MASK_ALL;
   iface->emit_info (&d->hooks, d->info);
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) = 0;
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) = 0;
 
   spa_hook_list_join (&d->hooks, &save);
   return 0;
@@ -947,10 +950,10 @@ wp_pw_object_mixin_notify_info (gpointer instance, guint32 change_mask)
       (change_mask & iface->CHANGE_MASK_PROPS) ? "props," : "",
       (change_mask & iface->CHANGE_MASK_PARAMS) ? "params," : "");
 
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) =
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) =
       (change_mask & iface->CHANGE_MASK_ALL);
   iface->emit_info (&d->hooks, d->info);
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) = 0;
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) = 0;
 
   if (change_mask & iface->CHANGE_MASK_PROPS)
     g_object_notify (G_OBJECT (instance), "properties");
@@ -988,10 +991,10 @@ wp_pw_object_mixin_notify_params_changed (gpointer instance, guint32 id)
   /* toggle the serial flag; this notifies that there is a data change */
   info->flags ^= SPA_PARAM_INFO_SERIAL;
 
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) =
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) =
       iface->CHANGE_MASK_PARAMS;
   iface->emit_info (&d->hooks, d->info);
-  G_STRUCT_MEMBER (guint32, d->info, iface->change_mask_offset) = 0;
+  G_STRUCT_MEMBER (guint64, d->info, iface->change_mask_offset) = 0;
 
   if (subscribed)
     wp_pw_object_mixin_impl_enum_params (instance, 1, id, 0, -1, NULL);
diff --git a/lib/wp/private/pipewire-object-mixin.h b/lib/wp/private/pipewire-object-mixin.h
index 202a5b28..c1761242 100644
--- a/lib/wp/private/pipewire-object-mixin.h
+++ b/lib/wp/private/pipewire-object-mixin.h
@@ -54,9 +54,9 @@ struct _WpPwObjectMixinPrivInterface
   gsize param_info_offset;
   gsize n_params_offset;
 
-  guint32 CHANGE_MASK_ALL;
-  guint32 CHANGE_MASK_PROPS;
-  guint32 CHANGE_MASK_PARAMS;
+  guint64 CHANGE_MASK_ALL;
+  guint64 CHANGE_MASK_PROPS;
+  guint64 CHANGE_MASK_PARAMS;
 
   gpointer (*update_info) (gpointer info, gconstpointer update);
   void (*free_info) (gpointer info);
diff --git a/lib/wp/proxy.c b/lib/wp/proxy.c
index eebffd90..899d81e5 100644
--- a/lib/wp/proxy.c
+++ b/lib/wp/proxy.c
@@ -133,6 +133,9 @@ proxy_event_destroy (void *data)
   spa_hook_remove (&priv->listener);
   priv->pw_proxy = NULL;
   wp_object_update_features (WP_OBJECT (self), 0, WP_PROXY_FEATURE_BOUND);
+
+  wp_object_abort_activation (WP_OBJECT (self), "PipeWire proxy destroyed");
+
   g_signal_emit (self, signals[SIGNAL_PW_PROXY_DESTROYED], 0);
 }
 
@@ -157,9 +160,18 @@ static void
 proxy_event_error (void *data, int seq, int res, const char *message)
 {
   WpProxy *self = WP_PROXY (data);
+  WpProxyPrivate *priv = wp_proxy_get_instance_private (self);
 
   wp_trace_object (self, "error seq:%d res:%d (%s) %s",
       seq, res, spa_strerror(res), message);
+
+  /* we destroy the proxy on error if feature bound is still not enabled */
+  if (priv->pw_proxy &&
+      !(wp_object_get_active_features (WP_OBJECT (self)) & WP_PROXY_FEATURE_BOUND))
+    pw_proxy_destroy (priv->pw_proxy);
+
+  wp_object_abort_activation (WP_OBJECT (self), message);
+
   g_signal_emit (self, signals[SIGNAL_ERROR], 0, seq, res, message);
 }
 
@@ -176,6 +188,17 @@ wp_proxy_init (WpProxy * self)
 {
 }
 
+static void
+wp_proxy_dispose (GObject * object)
+{
+  WpProxyPrivate *priv = wp_proxy_get_instance_private (WP_PROXY (object));
+
+  if (priv->pw_proxy)
+    pw_proxy_destroy (priv->pw_proxy);
+
+  G_OBJECT_CLASS (wp_proxy_parent_class)->dispose (object);
+}
+
 static void
 wp_proxy_get_property (GObject * object, guint property_id, GValue * value,
     GParamSpec * pspec)
@@ -213,6 +236,7 @@ wp_proxy_class_init (WpProxyClass * klass)
   WpObjectClass *wpobject_class = (WpObjectClass *) klass;
 
   object_class->get_property = wp_proxy_get_property;
+  object_class->dispose = wp_proxy_dispose;
 
   wpobject_class->deactivate = wp_proxy_deactivate;
 
@@ -338,46 +362,3 @@ wp_proxy_set_pw_proxy (WpProxy * self, struct pw_proxy * proxy)
   /* inform subclasses and listeners */
   g_signal_emit (self, signals[SIGNAL_PW_PROXY_CREATED], 0, priv->pw_proxy);
 }
-
-static void
-bind_error (WpProxy * proxy, int seq, int res, const gchar *msg,
-    WpTransition * transition)
-{
-  WpProxyPrivate *priv = wp_proxy_get_instance_private (proxy);
-
-  if (priv->pw_proxy)
-    pw_proxy_destroy (priv->pw_proxy);
-
-  wp_transition_return_error (transition, g_error_new (WP_DOMAIN_LIBRARY,
-          WP_LIBRARY_ERROR_OPERATION_FAILED, "%s", msg));
-}
-
-static void
-bind_success (WpProxy * proxy, uint32_t global_id, WpTransition * transition)
-{
-  g_signal_handlers_disconnect_by_func (proxy, bind_error, transition);
-  g_signal_handlers_disconnect_by_func (proxy, bind_success, transition);
-}
-
-/*!
- * \brief Private method to be used by subclasses to watch for errors
- * during a \a transition that leads the proxy to become bound
- *
- * If an error is signaled on the proxy before the \c bound signal is emitted,
- * the transition will return an error.
- *
- * \param proxy the proxy
- * \param transition the transition that binds the proxy
- * \ingroup wpproxy
- */
-void
-wp_proxy_watch_bind_error (WpProxy * proxy, WpTransition * transition)
-{
-  g_return_if_fail (WP_IS_PROXY (proxy));
-  g_return_if_fail (WP_IS_TRANSITION (transition));
-
-  g_signal_connect_object (proxy, "error", G_CALLBACK (bind_error),
-      transition, 0);
-  g_signal_connect_object (proxy, "bound", G_CALLBACK (bind_success),
-      transition, 0);
-}
diff --git a/lib/wp/proxy.h b/lib/wp/proxy.h
index 17187012..f6a6c43d 100644
--- a/lib/wp/proxy.h
+++ b/lib/wp/proxy.h
@@ -100,9 +100,6 @@ struct pw_proxy * wp_proxy_get_pw_proxy (WpProxy * self);
 WP_API
 void wp_proxy_set_pw_proxy (WpProxy * self, struct pw_proxy * proxy);
 
-WP_API
-void wp_proxy_watch_bind_error (WpProxy * proxy, WpTransition * transition);
-
 G_END_DECLS
 
 #endif
diff --git a/lib/wp/spa-pod.c b/lib/wp/spa-pod.c
index c1c22572..bd7b8876 100644
--- a/lib/wp/spa-pod.c
+++ b/lib/wp/spa-pod.c
@@ -2332,6 +2332,10 @@ wp_spa_pod_builder_add_valist (WpSpaPodBuilder *self, va_list args)
         }
         break;
       }
+      case 'b':
+        spa_pod_builder_bool(&self->builder,
+            va_arg(args, gboolean) ? true : false);
+        break;
       default:
         SPA_POD_BUILDER_COLLECT(&self->builder, *format, args);
         break;
@@ -2778,6 +2782,10 @@ wp_spa_pod_parser_get_valist (WpSpaPodParser *self, va_list args)
         }
         break;
       }
+      case 'b':
+        *va_arg(args, gboolean*) =
+            SPA_POD_VALUE(struct spa_pod_bool, pod) ? TRUE : FALSE;
+        break;
       default:
         SPA_POD_PARSER_COLLECT (pod, *format, args);
         break;
diff --git a/lib/wp/transition.c b/lib/wp/transition.c
index 97bd455f..a8571640 100644
--- a/lib/wp/transition.c
+++ b/lib/wp/transition.c
@@ -33,7 +33,7 @@
  * possible, the WpTransition base class does not expect
  * _WpTransitionClass::execute_step() to call wp_transition_advance() directly.
  * Instead, it is expected that wp_transition_advance() will be called from
- * the callback that the step's asyncrhonous operation will call when it is
+ * the callback that the step's asynchronous operation will call when it is
  * completed.
  *
  * \gproperties
diff --git a/lib/wp/wp.c b/lib/wp/wp.c
index ffdff1a2..c356133e 100644
--- a/lib/wp/wp.c
+++ b/lib/wp/wp.c
@@ -315,6 +315,9 @@ wp_new_files_iterator (WpLookupDirs dirs, const gchar *subdir,
     if (dir) {
       const gchar *filename;
       while ((filename = g_dir_read_name (dir))) {
+        if (filename[0] == '.')
+          continue;
+
         if (suffix && !g_str_has_suffix (filename, suffix))
           continue;
 
diff --git a/meson.build b/meson.build
index 3acaa312..1f9c9d8d 100644
--- a/meson.build
+++ b/meson.build
@@ -1,5 +1,5 @@
 project('wireplumber', ['c'],
-  version : '0.4.5',
+  version : '0.4.7',
   license : 'MIT',
   meson_version : '>= 0.56.0',
   default_options : [
@@ -33,7 +33,7 @@ gmodule_dep = dependency('gmodule-2.0', version : glib_req_version)
 gio_dep = dependency('gio-2.0', version : glib_req_version)
 giounix_dep = dependency('gio-unix-2.0', version : glib_req_version)
 spa_dep = dependency('libspa-0.2', version: '>= 0.2')
-pipewire_dep = dependency('libpipewire-0.3', version: '>= 0.3.37')
+pipewire_dep = dependency('libpipewire-0.3', version: '>= 0.3.43')
 mathlib = cc.find_library('m')
 threads_dep = dependency('threads')
 
@@ -138,3 +138,16 @@ wireplumber_uninstalled = custom_target('wp-uninstalled',
   build_by_default : true,
   command : ['cp', '@INPUT@', '@OUTPUT@'],
 )
+
+if meson.version().version_compare('>= 0.58')
+  builddir = meson.project_build_root()
+  srcdir = meson.project_source_root()
+
+  devenv = environment({
+    'WIREPLUMBER_MODULE_DIR': builddir / 'modules',
+    'WIREPLUMBER_CONFIG_DIR': srcdir / 'src' / 'config',
+    'WIREPLUMBER_DATA_DIR': srcdir / 'src',
+  })
+
+  meson.add_devenv(devenv)
+endif
diff --git a/modules/module-default-nodes-api.c b/modules/module-default-nodes-api.c
index ef5f4f86..16b74c8d 100644
--- a/modules/module-default-nodes-api.c
+++ b/modules/module-default-nodes-api.c
@@ -16,7 +16,6 @@ struct _WpDefaultNodesApi
 
   gchar *defaults[N_DEFAULT_NODES];
   WpObjectManager *om;
-  GSource *idle_source;
 };
 
 enum {
@@ -36,15 +35,18 @@ wp_default_nodes_api_init (WpDefaultNodesApi * self)
 {
 }
 
-static gboolean
-emit_changed_cb (gpointer data)
+static void
+sync_changed_notification (WpCore * core, GAsyncResult * res,
+    WpDefaultNodesApi * self)
 {
-  WpDefaultNodesApi *self = data;
+  g_autoptr (GError) error = NULL;
+  if (!wp_core_sync_finish (core, res, &error)) {
+    wp_warning_object (self, "core sync error: %s", error->message);
+    return;
+  }
 
   g_signal_emit (self, signals[SIGNAL_CHANGED], 0);
-
-  g_clear_pointer (&self->idle_source, g_source_unref);
-  return G_SOURCE_REMOVE;
+  return;
 }
 
 static void
@@ -52,7 +54,8 @@ schedule_changed_notification (WpDefaultNodesApi *self)
 {
   g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
   g_return_if_fail (core);
-  wp_core_idle_add (core, &self->idle_source, emit_changed_cb, self, NULL);
+  wp_core_sync_closure (core, NULL, g_cclosure_new_object (
+      G_CALLBACK (sync_changed_notification), G_OBJECT (self)));
 }
 
 static void
@@ -141,10 +144,6 @@ wp_default_nodes_api_disable (WpPlugin * plugin)
 {
   WpDefaultNodesApi * self = WP_DEFAULT_NODES_API (plugin);
 
-  if (self->idle_source)
-    g_source_destroy (self->idle_source);
-  g_clear_pointer (&self->idle_source, g_source_unref);
-
   for (guint i = 0; i < N_DEFAULT_NODES; i++)
     g_clear_pointer (&self->defaults[i], g_free);
   g_clear_object (&self->om);
@@ -161,14 +160,22 @@ wp_default_nodes_api_get_default_node (WpDefaultNodesApi * self,
       break;
     }
   }
+
   if (node_t != -1 && self->defaults[node_t]) {
-    g_autoptr (WpNode) node = wp_object_manager_lookup (self->om,
+    g_autoptr (WpIterator) it = NULL;
+    g_auto (GValue) val = G_VALUE_INIT;
+    it = wp_object_manager_new_filtered_iterator (self->om,
         WP_TYPE_NODE,
         WP_CONSTRAINT_TYPE_PW_PROPERTY,
         PW_KEY_NODE_NAME, "=s", self->defaults[node_t],
         NULL);
-    if (node)
-      return wp_proxy_get_bound_id (WP_PROXY (node));
+    for (; wp_iterator_next (it, &val); g_value_unset (&val)) {
+      WpNode *node = g_value_get_object (&val);
+      const gchar *mc = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), PW_KEY_MEDIA_CLASS);
+      if (!g_str_has_prefix (mc, "Stream/"))
+        return wp_proxy_get_bound_id (WP_PROXY (node));
+    }
   }
   return SPA_ID_INVALID;
 }
diff --git a/modules/module-default-nodes.c b/modules/module-default-nodes.c
index ce619d25..ac9a87bd 100644
--- a/modules/module-default-nodes.c
+++ b/modules/module-default-nodes.c
@@ -8,6 +8,7 @@
 
 #include <wp/wp.h>
 #include <errno.h>
+#include <pipewire/pipewire.h>
 #include <pipewire/keys.h>
 
 #define COMPILING_MODULE_DEFAULT_NODES 1
@@ -37,9 +38,8 @@ struct _WpDefaultNodes
   WpState *state;
   WpDefaultNode defaults[N_DEFAULT_NODES];
   WpObjectManager *metadata_om;
-  WpObjectManager *nodes_om;
+  WpObjectManager *rescan_om;
   GSource *timeout_source;
-  GSource *idle_source;
 
   /* properties */
   guint save_interval_ms;
@@ -98,57 +98,200 @@ timer_start (WpDefaultNodes *self)
   }
 }
 
+static gboolean
+node_has_available_routes (WpDefaultNodes * self, WpNode *node)
+{
+  const gchar *dev_id_str = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), PW_KEY_DEVICE_ID);
+  const gchar *cpd_str = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), "card.profile.device");
+  gint dev_id = dev_id_str ? atoi (dev_id_str) : -1;
+  gint cpd = cpd_str ? atoi (cpd_str) : -1;
+  g_autoptr (WpDevice) device = NULL;
+
+  if (dev_id == -1 || cpd == -1)
+    return TRUE;
+
+  /* Get the device */
+  device = wp_object_manager_lookup (self->rescan_om, WP_TYPE_DEVICE,
+      WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=i", dev_id, NULL);
+  if (!device)
+    return TRUE;
+
+  /* Check if the current device route supports the node card device profile */
+  {
+    g_autoptr (WpIterator) routes = NULL;
+    g_auto (GValue) val = G_VALUE_INIT;
+    routes = wp_pipewire_object_enum_params_sync (WP_PIPEWIRE_OBJECT (device),
+        "Route", NULL);
+    for (; wp_iterator_next (routes, &val); g_value_unset (&val)) {
+      WpSpaPod *route = g_value_get_boxed (&val);
+      gint route_device = -1;
+      guint32 route_avail = SPA_PARAM_AVAILABILITY_unknown;
+
+      if (!wp_spa_pod_get_object (route, NULL,
+          "device", "i", &route_device,
+          "available", "?I", &route_avail,
+          NULL))
+        continue;
+
+      if (route_device != cpd)
+        continue;
+
+      if (route_avail == SPA_PARAM_AVAILABILITY_no)
+        return FALSE;
+
+      return TRUE;
+    }
+  }
+
+  /* Check if available routes support the node card device profile */
+  {
+    g_autoptr (WpIterator) routes = NULL;
+    g_auto (GValue) val = G_VALUE_INIT;
+    routes = wp_pipewire_object_enum_params_sync (WP_PIPEWIRE_OBJECT (device),
+        "EnumRoute", NULL);
+    for (; wp_iterator_next (routes, &val); g_value_unset (&val)) {
+      WpSpaPod *route = g_value_get_boxed (&val);
+      guint32 route_avail = SPA_PARAM_AVAILABILITY_unknown;
+      g_autoptr (WpSpaPod) route_devices = NULL;
+
+      if (!wp_spa_pod_get_object (route, NULL,
+          "available", "?I", &route_avail,
+          "devices", "?P", &route_devices,
+          NULL))
+        continue;
+
+      {
+        g_autoptr (WpIterator) it = wp_spa_pod_new_iterator (route_devices);
+        g_auto (GValue) v = G_VALUE_INIT;
+        for (; wp_iterator_next (it, &v); g_value_unset (&v)) {
+          gint32 *d = (gint32 *)g_value_get_pointer (&v);
+          if (d && *d == cpd) {
+            if (route_avail != SPA_PARAM_AVAILABILITY_no)
+              return TRUE;
+          }
+        }
+      }
+    }
+  }
+
+  return FALSE;
+}
+
 static WpNode *
-find_highest_priority_node (WpDefaultNodes * self, gint node_t)
+find_best_media_class_node (WpDefaultNodes * self, const gchar *media_class,
+    const gchar *node_name, WpDirection direction, gint *priority)
 {
   g_autoptr (WpIterator) it = NULL;
   g_auto (GValue) val = G_VALUE_INIT;
   gint highest_prio = 0;
   WpNode *res = NULL;
 
-  it = wp_object_manager_new_filtered_iterator (self->nodes_om, WP_TYPE_NODE,
-      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_MEDIA_CLASS, "#s", MEDIA_CLASS_MATCH[node_t],
-      WP_CONSTRAINT_TYPE_G_PROPERTY, N_PORTS_KEY[node_t], "!u", 0,
+  g_return_val_if_fail (media_class, NULL);
+
+  it = wp_object_manager_new_filtered_iterator (self->rescan_om, WP_TYPE_NODE,
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_MEDIA_CLASS, "=s", media_class,
       NULL);
 
   for (; wp_iterator_next (it, &val); g_value_unset (&val)) {
     WpNode *node = g_value_get_object (&val);
-    const gchar *prio_str = wp_pipewire_object_get_property (
-        WP_PIPEWIRE_OBJECT (node), PW_KEY_PRIORITY_SESSION);
-    gint prio = prio_str ? atoi (prio_str) : -1;
+    gint n_ports = direction == WP_DIRECTION_INPUT ?
+        wp_node_get_n_input_ports (node, NULL) :
+        wp_node_get_n_output_ports (node, NULL);
+    if (n_ports > 0) {
+      const gchar *name = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), PW_KEY_NODE_NAME);
+      const gchar *prio_str = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), PW_KEY_PRIORITY_SESSION);
+      gint prio = prio_str ? atoi (prio_str) : -1;
+
+      if (!node_has_available_routes (self, node))
+        continue;
+
+      if (name && node_name && g_strcmp0 (name, node_name) == 0)
+        prio += 10000;
+
+      if (prio > highest_prio || res == NULL) {
+        highest_prio = prio;
+        res = node;
+      }
+    }
+  }
+
+  if (priority)
+    *priority = highest_prio;
+  return res;
+}
 
-    if (prio > highest_prio || res == NULL) {
+static WpNode *
+find_best_media_classes_node (WpDefaultNodes * self,
+    const gchar **media_classes, const gchar *node_name, WpDirection direction)
+{
+  gint highest_prio = -1;
+  WpNode *res = NULL;
+  for (guint i = 0; media_classes[i]; i++) {
+    gint prio = -1;
+    WpNode *node = find_best_media_class_node (self, media_classes[i],
+        node_name, direction, &prio);
+    if (node && (!res || prio > highest_prio)) {
       highest_prio = prio;
       res = node;
     }
   }
-  return res ? g_object_ref (res) : NULL;
+  return res;
+}
+
+static WpNode *
+find_best_node (WpDefaultNodes * self, gint node_t)
+{
+  const gchar *name = self->defaults[node_t].config_value;
+
+  switch (node_t) {
+  case AUDIO_SINK: {
+    const gchar *media_classes[] = {
+        "Audio/Sink",
+        "Audio/Duplex",
+        NULL};
+    return find_best_media_classes_node (self, media_classes, name,
+        WP_DIRECTION_INPUT);
+  }
+  case AUDIO_SOURCE: {
+    const gchar *media_classes[] = {
+        "Audio/Source",
+        "Audio/Source/Virtual",
+        "Audio/Duplex",
+        "Audio/Sink",
+        NULL};
+    return find_best_media_classes_node (self, media_classes, name,
+        WP_DIRECTION_OUTPUT);
+  }
+  case VIDEO_SOURCE: {
+    const gchar *media_classes[] = {
+        "Video/Source",
+        "Video/Source/Virtual",
+        NULL};
+    return find_best_media_classes_node (self, media_classes, name,
+        WP_DIRECTION_OUTPUT);
+  }
+  default:
+    break;
+  }
+
+  return NULL;
 }
 
 static void
 reevaluate_default_node (WpDefaultNodes * self, WpMetadata *m, gint node_t)
 {
-  g_autoptr (WpNode) node = NULL;
+  WpNode *node = NULL;
   const gchar *node_name = NULL;
   gchar buf[1024];
 
-  /* Find the configured default node */
-  node_name = self->defaults[node_t].config_value;
-  if (node_name) {
-    node = wp_object_manager_lookup (self->nodes_om, WP_TYPE_NODE,
-        WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_NODE_NAME, "=s", node_name,
-        WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_MEDIA_CLASS, "#s", MEDIA_CLASS_MATCH[node_t],
-        WP_CONSTRAINT_TYPE_G_PROPERTY, N_PORTS_KEY[node_t], "!u", 0,
-        NULL);
-  }
-
-  /* If not found, get the highest priority one */
-  if (!node) {
-    node = find_highest_priority_node (self, node_t);
-    if (node)
-      node_name = wp_pipewire_object_get_property (WP_PIPEWIRE_OBJECT (node),
-          PW_KEY_NODE_NAME);
-  }
+  node = find_best_node (self, node_t);
+  if (node)
+    node_name = wp_pipewire_object_get_property (WP_PIPEWIRE_OBJECT (node),
+        PW_KEY_NODE_NAME);
 
   /* store it in the metadata if it was changed */
   if (node && node_name &&
@@ -162,89 +305,215 @@ reevaluate_default_node (WpDefaultNodes * self, WpMetadata *m, gint node_t)
 
     g_snprintf (buf, sizeof(buf), "{ \"name\": \"%s\" }", node_name);
     wp_metadata_set (m, 0, DEFAULT_KEY[node_t], "Spa:String:JSON", buf);
+  } else if (!node && self->defaults[node_t].value) {
+    g_clear_pointer (&self->defaults[node_t].value, g_free);
+    wp_info_object (self, "unset default node for %s", NODE_TYPE_STR[node_t]);
+    wp_metadata_set (m, 0, DEFAULT_KEY[node_t], NULL, NULL);
   }
 }
 
-static void
-on_metadata_changed (WpMetadata *m, guint32 subject,
-    const gchar *key, const gchar *type, const gchar *value, gpointer d)
+static guint
+get_device_total_nodes (WpPipewireObject * proxy)
 {
-  WpDefaultNodes * self = WP_DEFAULT_NODES (d);
-  gint node_t = -1;
-  gchar name[1024];
-
-  if (subject == 0) {
-    for (gint i = 0; i < N_DEFAULT_NODES; i++) {
-      if (!g_strcmp0 (key, DEFAULT_CONFIG_KEY[i])) {
-        node_t = i;
-        break;
+  g_autoptr (WpIterator) profiles = NULL;
+  g_auto (GValue) item = G_VALUE_INIT;
+
+  profiles = wp_pipewire_object_enum_params_sync (proxy, "Profile", NULL);
+  if (!profiles)
+    return 0;
+
+  for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
+    WpSpaPod *pod = g_value_get_boxed (&item);
+    gint idx = -1;
+    const gchar *name = NULL;
+    g_autoptr (WpSpaPod) classes = NULL;
+
+    /* Parse */
+    if (!wp_spa_pod_get_object (pod, NULL,
+        "index", "i", &idx,
+        "name", "s", &name,
+        "classes", "?P", &classes,
+        NULL))
+      continue;
+    if (!classes)
+      continue;
+
+    /* Parse profile classes */
+    {
+      g_autoptr (WpIterator) it = wp_spa_pod_new_iterator (classes);
+      g_auto (GValue) v = G_VALUE_INIT;
+      gint total_nodes = 0;
+      for (; wp_iterator_next (it, &v); g_value_unset (&v)) {
+        WpSpaPod *entry = g_value_get_boxed (&v);
+        g_autoptr (WpSpaPodParser) pp = NULL;
+        const gchar *media_class = NULL;
+        gint n_nodes = 0;
+        g_return_val_if_fail (entry, 0);
+        if (!wp_spa_pod_is_struct (entry))
+          continue;
+        pp = wp_spa_pod_parser_new_struct (entry);
+        g_return_val_if_fail (pp, 0);
+        g_return_val_if_fail (wp_spa_pod_parser_get_string (pp, &media_class), 0);
+        g_return_val_if_fail (wp_spa_pod_parser_get_int (pp, &n_nodes), 0);
+        wp_spa_pod_parser_end (pp);
+
+        total_nodes += n_nodes;
       }
+
+      if (total_nodes > 0)
+        return total_nodes;
     }
   }
 
-  if (node_t != -1) {
-    g_clear_pointer (&self->defaults[node_t].config_value, g_free);
+  return 0;
+}
 
-    if (value && !g_strcmp0 (type, "Spa:String:JSON") &&
-        json_object_find (value, "name", name, sizeof(name)) == 0)
-    {
-      self->defaults[node_t].config_value = g_strdup (name);
-    }
+static gboolean
+nodes_ready (WpDefaultNodes * self)
+{
+  g_autoptr (WpIterator) it = NULL;
+  g_auto (GValue) val = G_VALUE_INIT;
 
-    wp_debug_object (m, "changed '%s' -> '%s'", key,
-        self->defaults[node_t].config_value);
+  /* Get the total number of nodes for each device and make sure they exist
+   * and have at least 1 port */
+  it = wp_object_manager_new_filtered_iterator (self->rescan_om,
+      WP_TYPE_DEVICE, NULL);
+  for (; wp_iterator_next (it, &val); g_value_unset (&val)) {
+    WpPipewireObject *device = g_value_get_object (&val);
+    guint total_nodes = get_device_total_nodes (device);
+    if (total_nodes > 0) {
+      guint32 device_id = wp_proxy_get_bound_id (WP_PROXY (device));
+      g_autoptr (WpIterator) node_it = NULL;
+      g_auto (GValue) node_val = G_VALUE_INIT;
+      guint ready_nodes = 0;
+
+      node_it = wp_object_manager_new_filtered_iterator (self->rescan_om,
+          WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY,
+          PW_KEY_DEVICE_ID, "=i", device_id, NULL);
+      for (; wp_iterator_next (node_it, &node_val); g_value_unset (&node_val)) {
+        WpPipewireObject *node = g_value_get_object (&node_val);
+        g_autoptr (WpPort) port =
+              wp_object_manager_lookup (self->rescan_om,
+              WP_TYPE_PORT, WP_CONSTRAINT_TYPE_PW_PROPERTY,
+              PW_KEY_NODE_ID, "=u", wp_proxy_get_bound_id (WP_PROXY (node)),
+              NULL);
+        if (port)
+          ready_nodes++;
+      }
 
-    /* re-evaluate the default, taking into account the new configured default;
-       block recursive calls to this handler as an optimization */
-    g_signal_handlers_block_by_func (m, on_metadata_changed, d);
-    reevaluate_default_node (self, m, node_t);
-    g_signal_handlers_unblock_by_func (m, on_metadata_changed, d);
+      if (ready_nodes < total_nodes) {
+        const gchar *device_name = wp_pipewire_object_get_property (
+            WP_PIPEWIRE_OBJECT (device), PW_KEY_DEVICE_NAME);
+        wp_debug_object (self, "device '%s' is not ready (%d/%d)", device_name,
+            ready_nodes, total_nodes);
+        return FALSE;
+      }
+    }
+  }
 
-    /* Save state after specific interval */
-    timer_start (self);
+  /* Make sure Audio and Video virtual sources have ports */
+  {
+    g_autoptr (WpIterator) node_it = NULL;
+    g_auto (GValue) node_val = G_VALUE_INIT;
+    node_it = wp_object_manager_new_filtered_iterator (self->rescan_om,
+        WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_DEVICE_ID, "-",
+        NULL);
+    for (; wp_iterator_next (node_it, &node_val); g_value_unset (&node_val)) {
+      WpPipewireObject *node = g_value_get_object (&node_val);
+      const gchar *media_class = wp_pipewire_object_get_property (
+          WP_PIPEWIRE_OBJECT (node), PW_KEY_MEDIA_CLASS);
+      g_autoptr (WpPort) port =
+          wp_object_manager_lookup (self->rescan_om,
+          WP_TYPE_PORT, WP_CONSTRAINT_TYPE_PW_PROPERTY,
+          PW_KEY_NODE_ID, "=u", wp_proxy_get_bound_id (WP_PROXY (node)),
+          NULL);
+      if (!port &&
+          (g_strcmp0 ("Audio/Source/Virtual", media_class) == 0 ||
+           g_strcmp0 ("Video/Source/Virtual", media_class) == 0)) {
+        const gchar *node_name = wp_pipewire_object_get_property (
+            WP_PIPEWIRE_OBJECT (node), PW_KEY_NODE_NAME);
+        wp_debug_object (self, "virtual node '%s' is not ready", node_name);
+        return FALSE;
+      }
+    }
   }
+
+  return TRUE;
 }
 
-static gboolean
-rescan (WpDefaultNodes * self)
+static void
+sync_rescan (WpCore * core, GAsyncResult * res, WpDefaultNodes * self)
 {
   g_autoptr (WpMetadata) metadata = NULL;
+  g_autoptr (GError) error = NULL;
 
-  g_clear_pointer (&self->idle_source, g_source_unref);
+  if (!wp_core_sync_finish (core, res, &error)) {
+    wp_warning_object (self, "core sync error: %s", error->message);
+    return;
+  }
 
   /* Get the metadata */
   metadata = wp_object_manager_lookup (self->metadata_om, WP_TYPE_METADATA,
       NULL);
   if (!metadata)
-    return G_SOURCE_REMOVE;
+    return;
+
+  /* Make sure nodes are ready for current profile */
+  if (!nodes_ready (self))
+    return;
 
-  wp_trace_object (self, "nodes changed, re-evaluating defaults");
+  wp_trace_object (self, "re-evaluating defaults");
   reevaluate_default_node (self, metadata, AUDIO_SINK);
   reevaluate_default_node (self, metadata, AUDIO_SOURCE);
   reevaluate_default_node (self, metadata, VIDEO_SOURCE);
-
-  return G_SOURCE_REMOVE;
 }
 
 static void
 schedule_rescan (WpDefaultNodes * self)
 {
-  if (!self->idle_source) {
-    g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
-    g_return_if_fail (core);
+  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
+  g_return_if_fail (core);
 
-    wp_core_idle_add_closure (core, &self->idle_source,
-        g_cclosure_new_object (G_CALLBACK (rescan), G_OBJECT (self)));
-  }
+  wp_debug_object (self, "scheduling default nodes rescan");
+  wp_core_sync_closure (core, NULL, g_cclosure_new_object (
+      G_CALLBACK (sync_rescan), G_OBJECT (self)));
 }
 
 static void
-on_node_added (WpObjectManager * om, WpNode * node, WpDefaultNodes * self)
+on_metadata_changed (WpMetadata *m, guint32 subject,
+    const gchar *key, const gchar *type, const gchar *value, gpointer d)
 {
-  g_signal_connect_object (node, "notify::n-input-ports",
-      G_CALLBACK (schedule_rescan), self, G_CONNECT_SWAPPED);
-  g_signal_connect_object (node, "notify::n-output-ports",
-      G_CALLBACK (schedule_rescan), self, G_CONNECT_SWAPPED);
+  WpDefaultNodes * self = WP_DEFAULT_NODES (d);
+  gint node_t = -1;
+  gchar name[1024];
+
+  if (subject == 0) {
+    for (gint i = 0; i < N_DEFAULT_NODES; i++) {
+      if (!g_strcmp0 (key, DEFAULT_CONFIG_KEY[i])) {
+        node_t = i;
+        break;
+      }
+    }
+  }
+
+  if (node_t != -1) {
+    g_clear_pointer (&self->defaults[node_t].config_value, g_free);
+
+    if (value && !g_strcmp0 (type, "Spa:String:JSON") &&
+        json_object_find (value, "name", name, sizeof(name)) == 0)
+    {
+      self->defaults[node_t].config_value = g_strdup (name);
+    }
+
+    wp_debug_object (m, "changed '%s' -> '%s'", key,
+        self->defaults[node_t].config_value);
+
+    /* schedule rescan */
+    schedule_rescan (self);
+
+    /* Save state after specific interval */
+    timer_start (self);
+  }
 }
 
 static void
@@ -268,18 +537,20 @@ on_metadata_added (WpObjectManager *om, WpMetadata *metadata, gpointer d)
   g_signal_connect_object (metadata, "changed",
       G_CALLBACK (on_metadata_changed), self, 0);
 
-  /* Create the nodes object manager */
-  self->nodes_om = wp_object_manager_new ();
-  wp_object_manager_add_interest (self->nodes_om, WP_TYPE_NODE, NULL);
-  wp_object_manager_request_object_features (self->nodes_om, WP_TYPE_NODE,
-      WP_PIPEWIRE_OBJECT_FEATURES_MINIMAL);
-  g_signal_connect_object (self->nodes_om, "object-added",
-      G_CALLBACK (on_node_added), self, 0);
-  g_signal_connect_object (self->nodes_om, "object-added",
-      G_CALLBACK (schedule_rescan), self, G_CONNECT_SWAPPED);
-  g_signal_connect_object (self->nodes_om, "object-removed",
+  /* Create the rescan object manager */
+  self->rescan_om = wp_object_manager_new ();
+  wp_object_manager_add_interest (self->rescan_om, WP_TYPE_DEVICE, NULL);
+  wp_object_manager_add_interest (self->rescan_om, WP_TYPE_NODE, NULL);
+  wp_object_manager_add_interest (self->rescan_om, WP_TYPE_PORT, NULL);
+  wp_object_manager_request_object_features (self->rescan_om, WP_TYPE_DEVICE,
+      WP_OBJECT_FEATURES_ALL);
+  wp_object_manager_request_object_features (self->rescan_om, WP_TYPE_NODE,
+      WP_OBJECT_FEATURES_ALL);
+  wp_object_manager_request_object_features (self->rescan_om, WP_TYPE_PORT,
+      WP_OBJECT_FEATURES_ALL);
+  g_signal_connect_object (self->rescan_om, "objects-changed",
       G_CALLBACK (schedule_rescan), self, G_CONNECT_SWAPPED);
-  wp_core_install_object_manager (core, self->nodes_om);
+  wp_core_install_object_manager (core, self->rescan_om);
 }
 
 static void
@@ -313,11 +584,6 @@ wp_default_nodes_disable (WpPlugin * plugin)
 {
   WpDefaultNodes * self = WP_DEFAULT_NODES (plugin);
 
-  /* Clear the current rescan callback */
-  if (self->idle_source)
-      g_source_destroy (self->idle_source);
-  g_clear_pointer (&self->idle_source, g_source_unref);
-
   /* Clear the current timeout callback */
   if (self->timeout_source)
       g_source_destroy (self->timeout_source);
@@ -329,7 +595,7 @@ wp_default_nodes_disable (WpPlugin * plugin)
   }
 
   g_clear_object (&self->metadata_om);
-  g_clear_object (&self->nodes_om);
+  g_clear_object (&self->rescan_om);
   g_clear_object (&self->state);
 }
 
diff --git a/modules/module-default-nodes/common.h b/modules/module-default-nodes/common.h
index 35faa011..20ab4cfc 100644
--- a/modules/module-default-nodes/common.h
+++ b/modules/module-default-nodes/common.h
@@ -37,18 +37,6 @@ static const gchar * DEFAULT_CONFIG_KEY[N_DEFAULT_NODES] = {
   [VIDEO_SOURCE] = "default.configured.video.source",
 };
 
-static const gchar * MEDIA_CLASS_MATCH[N_DEFAULT_NODES] = {
-  [AUDIO_SINK] = "Audio/*",
-  [AUDIO_SOURCE] = "Audio/*",
-  [VIDEO_SOURCE] = "Video/*",
-};
-
-static const gchar * N_PORTS_KEY[N_DEFAULT_NODES] = {
-  [AUDIO_SINK] = "n-input-ports",
-  [AUDIO_SOURCE] = "n-output-ports",
-  [VIDEO_SOURCE] = "n-output-ports",
-};
-
 #endif
 
 static int
@@ -62,7 +50,7 @@ json_object_find (const char *obj, const char *key, char *value, size_t len)
   if (spa_json_enter_object(&it[0], &it[1]) <= 0)
     return -EINVAL;
 
-  while (spa_json_get_string(&it[1], k, sizeof(k)-1) > 0) {
+  while (spa_json_get_string(&it[1], k, sizeof(k)) > 0) {
     if (strcmp(k, key) == 0) {
       if (spa_json_get_string(&it[1], value, len) <= 0)
         continue;
diff --git a/modules/module-default-profile.c b/modules/module-default-profile.c
index 54f585a6..03bf9e8b 100644
--- a/modules/module-default-profile.c
+++ b/modules/module-default-profile.c
@@ -66,12 +66,13 @@ find_device_profile (WpPipewireObject *device, const gchar *lookup_name)
     if (!wp_spa_pod_get_object (pod, NULL,
         "index", "i", &index,
         "name", "s", &name,
-        NULL)) {
+        NULL))
       continue;
-    }
 
-    if (g_strcmp0 (name, lookup_name) == 0)
+    if (g_strcmp0 (name, lookup_name) == 0) {
+      g_value_unset (&item);
       return index;
+    }
   }
 
   return -1;
@@ -169,50 +170,45 @@ update_profile (WpDefaultProfile *self, WpPipewireObject *device,
 }
 
 static void
-on_device_profile_notified (WpPipewireObject *device, GAsyncResult *res,
-    WpDefaultProfile *self)
+handle_profile (WpDefaultProfile *self, WpPipewireObject * device,
+    WpIterator *profiles)
 {
-  g_autoptr (WpIterator) profiles = NULL;
-  g_autoptr (GError) error = NULL;
   g_auto (GValue) item = G_VALUE_INIT;
-  const gchar *name = NULL;
-  gint index = 0;
-
-  /* Finish */
-  profiles = wp_pipewire_object_enum_params_finish (device, res, &error);
-  if (error) {
-    wp_warning_object (self, "failed to get current profile on device: %s",
-        error->message);
-    return;
-  }
 
-  /* Ignore empty profile notifications */
-  if (!wp_iterator_next (profiles, &item))
-    return;
-
-  /* Parse the profile */
-  WpSpaPod *pod = g_value_get_boxed (&item);
-  if (!wp_spa_pod_get_object (pod, NULL,
-      "index", "i", &index,
-      "name", "s", &name,
-      NULL)) {
-    wp_warning_object (self, "failed to parse current profile");
-    return;
-  }
+  for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
+    WpSpaPod *pod = g_value_get_boxed (&item);
+    const gchar *name = NULL;
+    gint index = 0;
+    gboolean save = FALSE;
 
-  g_value_unset (&item);
+    if (!wp_spa_pod_get_object (pod, NULL,
+        "index", "i", &index,
+        "name", "s", &name,
+        "save", "?b", &save,
+        NULL))
+      continue;
 
-  /* Update the profile */
-  update_profile (self, device, name);
+    if (save)
+      update_profile (self, device, name);
+  }
 }
 
 static void
-on_device_param_info_notified (WpPipewireObject * device, GParamSpec * param,
+on_device_params_changed (WpPipewireObject * proxy, const gchar *param_name,
     WpDefaultProfile *self)
 {
-  /* Check the profile every time the params have changed */
-  wp_pipewire_object_enum_params (device, "Profile", NULL, NULL,
-      (GAsyncReadyCallback) on_device_profile_notified, self);
+  g_autoptr (WpIterator) profiles = NULL;
+
+  if (g_strcmp0 (param_name, "Profile") == 0) {
+    profiles = wp_pipewire_object_enum_params_sync (proxy, "Profile", NULL);
+    if (profiles)
+      handle_profile (self, proxy, profiles);
+  } else if (g_strcmp0 (param_name, "EnumProfile") == 0) {
+    profiles = wp_pipewire_object_enum_params_sync (proxy, "EnumProfile", NULL);
+    if (profiles)
+      g_object_set_qdata_full (G_OBJECT (proxy), profiles_quark (),
+          g_steal_pointer (&profiles), (GDestroyNotify) wp_iterator_unref);
+  }
 }
 
 static void
@@ -221,21 +217,10 @@ on_device_added (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
   WpDefaultProfile *self = WP_DEFAULT_PROFILE (d);
   g_autoptr (WpIterator) profiles = NULL;
 
-  wp_debug_object (self, "device " WP_OBJECT_FORMAT " added",
-      WP_OBJECT_ARGS (proxy));
-
-  /* Enum available profiles */
-  profiles = wp_pipewire_object_enum_params_sync (proxy, "EnumProfile", NULL);
-  if (!profiles)
-    return;
-
-  /* Keep a reference of the profiles in the device object */
-  g_object_set_qdata_full (G_OBJECT (proxy), profiles_quark (),
-        g_steal_pointer (&profiles), (GDestroyNotify) wp_iterator_unref);
+  g_signal_connect_object (proxy, "params-changed",
+      G_CALLBACK (on_device_params_changed), self, 0);
 
-  /* Watch for param info changes */
-  g_signal_connect_object (proxy, "notify::param-info",
-      G_CALLBACK (on_device_param_info_notified), self, 0);
+  on_device_params_changed (proxy, "EnumProfile", self);
 }
 
 static void
diff --git a/modules/module-device-activation.c b/modules/module-device-activation.c
index 68aea2c9..98978a27 100644
--- a/modules/module-device-activation.c
+++ b/modules/module-device-activation.c
@@ -12,6 +12,9 @@
 #include <spa/utils/keys.h>
 #include <spa/utils/names.h>
 
+G_DEFINE_QUARK (wp-module-device-activation-best-profile, best_profile);
+G_DEFINE_QUARK (wp-module-device-activation-active-profile, active_profile);
+
 struct _WpDeviceActivation
 {
   WpPlugin parent;
@@ -28,8 +31,18 @@ G_DEFINE_TYPE (WpDeviceActivation, wp_device_activation, WP_TYPE_PLUGIN)
 static void
 set_device_profile (WpDeviceActivation *self, WpPipewireObject *device, gint index)
 {
+  const gchar *dn = wp_pipewire_object_get_property (device, PW_KEY_DEVICE_NAME);
+  gpointer active_ptr = NULL;
+
   g_return_if_fail (device);
 
+  /* Make sure the profile we want to set is not active */
+  active_ptr = g_object_get_qdata (G_OBJECT (device), active_profile_quark ());
+  if (active_ptr && GPOINTER_TO_INT (active_ptr) - 1 == index) {
+    wp_info_object (self, "profile %d is already active in %s", index, dn);
+    return;
+  }
+
   /* Set profile */
   wp_pipewire_object_set_param (device, "Profile", 0,
       wp_spa_pod_new_object (
@@ -37,66 +50,248 @@ set_device_profile (WpDeviceActivation *self, WpPipewireObject *device, gint ind
           "index", "i", index,
           NULL));
 
-  wp_info_object (self, "profile %d set on device " WP_OBJECT_FORMAT, index,
-      WP_OBJECT_ARGS (device));
+  wp_info_object (self, "profile %d set on device %s", index, dn);
 }
 
-static void
-handle_device_profiles (WpDeviceActivation *self, WpPipewireObject *proxy,
-    WpIterator *profiles)
+static gint
+find_active_profile (WpPipewireObject *proxy, gboolean *off)
+{
+  g_autoptr (WpIterator) profiles = NULL;
+  g_auto (GValue) item = G_VALUE_INIT;
+  gint idx = -1, prio = 0;
+  guint32 avail = SPA_PARAM_AVAILABILITY_unknown;
+  const gchar *name;
+
+  /* Get current profile */
+  profiles = wp_pipewire_object_enum_params_sync (proxy, "Profile", NULL);
+  if (!profiles)
+    return idx;
+
+  for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
+    WpSpaPod *pod = g_value_get_boxed (&item);
+    if (!wp_spa_pod_get_object (pod, NULL,
+        "index", "i", &idx,
+        "name", "s", &name,
+        "priority", "?i", &prio,
+        "available", "?I", &avail,
+        NULL))
+      continue;
+
+    g_value_unset (&item);
+    break;
+  }
+
+  if (off)
+    *off = idx >= 0 && g_strcmp0 (name, "off") == 0;
+
+  return idx;
+}
+
+static gint
+find_best_profile (WpIterator *profiles)
+{
+  g_auto (GValue) item = G_VALUE_INIT;
+  gint best_idx = -1, unk_idx = -1, off_idx = -1;
+  gint best_prio = 0, unk_prio = 0;
+
+  wp_iterator_reset (profiles);
+  for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
+    WpSpaPod *pod = g_value_get_boxed (&item);
+    gint idx, prio = 0;
+    guint32 avail = SPA_PARAM_AVAILABILITY_unknown;
+    const gchar *name;
+
+    if (!wp_spa_pod_get_object (pod, NULL,
+        "index", "i", &idx,
+        "name", "s", &name,
+        "priority", "?i", &prio,
+        "available", "?I", &avail,
+        NULL)) {
+      continue;
+    }
+
+    if (g_strcmp0 (name, "pro-audio") == 0)
+      continue;
+
+    if (g_strcmp0 (name, "off") == 0) {
+      off_idx = idx;
+    } else if (avail == SPA_PARAM_AVAILABILITY_yes) {
+      if (best_idx == -1 || prio > best_prio) {
+        best_prio = prio;
+        best_idx = idx;
+      }
+    } else if (avail != SPA_PARAM_AVAILABILITY_no) {
+      if (unk_idx == -1 || prio > unk_prio) {
+        unk_prio = prio;
+        unk_idx = idx;
+      }
+    }
+  }
+
+  if (best_idx != -1)
+    return best_idx;
+  else if (unk_idx != -1)
+    return unk_idx;
+  else if (off_idx != -1)
+    return off_idx;
+  return -1;
+}
+
+static gint
+find_default_profile (WpDeviceActivation *self, WpPipewireObject *proxy,
+    WpIterator *profiles, gboolean *available)
 {
   g_autoptr (WpPlugin) dp = g_weak_ref_get (&self->default_profile);
-  const gchar *name = NULL;
-  gint index = -1;
+  g_auto (GValue) item = G_VALUE_INIT;
+  const gchar *def_name = NULL;
 
   /* Get the default profile name if default-profile module is loaded */
   if (dp)
-    g_signal_emit_by_name (dp, "get-profile", WP_DEVICE (proxy), &name);
-
-  /* Find the profile index */
-  if (name) {
-    g_auto (GValue) item = G_VALUE_INIT;
-    for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
-      WpSpaPod *pod = g_value_get_boxed (&item);
-      gint i = 0;
-      const gchar *n = NULL;
-
-      /* Parse */
-      if (!wp_spa_pod_get_object (pod, NULL,
-          "index", "i", &i,
-          "name", "s", &n,
-          NULL)) {
-        continue;
-      }
+    g_signal_emit_by_name (dp, "get-profile", WP_DEVICE (proxy), &def_name);
+  if (!def_name)
+    return -1;
 
-      if (g_strcmp0 (name, n) == 0) {
-        index = i;
-        break;
-      }
+  /* Find the best profile index */
+  wp_iterator_reset (profiles);
+  for (; wp_iterator_next (profiles, &item); g_value_unset (&item)) {
+    WpSpaPod *pod = g_value_get_boxed (&item);
+    gint idx = -1, prio = 0;
+    guint32 avail = SPA_PARAM_AVAILABILITY_unknown;
+    const gchar *name = NULL;
+
+    /* Parse */
+    if (!wp_spa_pod_get_object (pod, NULL,
+        "index", "i", &idx,
+        "name", "s", &name,
+        "priority", "?i", &prio,
+        "available", "?I", &avail,
+        NULL))
+      continue;
+
+    /* Check if the profile name is the default one */
+    if (g_strcmp0 (def_name, name) == 0) {
+      if (available)
+        *available = avail;
+      g_value_unset (&item);
+      return idx;
     }
   }
 
-  /* If not profile was found, use index 1 for ALSA (no ACP) and Bluez5 */
-  if (index < 0) {
-    /* Alsa */
-    const gchar *api =
-        wp_pipewire_object_get_property (proxy, PW_KEY_DEVICE_API);
-    if (api && g_str_has_prefix (api, "alsa")) {
-      const gchar *acp =
-          wp_pipewire_object_get_property (proxy, "device.api.alsa.acp");
-      if (!acp || !atoi (acp))
-        index = 1;
-    }
+  return -1;
+}
+
+static gint
+handle_active_profile (WpDeviceActivation *self, WpPipewireObject *proxy,
+    WpIterator *profiles, gboolean *changed, gboolean *off)
+{
+  const gchar *dn = wp_pipewire_object_get_property (proxy, PW_KEY_DEVICE_NAME);
+  gpointer active_ptr = NULL;
+  gint new_active = -1;
+  gint local_changed = FALSE;
+
+  /* Find the new active profile */
+  new_active = find_active_profile (proxy, off);
+  if (new_active < 0) {
+    wp_info_object (self, "cannot find active profile in %s", dn);
+    return new_active;
+  }
+
+  /* Update active profile if changed */
+  active_ptr = g_object_get_qdata (G_OBJECT (proxy), active_profile_quark ());
+  local_changed = !active_ptr || GPOINTER_TO_INT (active_ptr) - 1 != new_active;
+  if (local_changed) {
+    wp_info_object (self, "active profile changed to %d in %s", new_active, dn);
+    g_object_set_qdata (G_OBJECT (proxy), active_profile_quark (),
+        GINT_TO_POINTER (new_active + 1));
+  }
 
-    /* Bluez5 */
-    else if (api && g_str_has_prefix (api, "bluez5")) {
-      index = 1;
+  if (changed)
+    *changed = local_changed;
+
+  return new_active;
+}
+
+static gint
+handle_best_profile (WpDeviceActivation *self, WpPipewireObject *proxy,
+    WpIterator *profiles, gboolean *changed)
+{
+  const gchar *dn = wp_pipewire_object_get_property (proxy, PW_KEY_DEVICE_NAME);
+  gpointer best_ptr = NULL;
+  gint new_best = -1;
+  gboolean local_changed = FALSE;
+
+  /* Get the new best profile index */
+  new_best = find_best_profile (profiles);
+  if (new_best < 0) {
+    wp_info_object (self, "cannot find best profile in %s", dn);
+    return new_best;
+  }
+
+  /* Update best profile if changed */
+  best_ptr = g_object_get_qdata (G_OBJECT (proxy), best_profile_quark ());
+  local_changed = !best_ptr || GPOINTER_TO_INT (best_ptr) - 1 != new_best;
+  if (local_changed) {
+    wp_info_object (self, "best profile changed to %d in %s", new_best, dn);
+    g_object_set_qdata (G_OBJECT (proxy), best_profile_quark (),
+        GINT_TO_POINTER (new_best + 1));
+  }
+
+  if (changed)
+    *changed = local_changed;
+
+  return new_best;
+}
+
+static void
+handle_enum_profiles (WpDeviceActivation *self, WpPipewireObject *proxy,
+    WpIterator *profiles)
+{
+  const gchar *dn = wp_pipewire_object_get_property (proxy, PW_KEY_DEVICE_NAME);
+  gint active_idx = FALSE, best_idx = FALSE;
+  gboolean active_changed = FALSE, best_changed = FALSE, active_off = FALSE;
+
+  /* Set default device if active profile changed to off */
+  active_idx = handle_active_profile (self, proxy, profiles, &active_changed,
+      &active_off);
+  if (active_idx >= 0 && active_changed && active_off) {
+    gboolean default_avail = FALSE;
+    gint default_idx = -1;
+    default_idx = find_default_profile (self, proxy, profiles, &default_avail);
+    if (default_idx >= 0) {
+      if (default_avail == SPA_PARAM_AVAILABILITY_no) {
+        wp_info_object (self, "default profile %d unavailable for %s",
+            default_idx, dn);
+      } else {
+        wp_info_object (self, "found default profile %d for %s", default_idx,
+            dn);
+        set_device_profile (self, proxy, default_idx);
+        return;
+      }
+    } else {
+      wp_info_object (self, "cannot find default profile for %s", dn);
     }
   }
 
-  /* Set the profile */
-  if (index >= 0)
-    set_device_profile (self, proxy, index);
+  /* Otherwise just set the best profile if changed */
+  best_idx = handle_best_profile (self, proxy, profiles, &best_changed);
+  if (best_idx >= 0 && best_changed)
+    set_device_profile (self, proxy, best_idx);
+  else if (best_idx >= 0)
+    wp_info_object (self, "best profile %d already set in %s", best_idx, dn);
+  else
+    wp_info_object (self, "best profile not found in %s", dn);
+}
+
+static void
+on_device_params_changed (WpPipewireObject * proxy, const gchar *param_name,
+    WpDeviceActivation *self)
+{
+  if (g_strcmp0 (param_name, "EnumProfile") == 0) {
+    g_autoptr (WpIterator) profiles = NULL;
+    profiles = wp_pipewire_object_enum_params_sync (proxy, "EnumProfile", NULL);
+    if (profiles)
+      handle_enum_profiles (self, proxy, profiles);
+  }
 }
 
 static void
@@ -105,12 +300,10 @@ on_device_added (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
   WpDeviceActivation *self = WP_DEVICE_ACTIVATION (d);
   g_autoptr (WpIterator) profiles = NULL;
 
-  /* Enum available profiles */
-  profiles = wp_pipewire_object_enum_params_sync (proxy, "EnumProfile", NULL);
-  if (!profiles)
-    return;
+  g_signal_connect_object (proxy, "params-changed",
+      G_CALLBACK (on_device_params_changed), self, 0);
 
-  handle_device_profiles (self, proxy, profiles);
+  on_device_params_changed (proxy, "EnumProfile", self);
 }
 
 static void
diff --git a/modules/module-lua-scripting.c b/modules/module-lua-scripting.c
index c143d3d8..a81038aa 100644
--- a/modules/module-lua-scripting.c
+++ b/modules/module-lua-scripting.c
@@ -132,6 +132,7 @@ find_script (const gchar * script, gboolean daemon)
     return g_strdup (script);
 
   return wp_find_file (WP_LOOKUP_DIR_ENV_DATA |
+                       WP_LOOKUP_DIR_XDG_CONFIG_HOME |
                        WP_LOOKUP_DIR_ETC |
                        WP_LOOKUP_DIR_PREFIX_SHARE,
                        script, "scripts");
diff --git a/modules/module-mixer-api.c b/modules/module-mixer-api.c
index b410a5ce..af6de20a 100644
--- a/modules/module-mixer-api.c
+++ b/modules/module-mixer-api.c
@@ -210,6 +210,7 @@ collect_node_info (WpMixerApi * self, struct node_info *info,
         info->route_index = r_index;
         info->route_device = r_device;
         have_volume = TRUE;
+        g_value_unset (&val);
         break;
       }
     }
@@ -222,8 +223,10 @@ collect_node_info (WpMixerApi * self, struct node_info *info,
     it = wp_pipewire_object_enum_params_sync (node, "Props", NULL);
     for (; it && wp_iterator_next (it, &val); g_value_unset (&val)) {
       WpSpaPod *param = g_value_get_boxed (&val);
-      if (node_info_fill (info, param))
+      if (node_info_fill (info, param)) {
+        g_value_unset (&val);
         break;
+      }
     }
   }
 }
diff --git a/modules/module-reserve-device/plugin.c b/modules/module-reserve-device/plugin.c
index d8e4e092..68df6ee8 100644
--- a/modules/module-reserve-device/plugin.c
+++ b/modules/module-reserve-device/plugin.c
@@ -221,7 +221,7 @@ wp_reserve_device_plugin_create_reservation (WpReserveDevicePlugin *self,
       NULL);
 
   /* use rd->name to avoid copying @em name again */
-  g_hash_table_insert (self->reserve_devices, rd->name, rd);
+  g_hash_table_replace (self->reserve_devices, rd->name, rd);
 
   return g_object_ref (rd);
 }
diff --git a/modules/module-route-settings-api.c b/modules/module-route-settings-api.c
index eae4a802..65f2161b 100644
--- a/modules/module-route-settings-api.c
+++ b/modules/module-route-settings-api.c
@@ -80,7 +80,7 @@ wp_route_settings_api_convert (WpRouteSettingsApi * self,
   if (spa_json_enter_object(&it[0], &it[1]) <= 0)
     return NULL;
 
-  while (spa_json_get_string(&it[1], k, sizeof(k)-1) > 0) {
+  while (spa_json_get_string(&it[1], k, sizeof(k)) > 0) {
     int len;
     const char *value;
 
@@ -98,9 +98,8 @@ wp_route_settings_api_convert (WpRouteSettingsApi * self,
       str = g_string_new("");
       while ((len = spa_json_next(&it[2], &value)) > 0) {
         char v[1024];
-	if (len > 1023)
+        if (spa_json_parse_stringn(value, len, v, sizeof(v)) < 0)
           continue;
-        spa_json_parse_string(value, len, v);
         g_string_append_printf(str, "%s;", v);
       }
       return g_string_free(str, false);
diff --git a/modules/module-si-audio-adapter.c b/modules/module-si-audio-adapter.c
index 00870248..f1f62185 100644
--- a/modules/module-si-audio-adapter.c
+++ b/modules/module-si-audio-adapter.c
@@ -205,6 +205,15 @@ si_audio_adapter_find_format (WpSiAudioAdapter * self, WpNode * node)
   return have_format;
 }
 
+static void
+on_proxy_destroyed (WpNode * proxy, WpSiAudioAdapter * self)
+{
+  if (self->node == proxy) {
+    wp_object_abort_activation (WP_OBJECT (self), "proxy destroyed");
+    si_audio_adapter_reset (WP_SESSION_ITEM (self));
+  }
+}
+
 static gboolean
 si_audio_adapter_configure (WpSessionItem * item, WpProperties *p)
 {
@@ -255,6 +264,8 @@ si_audio_adapter_configure (WpSessionItem * item, WpProperties *p)
   self->is_autoconnect = str && pw_properties_parse_bool (str);
 
   self->node = g_object_ref (node);
+  g_signal_connect_object (self->node, "pw-proxy-destroyed",
+      G_CALLBACK (on_proxy_destroyed), self, 0);
 
   wp_properties_set (si_props, "item.node.supports-encoded-fmts",
       self->have_encoded ? "true" : "false");
@@ -419,10 +430,13 @@ build_adapter_default_format (WpSiAudioAdapter * self, const gchar *mode)
 static void
 on_format_set (GObject *obj, GAsyncResult * res, gpointer p)
 {
-  WpTransition *transition = p;
+  g_autoptr(WpTransition) transition = p;
   WpSiAudioAdapter *self = wp_transition_get_source_object (transition);
   g_autoptr (GError) error = NULL;
 
+  if (wp_transition_get_completed (transition))
+    return;
+
   wp_si_adapter_set_ports_format_finish (WP_SI_ADAPTER (self), res, &error);
   if (error) {
     wp_transition_return_error (transition, g_steal_pointer (&error));
@@ -463,7 +477,7 @@ si_audio_adapter_configure_node (WpSiAudioAdapter *self,
 
   /* set chosen format in the ports */
   wp_si_adapter_set_ports_format (WP_SI_ADAPTER (self),
-      wp_spa_pod_ref (ports_format), mode, on_format_set, transition);
+      g_steal_pointer (&ports_format), mode, on_format_set, g_object_ref (transition));
 }
 
 static void
@@ -591,12 +605,10 @@ si_audio_adapter_set_ports_format (WpSiAdapter * item, WpSpaPod *f,
     const gchar *mode, GAsyncReadyCallback callback, gpointer data)
 {
   WpSiAudioAdapter *self = WP_SI_AUDIO_ADAPTER (item);
-  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
   g_autoptr (WpSpaPod) format = f;
+  g_autoptr (GTask) task = g_task_new (self, NULL, callback, data);
   guint32 active = 0;
 
-  g_return_if_fail (core);
-
   /* cancel previous task if any */
   if (self->format_task) {
     g_autoptr (GTask) t = g_steal_pointer (&self->format_task);
@@ -607,17 +619,18 @@ si_audio_adapter_set_ports_format (WpSiAdapter * item, WpSpaPod *f,
   /* build default format if NULL was given */
   if (!format && !g_strcmp0 (mode, "dsp")) {
     format = build_adapter_default_format (self, mode);
-    g_return_if_fail (format);
+    if (!format) {
+      g_task_return_new_error (task, WP_DOMAIN_LIBRARY,
+          WP_LIBRARY_ERROR_OPERATION_FAILED,
+          "failed to build default format, aborting set format operation");
+      return;
+    }
   }
 
-  /* create the new task */
-  g_return_if_fail (!self->format_task);
-  self->format_task = g_task_new (self, NULL, callback, data);
-
+  /* make sure the node has WP_NODE_FEATURE_PORTS */
   active = wp_object_get_active_features (WP_OBJECT (self->node));
   if (G_UNLIKELY (!(active & WP_NODE_FEATURE_PORTS))) {
-    g_autoptr (GTask) t = g_steal_pointer (&self->format_task);
-    g_task_return_new_error (t, WP_DOMAIN_LIBRARY,
+    g_task_return_new_error (task, WP_DOMAIN_LIBRARY,
         WP_LIBRARY_ERROR_OPERATION_FAILED,
         "node feature ports is not enabled, aborting set format operation");
     return;
@@ -627,20 +640,20 @@ si_audio_adapter_set_ports_format (WpSiAdapter * item, WpSpaPod *f,
   if (!g_strcmp0 (mode, self->mode) &&
       ((format == NULL && self->format == NULL) ||
         wp_spa_pod_equal (format, self->format))) {
-    g_autoptr (GTask) t = g_steal_pointer (&self->format_task);
-    g_task_return_boolean (t, TRUE);
+    g_task_return_boolean (task, TRUE);
     return;
   }
 
-  /* set format and mode */
-  g_clear_pointer (&self->format, wp_spa_pod_unref);
-  self->format = g_steal_pointer (&format);
-  strncpy (self->mode, mode ? mode : "dsp", sizeof (self->mode) - 1);
-
   /* ensure the node is suspended */
   if (wp_node_get_state (self->node, NULL) >= WP_NODE_STATE_IDLE)
     wp_node_send_command (self->node, "Suspend");
 
+  /* set task, format and mode */
+  self->format_task = g_steal_pointer (&task);
+  g_clear_pointer (&self->format, wp_spa_pod_unref);
+  self->format = g_steal_pointer (&format);
+  strncpy (self->mode, mode ? mode : "dsp", sizeof (self->mode) - 1);
+
   /* configure DSP with chosen format */
   wp_pipewire_object_set_param (WP_PIPEWIRE_OBJECT (self->node),
       "PortConfig", 0, wp_spa_pod_new_object (
diff --git a/modules/module-si-node.c b/modules/module-si-node.c
index 35075a9e..2aa078ac 100644
--- a/modules/module-si-node.c
+++ b/modules/module-si-node.c
@@ -46,6 +46,15 @@ si_node_reset (WpSessionItem * item)
   WP_SESSION_ITEM_CLASS (si_node_parent_class)->reset (item);
 }
 
+static void
+on_proxy_destroyed (WpNode * proxy, WpSiNode * self)
+{
+  if (self->node == proxy) {
+    wp_object_abort_activation (WP_OBJECT (self), "proxy destroyed");
+    si_node_reset (WP_SESSION_ITEM (self));
+  }
+}
+
 static gboolean
 si_node_configure (WpSessionItem * item, WpProperties *p)
 {
@@ -62,6 +71,8 @@ si_node_configure (WpSessionItem * item, WpProperties *p)
     return FALSE;
 
   self->node = g_object_ref (node);
+  g_signal_connect_object (self->node, "pw-proxy-destroyed",
+      G_CALLBACK (on_proxy_destroyed), self, 0);
 
   wp_properties_set (si_props, "item.factory.name", SI_FACTORY_NAME);
   wp_session_item_set_properties (WP_SESSION_ITEM (self),
diff --git a/modules/module-si-standard-link.c b/modules/module-si-standard-link.c
index 95ec7ca5..a90eeb04 100644
--- a/modules/module-si-standard-link.c
+++ b/modules/module-si-standard-link.c
@@ -27,6 +27,8 @@ struct _WpSiStandardLink
 
   /* activate */
   GPtrArray *node_links;
+  guint n_active_links;
+  guint n_failed_links;
   guint n_async_ops_wait;
 };
 
@@ -153,6 +155,8 @@ si_standard_link_disable_active (WpSessionItem *si)
   }
 
   g_clear_pointer (&self->node_links, g_ptr_array_unref);
+  self->n_active_links = 0;
+  self->n_failed_links = 0;
   self->n_async_ops_wait = 0;
 
   wp_object_update_features (WP_OBJECT (self), 0,
@@ -164,17 +168,29 @@ on_link_activated (WpObject * proxy, GAsyncResult * res,
     WpTransition * transition)
 {
   WpSiStandardLink *self = wp_transition_get_source_object (transition);
-  g_autoptr (GError) error = NULL;
+  guint len = self->node_links->len;
 
-  if (!wp_object_activate_finish (proxy, res, &error)) {
-    wp_transition_return_error (transition, g_steal_pointer (&error));
+  /* Count the number of failed and active links */
+  if (wp_object_activate_finish (proxy, res, NULL))
+    self->n_active_links++;
+  else
+    self->n_failed_links++;
+
+  /* Wait for all links to finish activation */
+  if (self->n_failed_links + self->n_active_links != len)
     return;
-  }
 
-  self->n_async_ops_wait--;
-  if (self->n_async_ops_wait == 0)
+  /* We only active feature if all links activated successfully */
+  if (self->n_failed_links > 0) {
+    g_clear_pointer (&self->node_links, g_ptr_array_unref);
+    wp_transition_return_error (transition, g_error_new (
+        WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
+        "%d of %d PipeWire links failed to activate",
+        self->n_failed_links, len));
+  } else {
     wp_object_update_features (WP_OBJECT (self),
         WP_SESSION_ITEM_FEATURE_ACTIVE, 0);
+  }
 }
 
 struct port
@@ -225,33 +241,34 @@ static gboolean
 create_links (WpSiStandardLink * self, WpTransition * transition,
     GVariant * out_ports, GVariant * in_ports)
 {
+  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
   g_autoptr (GArray) in_ports_arr = NULL;
-  g_autoptr (WpCore) core = NULL;
   struct port out_port = {0};
   struct port *in_port;
   GVariantIter *iter = NULL;
   guint i;
 
+  /* Clear old links if any */
+  self->n_active_links = 0;
+  self->n_failed_links = 0;
+  g_clear_pointer (&self->node_links, g_ptr_array_unref);
+
   /* tuple format:
       uint32 node_id;
       uint32 port_id;
       uint32 channel;  // enum spa_audio_channel
    */
-  if (!out_ports || !g_variant_is_of_type (out_ports, G_VARIANT_TYPE("a(uuu)")))
+  if (!g_variant_is_of_type (out_ports, G_VARIANT_TYPE("a(uuu)")))
     return FALSE;
-  if (!in_ports || !g_variant_is_of_type (in_ports, G_VARIANT_TYPE("a(uuu)")))
+  if (!g_variant_is_of_type (in_ports, G_VARIANT_TYPE("a(uuu)")))
     return FALSE;
 
-  core = wp_object_get_core (WP_OBJECT (self));
-  g_return_val_if_fail (core, FALSE);
-
-  self->n_async_ops_wait = 0;
-  self->node_links = g_ptr_array_new_with_free_func (g_object_unref);
-
   i = g_variant_n_children (in_ports);
   if (i == 0)
     return FALSE;
 
+  self->node_links = g_ptr_array_new_with_free_func (g_object_unref);
+
   /* transfer the in ports to an array so that we can
      mark them when they are linked */
   in_ports_arr = g_array_sized_new (FALSE, TRUE, sizeof (struct port), i + 1);
@@ -309,7 +326,6 @@ create_links (WpSiStandardLink * self, WpTransition * transition,
     g_ptr_array_add (self->node_links, link);
 
     /* activate to ensure it is created without errors */
-    self->n_async_ops_wait++;
     wp_object_activate_closure (WP_OBJECT (link),
         WP_PIPEWIRE_OBJECT_FEATURES_MINIMAL, NULL,
         g_cclosure_new_object (
@@ -330,11 +346,23 @@ get_ports_and_create_links (WpSiStandardLink *self, WpTransition *transition)
   si_out = WP_SI_LINKABLE (g_weak_ref_get (&self->out_item));
   si_in = WP_SI_LINKABLE (g_weak_ref_get (&self->in_item));
 
-  g_return_if_fail (si_out);
-  g_return_if_fail (si_in);
+  if (!si_out || !si_in ||
+      !wp_session_item_is_configured (WP_SESSION_ITEM (si_out)) ||
+      !wp_session_item_is_configured (WP_SESSION_ITEM (si_in))) {
+    wp_transition_return_error (transition,
+        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "si-standard-link: in/out items are not valid anymore"));
+    return;
+  }
 
   out_ports = wp_si_linkable_get_ports (si_out, self->out_item_port_context);
   in_ports = wp_si_linkable_get_ports (si_in, self->in_item_port_context);
+  if (!out_ports || !in_ports) {
+    wp_transition_return_error (transition, g_error_new (WP_DOMAIN_LIBRARY,
+          WP_LIBRARY_ERROR_INVARIANT,
+          "Failed to create links because one of the nodes has no ports"));
+    return;
+  }
 
   if (!create_links (self, transition, out_ports, in_ports))
       wp_transition_return_error (transition, g_error_new (WP_DOMAIN_LIBRARY,
@@ -426,6 +454,14 @@ on_main_adapter_ready (GObject *obj, GAsyncResult * res, gpointer p)
   main = g_object_get_data (G_OBJECT (transition), "adapter_main");
   other = g_object_get_data (G_OBJECT (transition), "adapter_other");
 
+  if (!wp_session_item_is_configured (WP_SESSION_ITEM (main->si)) ||
+      !wp_session_item_is_configured (WP_SESSION_ITEM (other->si))) {
+    wp_transition_return_error (transition,
+        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "si-standard-link: in/out items are not valid anymore"));
+    return;
+  }
+
   if (self->passthrough) {
     wp_si_adapter_set_ports_format (other->si, NULL, "passthrough",
         on_adapters_ready, transition);
@@ -444,15 +480,26 @@ on_main_adapter_ready (GObject *obj, GAsyncResult * res, gpointer p)
 static void
 configure_and_link_adapters (WpSiStandardLink *self, WpTransition *transition)
 {
+  g_autoptr (WpSiAdapter) si_out =
+      WP_SI_ADAPTER (g_weak_ref_get (&self->out_item));
+  g_autoptr (WpSiAdapter) si_in =
+      WP_SI_ADAPTER (g_weak_ref_get (&self->in_item));
   struct adapter *out, *in, *main, *other;
   const gchar *str = NULL;
 
+  if (!si_out || !si_in ||
+      !wp_session_item_is_configured (WP_SESSION_ITEM (si_out)) ||
+      !wp_session_item_is_configured (WP_SESSION_ITEM (si_in))) {
+    wp_transition_return_error (transition,
+        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "si-standard-link: in/out items are not valid anymore"));
+    return;
+  }
+
   out = g_slice_new0 (struct adapter);
   in = g_slice_new0 (struct adapter);
-  out->si = WP_SI_ADAPTER (g_weak_ref_get (&self->out_item));
-  in->si = WP_SI_ADAPTER (g_weak_ref_get (&self->in_item));
-  g_return_if_fail (out->si);
-  g_return_if_fail (in->si);
+  out->si = g_steal_pointer (&si_out);
+  in->si = g_steal_pointer (&si_in);
 
   str = wp_session_item_get_property (WP_SESSION_ITEM (out->si), "item.node.type");
   out->is_device = !g_strcmp0 (str, "device");
@@ -537,6 +584,15 @@ si_standard_link_do_link (WpSiStandardLink *self, WpTransition *transition)
   g_autoptr (WpSessionItem) si_out = g_weak_ref_get (&self->out_item);
   g_autoptr (WpSessionItem) si_in = g_weak_ref_get (&self->in_item);
 
+  if (!si_out || !si_in ||
+      !wp_session_item_is_configured (si_out) ||
+      !wp_session_item_is_configured (si_in)) {
+    wp_transition_return_error (transition,
+        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
+            "si-standard-link: in/out items are not valid anymore"));
+    return;
+  }
+
   if (WP_IS_SI_ADAPTER (si_out) && WP_IS_SI_ADAPTER (si_in))
     configure_and_link_adapters (self, transition);
   else if (!WP_IS_SI_ADAPTER (si_out) && !WP_IS_SI_ADAPTER (si_in))
@@ -582,9 +638,11 @@ si_standard_link_enable_active (WpSessionItem *si, WpTransition *transition)
   /* make sure in/out items are valid */
   si_out = g_weak_ref_get (&self->out_item);
   si_in = g_weak_ref_get (&self->in_item);
-  if (!si_out || !si_in) {
+  if (!si_out || !si_in ||
+      !wp_session_item_is_configured (si_out) ||
+      !wp_session_item_is_configured (si_in)) {
     wp_transition_return_error (transition,
-        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT,
+        g_error_new (WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED,
             "si-standard-link: in/out items are not valid anymore"));
     return;
   }
diff --git a/src/config/bluetooth.conf b/src/config/bluetooth.conf
index ebd0b600..d7d2764e 100644
--- a/src/config/bluetooth.conf
+++ b/src/config/bluetooth.conf
@@ -35,6 +35,28 @@ context.modules = [
   # If nofail is given, module initialization failures are ignored.
   #
 
+  # Uses RTKit to boost the data thread priority.
+  { name = libpipewire-module-rtkit
+      args = {
+          #nice.level   = -11
+          #rt.prio      = 88
+          #rt.time.soft = 2000000
+          #rt.time.hard = 2000000
+      }
+      flags = [ ifexists nofail ]
+  }
+
+  # Set thread priorities without using RTKit.
+  #{ name = libpipewire-module-rt
+  #    args = {
+  #        nice.level   = -11
+  #        rt.prio      = 88
+  #        rt.time.soft = 2000000
+  #        rt.time.hard = 2000000
+  #    }
+  #    flags = [ ifexists nofail ]
+  #}
+
   # The native communication protocol.
   { name = libpipewire-module-protocol-native }
 
diff --git a/src/config/main.lua.d/40-device-defaults.lua b/src/config/main.lua.d/40-device-defaults.lua
index 4986db96..569ffc33 100644
--- a/src/config/main.lua.d/40-device-defaults.lua
+++ b/src/config/main.lua.d/40-device-defaults.lua
@@ -5,6 +5,9 @@ device_defaults.properties = {
   -- when set to false, default nodes and routes are selected based on
   -- their priorities and any runtime changes do not persist after restart
   ["use-persistent-storage"] = true,
+
+  -- the default volume to apply to ACP device nodes, in the linear scale
+  --["default-volume"] = 0.4,
 }
 
 function device_defaults.enable()
diff --git a/src/config/main.lua.d/50-alsa-config.lua b/src/config/main.lua.d/50-alsa-config.lua
index 43bfc114..23f8ca1b 100644
--- a/src/config/main.lua.d/50-alsa-config.lua
+++ b/src/config/main.lua.d/50-alsa-config.lua
@@ -17,6 +17,19 @@ alsa_monitor.properties = {
 
 alsa_monitor.rules = {
   -- An array of matches/actions to evaluate.
+  -- 
+  -- If you want to disable some devices or nodes, you can apply properties per device as the following example.
+  -- The name can be found by running pw-cli ls Device, or pw-cli dump Device
+  --{
+  --  matches = {
+  --    {
+  --      { "device.name", "matches", "name_of_some_disabled_card" },
+  --    },
+  --  },
+  --  apply_properties = {
+  --    ["device.disabled"] = true,
+  --  },
+  --}
   {
     -- Rules for matching a device or node. It is an array of
     -- properties that all need to match the regexp. If any of the
@@ -89,11 +102,13 @@ alsa_monitor.rules = {
       --["audio.channels"]         = 2,
       --["audio.format"]           = "S16LE",
       --["audio.rate"]             = 44100,
+      --["audio.allowed-rates"]    = "32000,96000"
       --["audio.position"]         = "FL,FR",
       --["api.alsa.period-size"]   = 1024,
       --["api.alsa.headroom"]      = 0,
       --["api.alsa.disable-mmap"]  = false,
       --["api.alsa.disable-batch"] = false,
+      --["session.suspend-timeout-seconds"] = 5,  -- 0 disables suspend
     },
   },
 }
diff --git a/src/config/main.lua.d/50-default-access-config.lua b/src/config/main.lua.d/50-default-access-config.lua
index 0282d4aa..6cf18bed 100644
--- a/src/config/main.lua.d/50-default-access-config.lua
+++ b/src/config/main.lua.d/50-default-access-config.lua
@@ -20,4 +20,12 @@ default_access.rules = {
     },
     default_permissions = "rx",
   },
+  {
+    matches = {
+      {
+        { "pipewire.access", "=", "restricted" },
+      },
+    },
+    default_permissions = "rx",
+  },
 }
diff --git a/src/config/policy.lua.d/50-endpoints-config.lua b/src/config/policy.lua.d/50-endpoints-config.lua
index 4bdf82ab..62415094 100644
--- a/src/config/policy.lua.d/50-endpoints-config.lua
+++ b/src/config/policy.lua.d/50-endpoints-config.lua
@@ -4,36 +4,92 @@
 --[[
 
 default_policy.policy.roles = {
+  ["Capture"] = {
+    ["alias"] = { "Multimedia", "Music", "Voice", "Capture" },
+    ["priority"] = 25,
+    ["action.default"] = "cork",
+    ["action.capture"] = "mix",
+    ["media.class"] = "Audio/Source",
+  },
   ["Multimedia"] = {
     ["alias"] = { "Movie", "Music", "Game" },
-    ["priority"] = 10,
-    ["action.default"] = "mix",
+    ["priority"] = 25,
+    ["action.default"] = "cork",
+  },
+  ["Speech-Low"] = {
+    ["priority"] = 30,
+    ["action.default"] = "cork",
+    ["action.Speech-Low"] = "mix",
+  },
+  ["Custom-Low"] = {
+    ["priority"] = 35,
+    ["action.default"] = "cork",
+    ["action.Custom-Low"] = "mix",
   },
-  ["Notification"] = {
-    ["priority"] = 20,
+  ["Navigation"] = {
+    ["priority"] = 50,
     ["action.default"] = "duck",
-    ["action.Notification"] = "mix",
+    ["action.Navigation"] = "mix",
   },
-  ["Alert"] = {
-    ["priority"] = 30,
+  ["Speech-High"] = {
+    ["priority"] = 60,
+    ["action.default"] = "cork",
+    ["action.Speech-High"] = "mix",
+  },
+  ["Custom-High"] = {
+    ["priority"] = 65,
     ["action.default"] = "cork",
-    ["action.Alert"] = "mix",
+    ["action.Custom-High"] = "mix",
+  },
+  ["Communication"] = {
+    ["priority"] = 75,
+    ["action.default"] = "cork",
+    ["action.Communication"] = "mix",
+  },
+  ["Emergency"] = {
+    ["alias"] = { "Alert" },
+    ["priority"] = 99,
+    ["action.default"] = "cork",
+    ["action.Emergency"] = "mix",
   },
 }
 
 default_policy.endpoints = {
+  ["endpoint.capture"] = {
+    ["media.class"] = "Audio/Source",
+    ["role"] = "Capture",
+  },
   ["endpoint.multimedia"] = {
     ["media.class"] = "Audio/Sink",
     ["role"] = "Multimedia",
   },
-  ["endpoint.notifications"] = {
+  ["endpoint.speech_low"] = {
+    ["media.class"] = "Audio/Sink",
+    ["role"] = "Speech-Low",
+  },
+  ["endpoint.custom_low"] = {
     ["media.class"] = "Audio/Sink",
-    ["role"] = "Notification",
+    ["role"] = "Custom-Low",
   },
-  ["endpoint.alert"] = {
+  ["endpoint.navigation"] = {
     ["media.class"] = "Audio/Sink",
-    ["role"] = "Alert",
+    ["role"] = "Navigation",
+  },
+  ["endpoint.speech_high"] = {
+    ["media.class"] = "Audio/Sink",
+    ["role"] = "Speech-High",
+  },
+  ["endpoint.custom_high"] = {
+    ["media.class"] = "Audio/Sink",
+    ["role"] = "Custom-High",
+  },
+  ["endpoint.communication"] = {
+    ["media.class"] = "Audio/Sink",
+    ["role"] = "Communication",
+  },
+  ["endpoint.emergency"] = {
+    ["media.class"] = "Audio/Sink",
+    ["role"] = "Emergency",
   },
 }
-
-]]--
+]]--
\ No newline at end of file
diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf
index 0ac65c73..5658c961 100644
--- a/src/config/wireplumber.conf
+++ b/src/config/wireplumber.conf
@@ -38,6 +38,28 @@ context.modules = [
   # If nofail is given, module initialization failures are ignored.
   #
 
+  # Uses RTKit to boost the data thread priority.
+  { name = libpipewire-module-rtkit
+      args = {
+          #nice.level   = -11
+          #rt.prio      = 88
+          #rt.time.soft = 2000000
+          #rt.time.hard = 2000000
+      }
+      flags = [ ifexists nofail ]
+  }
+
+  # Set thread priorities without using RTKit.
+  #{ name = libpipewire-module-rt
+  #    args = {
+  #        nice.level   = -11
+  #        rt.prio      = 88
+  #        rt.time.soft = 2000000
+  #        rt.time.hard = 2000000
+  #    }
+  #    flags = [ ifexists nofail ]
+  #}
+
   # The native communication protocol.
   { name = libpipewire-module-protocol-native }
 
diff --git a/src/main.c b/src/main.c
index 8fefa886..e1970b69 100644
--- a/src/main.c
+++ b/src/main.c
@@ -151,7 +151,7 @@ wp_init_transition_execute_step (WpTransition * transition, guint step)
     while (spa_json_enter_object(&it[1], &it[2]) > 0) {
       char *name = NULL, *type = NULL;
 
-      while (spa_json_get_string(&it[2], key, sizeof(key)-1) > 0) {
+      while (spa_json_get_string(&it[2], key, sizeof(key)) > 0) {
         const char *val;
         int len;
 
@@ -160,10 +160,10 @@ wp_init_transition_execute_step (WpTransition * transition, guint step)
 
         if (strcmp(key, "name") == 0) {
           name = (char*)val;
-          spa_json_parse_string(val, len, name);
+          spa_json_parse_stringn(val, len, name, len+1);
         } else if (strcmp(key, "type") == 0) {
           type = (char*)val;
-          spa_json_parse_string(val, len, type);
+          spa_json_parse_stringn(val, len, type, len+1);
         }
       }
       if (name == NULL || type == NULL) {
diff --git a/src/scripts/access/access-default.lua b/src/scripts/access/access-default.lua
index 3c27e90a..0fac87b7 100644
--- a/src/scripts/access/access-default.lua
+++ b/src/scripts/access/access-default.lua
@@ -5,7 +5,7 @@
 --
 -- SPDX-License-Identifier: MIT
 
-local config = ...
+local config = ... or {}
 
 -- preprocess rules and create Interest objects
 for _, r in ipairs(config.rules or {}) do
diff --git a/src/scripts/create-item.lua b/src/scripts/create-item.lua
index 9f2bc862..f2c75623 100644
--- a/src/scripts/create-item.lua
+++ b/src/scripts/create-item.lua
@@ -6,7 +6,7 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 
 items = {}
 
@@ -22,6 +22,8 @@ function configProperties(node)
     ["client.id"] = np["client.id"],
     ["object.path"] = np["object.path"],
     ["priority.session"] = np["priority.session"],
+    ["device.id"] = np["device.id"],
+    ["card.profile.device"] = np["card.profile.device"],
   }
 
   for k, v in pairs(np) do
@@ -67,9 +69,13 @@ function addItem (node, item_type)
   end
 
   -- activate item
-  items[id]:activate (Features.ALL, function (item)
-    Log.info(item, "activated item for node " .. tostring(id))
-    item:register ()
+  items[id]:activate (Features.ALL, function (item, e)
+    if e then
+      Log.message(item, "failed to activate item: " .. tostring(e));
+    else
+      Log.info(item, "activated item for node " .. tostring(id))
+      item:register ()
+   end
   end)
 end
 
diff --git a/src/scripts/default-routes.lua b/src/scripts/default-routes.lua
index b55ae4e0..075f5d63 100644
--- a/src/scripts/default-routes.lua
+++ b/src/scripts/default-routes.lua
@@ -8,11 +8,14 @@
 --
 -- SPDX-License-Identifier: MIT
 
-local config = ...
+local config = ... or {}
 
 -- whether to store state on the file system
 use_persistent_storage = config["use-persistent-storage"] or false
 
+-- the default volume to apply
+default_volume = tonumber(config["default-volume"] or 0.4)
+
 -- table of device info
 dev_infos = {}
 
@@ -130,7 +133,7 @@ function restoreRoute(device, dev_info, device_id, route)
   -- default props
   local props = {
     "Spa:Pod:Object:Param:Props", "Route",
-    channelVolumes = { 0.4 },
+    channelVolumes = { default_volume },
     mute = false,
   }
 
@@ -349,6 +352,12 @@ function handleDevice(device)
   local dev_info = dev_infos[device["bound-id"]]
   local new_route_infos = {}
   local avail_routes_changed = false
+  local profile = nil
+
+  -- get current profile
+  for p in device:iterate_params("Profile") do
+    profile = parseParam(p, "Profile")
+  end
 
   -- look at all the routes and update/reset cached information
   for p in device:iterate_params("EnumRoute") do
@@ -367,7 +376,9 @@ function handleDevice(device)
       Log.info(device, "route " .. route.name .. " available changed " ..
                        route_info.available .. " -> " .. route.available)
       route_info.available = route.available
-      avail_routes_changed = true
+      if profile and arrayContains(route.profiles, profile.index) then
+        avail_routes_changed = true
+      end
     end
     route_info.prev_active = route_info.active
     route_info.active = false
@@ -416,23 +427,20 @@ function handleDevice(device)
     ::skip_route::
   end
 
-  -- now get the profile and restore routes for it
-  for p in device:iterate_params("Profile") do
-    local profile = parseParam(p, "Profile")
-    if profile then
-      local profile_changed = (dev_info.active_profile ~= profile.index)
-
-      -- if the profile changed, restore routes for that profile
-      -- if any of the routes of the current profile changed in availability,
-      -- then try to select a new "best" route for each device and ignore
-      -- what was stored
-      if profile_changed or avail_routes_changed then
-        dev_info.active_profile = profile.index
-        restoreProfileRoutes(device, dev_info, profile, profile_changed)
-      end
-
-      saveProfile(dev_info, profile.name)
+  -- restore routes for profile
+  if profile then
+    local profile_changed = (dev_info.active_profile ~= profile.index)
+
+    -- if the profile changed, restore routes for that profile
+    -- if any of the routes of the current profile changed in availability,
+    -- then try to select a new "best" route for each device and ignore
+    -- what was stored
+    if profile_changed or avail_routes_changed then
+      dev_info.active_profile = profile.index
+      restoreProfileRoutes(device, dev_info, profile, profile_changed)
     end
+
+    saveProfile(dev_info, profile.name)
   end
 end
 
diff --git a/src/scripts/monitors/alsa-midi.lua b/src/scripts/monitors/alsa-midi.lua
index bf4fde47..0efee345 100644
--- a/src/scripts/monitors/alsa-midi.lua
+++ b/src/scripts/monitors/alsa-midi.lua
@@ -6,7 +6,7 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 
 -- ensure config.properties is not nil
 config.properties = config.properties or {}
diff --git a/src/scripts/monitors/alsa.lua b/src/scripts/monitors/alsa.lua
index 18ed6145..3e8eda83 100644
--- a/src/scripts/monitors/alsa.lua
+++ b/src/scripts/monitors/alsa.lua
@@ -6,7 +6,7 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 
 -- ensure config.properties is not nil
 config.properties = config.properties or {}
@@ -168,6 +168,9 @@ function createNode(parent, id, type, factory, properties)
 
   -- apply properties from config.rules
   rulesApplyProperties(properties)
+  if properties["node.disabled"] then
+    return
+  end
 
   -- create the node
   local node = Node("adapter", properties)
@@ -177,9 +180,13 @@ end
 
 function createDevice(parent, id, factory, properties)
   local device = SpaDevice(factory, properties)
-  device:connect("create-object", createNode)
-  device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
-  parent:store_managed_object(id, device)
+  if device then
+    device:connect("create-object", createNode)
+    device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
+    parent:store_managed_object(id, device)
+  else
+    Log.warning ("Failed to create '" .. factory .. "' device")
+  end
 end
 
 function prepareDevice(parent, id, type, factory, properties)
@@ -250,6 +257,9 @@ function prepareDevice(parent, id, type, factory, properties)
 
   -- apply properties from config.rules
   rulesApplyProperties(properties)
+  if properties["device.disabled"] then
+    return
+  end
 
   -- override the device factory to use ACP
   if properties["api.alsa.use-acp"] then
@@ -287,6 +297,12 @@ function prepareDevice(parent, id, type, factory, properties)
       end
     end)
 
+    rd:connect("release-requested", function (rd)
+        Log.info("release requested")
+        parent:store_managed_object(id, nil)
+        rd:call("release")
+    end)
+
     if jack_device then
       rd:connect("notify::owner-name-changed", function (rd, pspec)
         if rd["state"] == "busy" and
@@ -309,7 +325,7 @@ function createMonitor ()
   local m = SpaDevice("api.alsa.enum.udev", config.properties)
   if m == nil then
     Log.message("PipeWire's SPA ALSA udev plugin(\"api.alsa.enum.udev\")"
-      .. "missing or broken. Sound Cards Cannot be enumerated")
+      .. "missing or broken. Sound Cards cannot be enumerated")
     return nil
   end
 
diff --git a/src/scripts/monitors/bluez.lua b/src/scripts/monitors/bluez.lua
index 172e013d..4a54a7b2 100644
--- a/src/scripts/monitors/bluez.lua
+++ b/src/scripts/monitors/bluez.lua
@@ -109,8 +109,13 @@ function createDevice(parent, id, type, factory, properties)
 
     -- create the device
     device = SpaDevice(factory, properties)
-    device:connect("create-object", createNode)
-    parent:store_managed_object(id, device)
+    if device then
+      device:connect("create-object", createNode)
+      parent:store_managed_object(id, device)
+    else
+      Log.warning ("Failed to create '" .. factory .. "' device")
+      return
+    end
   end
 
   Log.info(parent, string.format("%d, %s (%s): %s",
diff --git a/src/scripts/monitors/libcamera.lua b/src/scripts/monitors/libcamera.lua
index 7209c620..27c74597 100644
--- a/src/scripts/monitors/libcamera.lua
+++ b/src/scripts/monitors/libcamera.lua
@@ -125,12 +125,16 @@ function createDevice(parent, id, type, factory, properties)
 
   -- create the device
   local device = SpaDevice(factory, properties)
-  device:connect("create-object", createNode)
-  device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
-  parent:store_managed_object(id, device)
+  if device then
+    device:connect("create-object", createNode)
+    device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
+    parent:store_managed_object(id, device)
+  else
+    Log.warning ("Failed to create '" .. factory .. "' device")
+  end
 end
 
-monitor = SpaDevice("api.libcamera.enum.client", config.properties or {})
+monitor = SpaDevice("api.libcamera.enum.manager", config.properties or {})
 if monitor then
   monitor:connect("create-object", createDevice)
   monitor:activate(Feature.SpaDevice.ENABLED)
diff --git a/src/scripts/monitors/v4l2.lua b/src/scripts/monitors/v4l2.lua
index fd9a20db..102eb810 100644
--- a/src/scripts/monitors/v4l2.lua
+++ b/src/scripts/monitors/v4l2.lua
@@ -125,9 +125,13 @@ function createDevice(parent, id, type, factory, properties)
 
   -- create the device
   local device = SpaDevice(factory, properties)
-  device:connect("create-object", createNode)
-  device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
-  parent:store_managed_object(id, device)
+  if device then
+    device:connect("create-object", createNode)
+    device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
+    parent:store_managed_object(id, device)
+  else
+    Log.warning ("Failed to create '" .. factory .. "' device")
+  end
 end
 
 monitor = SpaDevice("api.v4l2.enum.udev", config.properties or {})
diff --git a/src/scripts/policy-endpoint-client-links.lua b/src/scripts/policy-endpoint-client-links.lua
index 1a932940..eaa1c088 100644
--- a/src/scripts/policy-endpoint-client-links.lua
+++ b/src/scripts/policy-endpoint-client-links.lua
@@ -5,7 +5,7 @@
 --
 -- SPDX-License-Identifier: MIT
 
-local config = ...
+local config = ... or {}
 config.roles = config.roles or {}
 config["duck.level"] = config["duck.level"] or 0.3
 
diff --git a/src/scripts/policy-endpoint-client.lua b/src/scripts/policy-endpoint-client.lua
index d32de5bb..487d4b4a 100644
--- a/src/scripts/policy-endpoint-client.lua
+++ b/src/scripts/policy-endpoint-client.lua
@@ -6,15 +6,43 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 config.roles = config.roles or {}
 
-local pending_rescan = false
+local self = {}
+self.scanning = false
+self.pending_rescan = false
 
-function findRole(role)
+function rescan ()
+  for si in linkables_om:iterate() do
+    handleLinkable (si)
+  end
+end
+
+function scheduleRescan ()
+  if self.scanning then
+    self.pending_rescan = true
+    return
+  end
+
+  self.scanning = true
+  rescan ()
+  self.scanning = false
+
+  if self.pending_rescan then
+    self.pending_rescan = false
+    Core.sync(function ()
+      scheduleRescan ()
+    end)
+  end
+end
+
+function findRole(role, tmc)
   if role and not config.roles[role] then
     for r, p in pairs(config.roles) do
-      if type(p.alias) == "table" then
+      -- default media class can be overridden in the role config data
+      mc = p["media.class"] or "Audio/Sink"
+      if (type(p.alias) == "table" and tmc == mc) then
         for i = 1, #(p.alias), 1 do
           if role == p.alias[i] then
             return r
@@ -43,7 +71,7 @@ function findTargetEndpoint (node, media_class)
   end
 
   -- find highest priority endpoint by role
-  media_role = findRole(node.properties["media.role"])
+  media_role = findRole(node.properties["media.role"], target_media_class)
   for si_target_ep in endpoints_om:iterate {
     Constraint { "role", "=", media_role, type = "pw-global" },
     Constraint { "media.class", "=", target_media_class, type = "pw-global" },
@@ -158,7 +186,7 @@ function handleLinkable (si)
             link:remove ()
             Log.info (si, "... moving to new target")
           else
-            pending_rescan = true
+            scheduleRescan ()
             Log.info (si, "... scheduled rescan")
             return
           end
@@ -191,20 +219,6 @@ function unhandleLinkable (si)
   end
 end
 
-function rescan ()
-  for si in linkables_om:iterate() do
-    handleLinkable (si)
-  end
-
-  -- if pending_rescan, re-evaluate after sync
-  if pending_rescan then
-    pending_rescan = false
-    Core.sync (function (c)
-      rescan()
-    end)
-  end
-end
-
 endpoints_om = ObjectManager { Interest { type = "SiEndpoint" }}
 linkables_om = ObjectManager { Interest { type = "SiLinkable",
   -- only handle si-audio-adapter and si-node
@@ -218,7 +232,7 @@ links_om = ObjectManager { Interest { type = "SiLink",
 } }
 
 linkables_om:connect("objects-changed", function (om)
-  rescan ()
+  scheduleRescan ()
 end)
 
 linkables_om:connect("object-removed", function (om, si)
diff --git a/src/scripts/policy-endpoint-device.lua b/src/scripts/policy-endpoint-device.lua
index 04e3bc62..71f77727 100644
--- a/src/scripts/policy-endpoint-device.lua
+++ b/src/scripts/policy-endpoint-device.lua
@@ -6,13 +6,40 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 
 -- ensure config.move and config.follow are not nil
 config.move = config.move or false
 config.follow = config.follow or false
 
-local pending_rescan = false
+local self = {}
+self.scanning = false
+self.pending_rescan = false
+
+function rescan ()
+  -- check endpoints and register new links
+  for si_ep in endpoints_om:iterate() do
+    handleEndpoint (si_ep)
+  end
+end
+
+function scheduleRescan ()
+  if self.scanning then
+    self.pending_rescan = true
+    return
+  end
+
+  self.scanning = true
+  rescan ()
+  self.scanning = false
+
+  if self.pending_rescan then
+    self.pending_rescan = false
+    Core.sync(function ()
+      scheduleRescan ()
+    end)
+  end
+end
 
 function findTargetByDefaultNode (target_media_class)
   local def_id = default_nodes:call("get-default-node", target_media_class)
@@ -133,7 +160,7 @@ function handleEndpoint (si_ep)
             link:remove ()
             Log.info (si_ep, "... moving to new target")
           else
-            pending_rescan = true
+            scheduleRescan ()
             Log.info (si_ep, "... scheduled rescan")
             return
           end
@@ -164,21 +191,6 @@ function unhandleLinkable (si)
   end
 end
 
-function rescan ()
-  -- check endpoints and register new links
-  for si_ep in endpoints_om:iterate() do
-    handleEndpoint (si_ep)
-  end
-
-  -- if pending_rescan, re-evaluate after sync
-  if pending_rescan then
-    pending_rescan = false
-    Core.sync (function (c)
-      rescan()
-    end)
-  end
-end
-
 default_nodes = Plugin.find("default-nodes-api")
 endpoints_om = ObjectManager { Interest { type = "SiEndpoint" }}
 linkables_om = ObjectManager {
@@ -200,12 +212,12 @@ links_om = ObjectManager {
 -- listen for default node changes if config.follow is enabled
 if config.follow then
   default_nodes:connect("changed", function (p)
-    rescan()
+    scheduleRescan ()
   end)
 end
 
 linkables_om:connect("objects-changed", function (om)
-  rescan()
+  scheduleRescan ()
 end)
 
 linkables_om:connect("object-removed", function (om, si)
diff --git a/src/scripts/policy-node.lua b/src/scripts/policy-node.lua
index b2e1e143..51a55490 100644
--- a/src/scripts/policy-node.lua
+++ b/src/scripts/policy-node.lua
@@ -6,13 +6,39 @@
 -- SPDX-License-Identifier: MIT
 
 -- Receive script arguments from config.lua
-local config = ...
+local config = ... or {}
 
 -- ensure config.move and config.follow are not nil
 config.move = config.move or false
 config.follow = config.follow or false
 
-local pending_rescan = false
+local self = {}
+self.scanning = false
+self.pending_rescan = false
+
+function rescan()
+  for si in linkables_om:iterate() do
+    handleLinkable (si)
+  end
+end
+
+function scheduleRescan ()
+  if self.scanning then
+    self.pending_rescan = true
+    return
+  end
+
+  self.scanning = true
+  rescan ()
+  self.scanning = false
+
+  if self.pending_rescan then
+    self.pending_rescan = false
+    Core.sync(function ()
+      scheduleRescan ()
+    end)
+  end
+end
 
 function parseBool(var)
   return var and (var:lower() == "true" or var == "1")
@@ -82,12 +108,16 @@ function createLink (si, si_target, passthrough, exclusive)
   -- activate
   si_link:activate (Feature.SessionItem.ACTIVE, function (l, e)
     if e then
-      Log.warning (l, "failed to activate si-standard-link: " .. tostring(e))
-      si_flags[si_id].peer_id = nil
+      Log.info (l, "failed to activate si-standard-link: " .. tostring(e))
+      if si_flags[si_id] ~= nil then
+        si_flags[si_id].peer_id = nil
+      end
       l:remove ()
     else
-      si_flags[si_id].failed_peer_id = nil
-      si_flags[si_id].failed_count = 0
+      if si_flags[si_id] ~= nil then
+        si_flags[si_id].failed_peer_id = nil
+        si_flags[si_id].failed_count = 0
+      end
       Log.info (l, "activated si-standard-link")
     end
   end)
@@ -262,9 +292,112 @@ function findDefinedTarget (properties)
   return nil
 end
 
--- User or client defined target is not available, Loop through all the valid
--- linkables(nodes) and pick an appropriate one.
-function findUndefinedTarget (si)
+function parseParam(param, id)
+  local route = param:parse()
+  if route.pod_type == "Object" and route.object_id == id then
+    return route.properties
+  else
+    return nil
+  end
+end
+
+function arrayContains(a, value)
+  for _, v in ipairs(a) do
+    if v == value then
+      return true
+    end
+  end
+  return false
+end
+
+
+-- Does the target device have any active/available paths/routes to
+-- the physical device(spkr/mic/cam)?
+function haveAvailableRoutes (si_props)
+  local card_profile_device = si_props["card.profile.device"]
+  local device_id = si_props["device.id"]
+  local device = device_id and devices_om:lookup {
+    Constraint { "bound-id", "=", device_id, type = "gobject"},
+  }
+
+  if not card_profile_device or not device then
+    return true
+  end
+
+  local found = 0
+  local avail = 0
+
+  -- First check "SPA_PARAM_Route" if there are any active devices
+  -- in an active profile.
+  for p in device:iterate_params("Route") do
+    local route = parseParam(p, "Route")
+    if not route then
+      goto skip_route
+    end
+
+    if (route.device ~= tonumber(card_profile_device)) then
+      goto skip_route
+    end
+
+    if (route.available == "no") then
+      return false
+    end
+
+    do return true end
+
+    ::skip_route::
+  end
+
+  -- Second check "SPA_PARAM_EnumRoute" if there is any route that
+  -- is available if not active.
+  for p in device:iterate_params("EnumRoute") do
+    local route = parseParam(p, "EnumRoute")
+    if not route then
+      goto skip_enum_route
+    end
+
+    if not arrayContains(route.devices, tonumber(card_profile_device)) then
+      goto skip_enum_route
+    end
+    found = found + 1;
+    if (route.available ~= "no") then
+      avail = avail +1
+    end
+    ::skip_enum_route::
+  end
+
+  if found == 0 then
+    return true
+  end
+  if avail > 0 then
+    return true
+  end
+
+  return false
+
+end
+
+function findDefaultlinkable (si)
+  local si_props = si.properties
+  local target_direction = getTargetDirection(si_props)
+  local def_node_id = getDefaultNode(si_props, target_direction)
+  local si_target = linkables_om:lookup {
+      Constraint { "node.id", "=", tostring(def_node_id) }
+  }
+
+  if si_target ~= nil then
+    local can_passthrough = canPassthrough(si, si_target)
+    Log.info(string.format("... default target picked: %s (%s), can_passthrough:%s",
+        tostring(si_target.properties["node.name"]),
+        tostring(si_target.properties["node.id"]),
+        tostring(can_passthrough)))
+    return si_target, can_passthrough
+  end
+
+  return nil, nil
+end
+
+function findBestLinkable (si)
   local si_props = si.properties
   local target_direction = getTargetDirection(si_props)
   local target_picked = nil
@@ -279,6 +412,7 @@ function findUndefinedTarget (si)
   } do
     local si_target_props = si_target.properties
     local si_target_node_id = si_target_props["node.id"]
+    local priority = tonumber(si_target_props["priority.session"]) or 0
 
     Log.debug(string.format("Looking at: %s (%s)",
         tostring(si_target_props["node.name"]),
@@ -289,14 +423,9 @@ function findUndefinedTarget (si)
       goto skip_linkable
     end
 
-    local priority = tonumber(si_target_props["priority.session"]) or 0
-
-    -- Is this linkable(node) a default one?
-    local def_node_id = getDefaultNode(si_props, target_direction)
-    if tostring(def_node_id) == si_target_node_id then
-      Log.debug(string.format("... this (%s) is the default node for %s",
-          def_node_id, target_direction))
-      priority = priority + 10000
+    if not haveAvailableRoutes(si_target_props) then
+      Log.debug("... does not have routes, skip linkable")
+      goto skip_linkable
     end
 
     -- todo:check if this linkable(node/device) have valid routes.
@@ -336,7 +465,7 @@ function findUndefinedTarget (si)
   end
 
   if target_picked then
-    Log.info(string.format("... target: %s (%s), can_passthrough:%s",
+    Log.info(string.format("... best target picked: %s (%s), can_passthrough:%s",
       tostring(target_picked.properties["node.name"]),
       tostring(target_picked.properties["node.id"]),
       tostring(target_can_passthrough)))
@@ -347,6 +476,25 @@ function findUndefinedTarget (si)
 
 end
 
+function findUndefinedTarget (si)
+  -- Just find the best linkable if default nodes module is not loaded
+  if default_nodes == nil then
+    return findBestLinkable (si)
+  end
+
+  -- Otherwise find the default linkable. If the default linkabke cannot link,
+  -- we find the best one instead. We return nil if default does not exist.
+  local si_target, can_passthrough = findDefaultlinkable (si)
+  if si_target then
+    if canLink (si.properties, si_target) then
+      return si_target, can_passthrough
+    else
+      return findBestLinkable (si)
+    end
+  end
+  return nil, nil
+end
+
 function lookupLink (si_id, si_target_id)
   local link = links_om:lookup {
     Constraint { "out.item.id", "=", si_id },
@@ -413,25 +561,16 @@ function handleLinkable (si)
     si_target = nil
   end
 
-  -- wait up to 2 seconds for the requested target to become available
-  -- this is because the client may have already "seen" a target that we haven't
-  -- yet prepared, which leads to a race condition
+  -- if the client has seen a target that we haven't yet prepared, schedule
+  -- a rescan one more time and hope for the best
   local si_id = si.id
   if si_props["node.target"] and si_props["node.target"] ~= "-1"
       and not si_target
       and not si_flags[si_id].was_handled
       and not si_flags[si_id].done_waiting then
-    if not si_flags[si_id].timeout_source then
-      si_flags[si_id].timeout_source = Core.timeout_add(2000, function()
-        if si_flags[si_id] then
-          si_flags[si_id].done_waiting = true
-          si_flags[si_id].timeout_source = nil
-          rescan()
-        end
-        return false
-      end)
-    end
     Log.info (si, "... waiting for target")
+    si_flags[si_id].done_waiting = true
+    scheduleRescan()
     return
   end
 
@@ -455,7 +594,7 @@ function handleLinkable (si)
           link:remove ()
           Log.info (si, "... moving to new target")
         else
-          pending_rescan = true
+          scheduleRescan()
           Log.info (si, "... scheduled rescan")
           return
         end
@@ -495,6 +634,9 @@ function handleLinkable (si)
     if not reconnect then
       Log.info (si, "... destroy node")
       node:request_destroy()
+    elseif si_flags[si.id].was_handled then
+      Log.info (si, "... waiting reconnect")
+      return
     end
 
     local client_id = node.properties["client.id"]
@@ -541,20 +683,6 @@ function unhandleLinkable (si)
   si_flags[si.id] = nil
 end
 
-function rescan()
-  for si in linkables_om:iterate() do
-    handleLinkable (si)
-  end
-
-  -- if pending_rescan, re-evaluate after sync
-  if pending_rescan then
-    pending_rescan = false
-    Core.sync (function (c)
-      rescan()
-    end)
-  end
-end
-
 default_nodes = Plugin.find("default-nodes-api")
 
 metadata_om = ObjectManager {
@@ -568,6 +696,9 @@ endpoints_om = ObjectManager { Interest { type = "SiEndpoint" } }
 
 clients_om = ObjectManager { Interest { type = "client" } }
 
+devices_om = ObjectManager { Interest { type = "device" } }
+
+
 linkables_om = ObjectManager {
   Interest {
     type = "SiLinkable",
@@ -586,7 +717,7 @@ links_om = ObjectManager {
 
 function cleanupTargetNodeMetadata()
   local metadata = metadata_om:lookup()
-  if metadata then
+  if metadata and default_nodes ~= nil then
     local to_remove = {}
     for s, k, t, v in metadata:iterate(Id.ANY) do
       if k == "target.node" then
@@ -613,10 +744,10 @@ function cleanupTargetNodeMetadata()
 end
 
 -- listen for default node changes if config.follow is enabled
-if config.follow then
+if config.follow and default_nodes ~= nil then
   default_nodes:connect("changed", function ()
     cleanupTargetNodeMetadata()
-    rescan()
+    scheduleRescan ()
   end)
 end
 
@@ -625,18 +756,25 @@ if config.move then
   metadata_om:connect("object-added", function (om, metadata)
     metadata:connect("changed", function (m, subject, key, t, value)
       if key == "target.node" then
-        rescan()
+        scheduleRescan ()
       end
     end)
   end)
 end
 
-linkables_om:connect("objects-changed", function (om)
-  rescan()
+linkables_om:connect("object-added", function (om, si)
+  if si.properties["item.node.type"] ~= "stream" then
+    scheduleRescan ()
+  else
+    handleLinkable (si)
+  end
 end)
 
 linkables_om:connect("object-removed", function (om, si)
   unhandleLinkable (si)
+  if si.properties["item.node.type"] ~= "stream" then
+    scheduleRescan ()
+  end
 end)
 
 metadata_om:activate()
@@ -644,3 +782,4 @@ endpoints_om:activate()
 clients_om:activate()
 linkables_om:activate()
 links_om:activate()
+devices_om:activate()
diff --git a/src/scripts/suspend-node.lua b/src/scripts/suspend-node.lua
index 90689300..b051d46f 100644
--- a/src/scripts/suspend-node.lua
+++ b/src/scripts/suspend-node.lua
@@ -25,11 +25,11 @@ om:connect("object-added", function (om, node)
       sources[id] = nil
     end
 
-    -- Add a timeout source if idle for at least 3 seconds
+    -- Add a timeout source if idle for at least 5 seconds
     if cur_state == "idle" then
       -- honor "session.suspend-timeout-seconds" if specified
       local timeout =
-          tonumber(node.properties["session.suspend-timeout-seconds"]) or 3
+          tonumber(node.properties["session.suspend-timeout-seconds"]) or 5
 
       if timeout == 0 then
         return
@@ -45,7 +45,7 @@ om:connect("object-added", function (om, node)
         sources[id] = nil
 
         -- false (== G_SOURCE_REMOVE) destroys the source so that this
-        -- function does not get fired again after 3 seconds
+        -- function does not get fired again after 5 seconds
         return false
       end)
     end
-- 
GitLab