New Upstream Release - bluez-alsa

Ready changes

Summary

Merged new upstream version: 4.1.0 (was: 4.0.0).

Diff

diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md
new file mode 100644
index 0000000..91ead5b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-bug-report.md
@@ -0,0 +1,36 @@
+---
+name: "\U0001F41B Bug report"
+about: Create a report to help improve bluez-alsa.
+title:
+---
+
+> Please read the [troubleshooting guide](../blob/master/TROUBLESHOOTING.md)
+> before raising a new issue.
+
+### Problem
+
+> A clear and concise description of what the bug is.
+> If possible, please check if the bug still exists in the master branch (or
+> the latest release if you are not using it already).
+
+### Reproduction steps
+
+> Provide a minimal example of how to reproduce the problem. State `bluealsa`
+> command line arguments and the content of .asoundrc file (if PCM alias with
+> "type bluealsa" was added to that file).
+
+### Setup
+
+> - the OS distribution and version
+> - the version of BlueALSA (`bluealsa --version`)
+> - the version of BlueZ (`bluetoothd --version`)
+> - the version of ALSA (`aplay --version`)
+> - if self-built from source, please state the branch and commit
+> (`git log -1 --oneline`), and the used configure options.
+
+### Additional context
+
+> Add any other context about the problem here, e.g. log messages printed by
+> `bluealsa` and/or client application.
+>
+> Please delete instructions prefixed with '>' to prove you have read them.
diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.md b/.github/ISSUE_TEMPLATE/2-feature-request.md
new file mode 100644
index 0000000..5650185
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2-feature-request.md
@@ -0,0 +1,17 @@
+---
+name: "\U0001F4A1 Feature request"
+about: Suggest an idea for bluez-alsa.
+labels: enhancement
+title:
+---
+
+### Feature description
+
+> A clear and concise description of what the problem is. Provide a simple use
+> case for requested functionality or enhancement.
+
+## Additional context
+
+> Add any other context about the feature request.
+>
+> Please delete instructions prefixed with '>' to prove you have read them.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..2b07c53
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,9 @@
+> Please have a good description of the problem and the fix. Help the reviewer
+> understand what to expect.
+>
+> For new feature, extensive change or non-trivial bug fix add a simple unit
+> test or at least describe how to test the code manually.
+>
+> If you have an issue number, please reference it with a syntax `Fixes #123`.
+>
+> Please delete instructions prefixed with '>' to prove you have read them.
diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
new file mode 100644
index 0000000..e0d1c27
--- /dev/null
+++ b/.github/dependabot.yaml
@@ -0,0 +1,11 @@
+# BlueALSA - dependabot.yaml
+# Copyright (c) 2016-2023 Arkadiusz Bokowy
+
+version: 2
+updates:
+
+  # Check for updates to GitHub Actions every week
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
diff --git a/.github/iwyu.imp b/.github/iwyu.imp
new file mode 100644
index 0000000..b0eabbd
--- /dev/null
+++ b/.github/iwyu.imp
@@ -0,0 +1,34 @@
+[
+
+	{ include: [ '<bits/getopt_core.h>', private, '<getopt.h>', public ] },
+	{ include: [ '<bsd/libutil.h>', private, '<bsd/stdlib.h>', public ] },
+	{ include: [ '<bsd/sys/time.h>', private, '<sys/time.h>', public ] },
+	{ symbol: [ 'sig_atomic_t', private, '<signal.h>', public ] },
+
+	{ include: [ '<alsa/conf.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/control.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/error.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/global.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/input.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/mixer.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/output.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/pcm.h>', private, '<alsa/asoundlib.h>', public ] },
+	{ include: [ '<alsa/pcm_ioplug.h>', private, '<alsa/pcm_external.h>', public ] },
+	{ include: [ '<alsa/version.h>', private, '<alsa/asoundlib.h>', public ] },
+
+	{ include: [ '"dbus/dbus-memory.h"', private, '<dbus/dbus.h>', public ] },
+	{ include: [ '"dbus/dbus-protocol.h"', private, '<dbus/dbus.h>', public ] },
+	{ include: [ '"dbus/dbus-shared.h"', private, '<dbus/dbus.h>', public ] },
+
+	{ include: [ '<fdk-aac/FDK_audio.h>', private, '<fdk-aac/aacenc_lib.h>', public ] },
+
+	{ include: [ '<glib/gtypes.h>', private, '<glib.h>', public ] },
+	{ include: [ '"gio/gdbusinterfaceskeleton.h"', private, '<gio/gio.h>', public ] },
+	{ include: [ '"gio/gdbusobjectmanagerserver.h"', private, '<gio/gio.h>', public ] },
+	{ include: [ '"gio/gdbusobjectskeleton.h"', private, '<gio/gio.h>', public ] },
+
+	{ include: [ '<fmt123.h>', private, '<mpg123.h>', public ] },
+
+	{ include: [ '<spandsp/plc.h>', private, '<spandsp.h>', public ] },
+
+]
diff --git a/.github/spellcheck-wordlist.txt b/.github/spellcheck-wordlist.txt
new file mode 100644
index 0000000..49b1f55
--- /dev/null
+++ b/.github/spellcheck-wordlist.txt
@@ -0,0 +1,225 @@
+# Abbreviations
+AAC
+ABR
+ADDR
+AVRCP
+BT
+CBR
+CLI
+CMD
+CRC
+CTL
+CTLs
+CVSD
+eSCO
+FDK
+GIO
+GSM
+HCI
+HD
+HFP
+HSP
+HW
+IEC
+kbps
+LATM
+mSBC
+MTU
+MUX
+NUM
+PCM
+PCMs
+PLC
+POSIX
+PRs
+RFCOMM
+RTP
+SBC
+SCO
+SDP
+SEPs
+SLC
+SND
+SNK
+SRC
+SRV
+TLV
+TODO
+TSAN
+UI
+UUID
+UUIDs
+VBR
+XQ
+
+# Proper Names
+ALSA
+AlsaProject
+AptX
+ATRAC
+BlueALSA
+BlueZ
+Fraunhofer
+IWYU
+LDAC
+MPD
+oFono
+PipeWire
+PulseAudio
+UPower
+
+# Technical Words
+arg
+async
+backend
+codec
+codecs
+conf
+dereferenced
+duplications
+endian
+endianness
+enum
+init
+middleware
+mutex
+natively
+param
+params
+pragma
+proc
+reinitialization
+scalable
+signedness
+unexported
+unmute
+unmutes
+unref
+unreferencing
+unregister
+utils
+
+# Others
+amixer
+AOT
+aplay
+appl
+aptXHD
+asoundrc
+asrsync
+autoreconf
+ay
+BAC
+batostr
+battchg
+BCC
+BCS
+bdaddr
+bfr
+bluealsa
+BlueALSA's
+bluetoothctl
+bluetoothd
+BRSF
+BSC
+btmon
+BTRH
+BTT
+bttransport
+btusb
+CCE
+CIEV
+CIND
+CKPD
+CMER
+ctrl
+ctx
+dbus
+dev
+disp
+dmix
+docutils
+dpsnk
+dpsrc
+DYN
+EAGAIN
+EINVAL
+ENODEV
+EP
+EPIPE
+EPMR
+EQMID
+errno
+FB
+ffb
+fs
+GError
+GHashTable
+hciconfig
+hcitop
+hciX
+HCTL
+hfpag
+hfphf
+hspag
+hsphs
+hVSB
+ioplug
+IPHONEACCEV
+iter
+keyp
+LF
+LFE
+LHDC
+libasound
+libbsd
+libdbus
+libldac
+libopenaptx
+LLAC
+MPF
+mtx
+ncurses
+nmemb
+NREC
+oa
+openaptx
+PCH
+pfd
+pthread
+ptr
+qsort
+readi
+readline
+renderer
+revents
+RST
+RVLC
+SBR
+SIGIO
+SIGPIPE
+SIGSEGV
+SIGTERM
+SNR
+spandsp
+SRA
+sv
+syslog
+syslogd
+systemd
+TIOCOUTQ
+TNS
+TWS
+ud
+uint
+VGM
+VGS
+VTable
+writei
+XAPL
+xFFFF
+XHSMICMUTE
+XHSTBATSOC
+XHSTBATSOH
+XM
+XRUN
diff --git a/.github/spellcheck.yaml b/.github/spellcheck.yaml
new file mode 100644
index 0000000..c262af1
--- /dev/null
+++ b/.github/spellcheck.yaml
@@ -0,0 +1,112 @@
+# BlueALSA - spellcheck.yaml
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
+#
+# This file provides PySpelling configuration for BlueALSA project. In order
+# to run spellcheck locally, do as follows:
+#
+#   pip install pyspelling
+#   pyspelling -c .github/spellcheck.yaml
+
+
+aspell: &aspell
+  ignore-case: true
+  run-together: true
+  run-together-limit: 4
+  lang: en
+
+dictionary: &dict
+  wordlists:
+  - .github/spellcheck-wordlist.txt
+
+matrix:
+
+- name: C
+  sources:
+  - 'src/**/*.{c,h}|!config.h'
+  aspell: *aspell
+  dictionary: *dict
+  pipeline:
+  - pyspelling.filters.url:
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore filename in the project header
+      - open: '^[* ]+BlueALSA -'
+        close: '.(c|h)$'
+      # Ignore copyright text
+      - open: '(?i)^[* ]+copyright \(c\)'
+        close: '$'
+      # Ignore include statement
+      - open: '^# *include'
+        close: '$'
+  - pyspelling.filters.cpp:
+      prefix: c
+      strings: true
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore parameter name in docstring
+      - open: '@param '
+        close: '\W'
+      # Ignore printf format placeholder
+      - open: '%'
+        content: '#?(l|z)'
+        close: 'd|u|x'
+      # Ignore D-Bus type format string
+      - open: '^a?[{(]+'
+        close: '[)}]+$'
+
+- name: Markdown
+  sources:
+  - '**/*.md'
+  aspell: *aspell
+  dictionary: *dict
+  pipeline:
+  - pyspelling.filters.url:
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore multiline content between fences
+      - open: '(?s)^(?P<open> *```)[a-z]*$'
+        close: '^(?P=open)$'
+      # Ignore text between inline back ticks
+      - open: '(?P<open>`+)'
+        close: '(?P=open)'
+      # Ignore URL in hyperlinks [title](url)
+      - open: '\]\('
+        close: '\)'
+
+- name: reStructuredText
+  sources:
+  - '**/*.rst'
+  aspell: *aspell
+  dictionary: *dict
+  pipeline:
+  - pyspelling.filters.url:
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore copyright text
+      - open: '(?i)^copyright \(c\)'
+        close: '$'
+      # Ignore multiline content in codeblock
+      - open: '(?s)^::\n\n  '
+        close: '^\n'
+      # Ignore text between inline asterisks or back ticks
+      - open: '(?P<open>[*`]+)'
+        close: '(?P=open)'
+
+- name: TXT
+  sources:
+  - '**/*.txt'
+  - NEWS
+  aspell: *aspell
+  dictionary: *dict
+  pipeline:
+  - pyspelling.filters.url:
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore "at" references
+      - open: '@'
+        close: '\W'
diff --git a/.github/tsan-supressions.txt b/.github/tsan-supressions.txt
new file mode 100644
index 0000000..8030bbe
--- /dev/null
+++ b/.github/tsan-supressions.txt
@@ -0,0 +1,7 @@
+# Unfortunately glib-2.0 has some issues with thread safety
+# https://gitlab.gnome.org/GNOME/glib/-/issues/1672
+race:glib/gslice.c
+
+# We are not interested in data races in PulseAudio
+called_from_lib:libpulsecommon-*.so
+called_from_lib:libpulse.so.0
diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml
index eb7f65b..666c8f5 100644
--- a/.github/workflows/build-and-test.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -1,10 +1,17 @@
-name: CI - Build and Test
+name: Build and Test
+
 on:
   push:
   pull_request:
     branches: [ master ]
+
+env:
+  MAKEFLAGS: -j8
+  SANITIZE_THREAD_LIBS: ${{ github.workspace }}/sanitize-thread-libs
+
 jobs:
-  build:
+
+  check:
     strategy:
       matrix:
         features:
@@ -12,30 +19,28 @@ jobs:
         - --enable-debug --enable-aac --enable-msbc
         - --enable-debug --enable-mp3lame --enable-mpg123
         - --enable-faststream --enable-mp3lame
-        - --enable-ofono --enable-upower
+        - --enable-aplay --enable-ofono --enable-upower
         - --enable-cli --enable-rfcomm --enable-manpages
       fail-fast: false
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
     steps:
     - name: Install Dependencies
-      run: |
-        sudo apt update
-        sudo apt install --yes --quiet --no-install-recommends \
-          check \
-          libasound2-dev \
-          libbluetooth-dev \
-          libbsd-dev \
-          libdbus-1-dev \
-          libfdk-aac-dev \
-          libglib2.0-dev \
-          libmp3lame-dev \
-          libmpg123-dev \
-          libncurses5-dev \
-          libreadline-dev \
-          libsbc-dev \
-          libspandsp-dev \
-          python-docutils
-    - uses: actions/checkout@v2
+      uses: awalsh128/cache-apt-pkgs-action@v1
+      with:
+        packages: >
+          check
+          libasound2-dev
+          libbluetooth-dev
+          libdbus-1-dev
+          libfdk-aac-dev
+          libglib2.0-dev
+          libmp3lame-dev
+          libmpg123-dev
+          libreadline-dev
+          libsbc-dev
+          libspandsp-dev
+          python3-docutils
+    - uses: actions/checkout@v3
     - name: Create Build Environment
       run: |
         mkdir -p ${{ github.workspace }}/{build,m4}
@@ -48,13 +53,125 @@ jobs:
           --enable-test
     - name: Build
       working-directory: ${{ github.workspace }}/build
-      env:
-        CFLAGS: -Wall -Wextra -Werror
-      run: make && make check TESTS=
-    - name: Run Test
-      working-directory: ${{ github.workspace }}/build
-      run: make check
-    - name: Show Test Log
+      run: make check CFLAGS="-Wall -Wextra -Werror -Wshadow" TESTS=
+    - name: Run Tests
+      working-directory: ${{ github.workspace }}/build/test
+      run: make check-TESTS
+    - name: Upload Tests Log
+      uses: actions/upload-artifact@v3
       if: ${{ always() }}
+      with:
+        name: ${{ github.job }} (${{ matrix.features }}) logs
+        path: ${{ github.workspace }}/build/test/*.log
+
+  sanitize-prepare:
+    runs-on: ubuntu-22.04
+    steps:
+    - uses: actions/cache@v3
+      id: cache
+      with:
+        key: sanitize-env
+        path: ${{ env.SANITIZE_THREAD_LIBS }}
+    - name: Create Build Environment
+      if: steps.cache.outputs.cache-hit != 'true'
+      run: |
+        sudo apt install --yes --quiet --no-install-recommends meson
+        mkdir -p ${{ env.SANITIZE_THREAD_LIBS }}
+    - name: Compile Patched GCC Thread Sanitizer Library
+      if: steps.cache.outputs.cache-hit != 'true'
+      run: |
+        git clone --depth=1 --branch=releases/gcc-11.3.0 https://github.com/gcc-mirror/gcc.git
+        # GCC (and Clang) skips interception in case when the call is made while the
+        # thread is waiting on a blocking system call. In most cases this is a case
+        # when the call is made from a signal handler. However, BlueALSA uses thread
+        # cancellation to stop the thread, which operates in a similar way. Therefore,
+        # we need to patch the thread sanitizer library to not skip the interception.
+        sed -i 's/|| thr->ignore_interceptors//' gcc/libsanitizer/tsan/tsan_interceptors.h
+        mkdir libstdc++-v3 && cd $_ && ../gcc/libstdc++-v3/configure --disable-multilib && make && cd -
+        mkdir libsanitizer && cd $_ && ../gcc/libsanitizer/configure --disable-multilib && make && cd -
+        mv libsanitizer/tsan/.libs/libtsan.so* ${{ env.SANITIZE_THREAD_LIBS }}
+    - name: Compile glib-2.0 with Thread Sanitizer
+      if: steps.cache.outputs.cache-hit != 'true'
+      run: |
+        git clone --depth=1 --branch=2.72.4 https://github.com/GNOME/glib.git
+        CFLAGS="-O2 -g -fsanitize=thread" meson glib/build glib
+        DESTDIR=../build-image ninja -C glib/build install
+        mv glib/build-image/usr/local/lib/x86_64-linux-gnu/lib* ${{ env.SANITIZE_THREAD_LIBS }}
+
+  sanitize:
+    strategy:
+      matrix:
+        sanitize:
+        - address alignment bool bounds nonnull-attribute shift undefined
+        - thread
+      fail-fast: false
+    needs: sanitize-prepare
+    runs-on: ubuntu-22.04
+    steps:
+    - name: Install Dependencies
+      uses: awalsh128/cache-apt-pkgs-action@v1
+      with:
+        packages: >
+          check
+          libasound2-dev
+          libbluetooth-dev
+          libdbus-1-dev
+          libfdk-aac-dev
+          libglib2.0-dev
+          libmp3lame-dev
+          libmpg123-dev
+          libsbc-dev
+          libspandsp-dev
+    - uses: actions/checkout@v3
+    - name: Create Build Environment
+      run: |
+        mkdir -p ${{ github.workspace }}/{build,m4}
+        autoreconf --install
+    - name: Configure GNU Automake
       working-directory: ${{ github.workspace }}/build
-      run: cat test/*.log
+      run: |
+        ${{ github.workspace }}/configure \
+          --enable-aac \
+          --enable-faststream \
+          --enable-mp3lame \
+          --enable-mpg123 \
+          --enable-msbc \
+          --enable-ofono \
+          --enable-upower \
+          --enable-aplay \
+          --enable-cli \
+          --enable-test
+    - name: Build
+      working-directory: ${{ github.workspace }}/build
+      run: |
+        make clean
+        SANITIZERS=$(for x in ${{ matrix.sanitize }}; do echo -n " -fsanitize=$x"; done)
+        make check CFLAGS="-g -O2 $SANITIZERS -fno-sanitize-recover=all" TESTS=
+    - uses: actions/cache/restore@v3
+      if: ${{ matrix.sanitize == 'thread' }}
+      with:
+        key: sanitize-env
+        path: ${{ env.SANITIZE_THREAD_LIBS }}
+    - name: Run Tests
+      working-directory: ${{ github.workspace }}/build/test
+      env:
+        CK_DEFAULT_TIMEOUT: 15
+        ASAN_OPTIONS: detect_stack_use_after_return=1
+        TSAN_OPTIONS: suppressions=${{ github.workspace }}/.github/tsan-supressions.txt
+      run: |
+        case "${{ matrix.sanitize }}" in
+          *address*)
+            export LD_PRELOAD_SANITIZER="libasan.so.6" ;;
+          *thread*)
+            # As for now, not all tests pass with thread sanitizer enabled...
+            export XFAIL_TESTS="test-alsa-ctl test-alsa-pcm test-utils-cli"
+            export LD_LIBRARY_PATH="${{ env.SANITIZE_THREAD_LIBS }}:$LD_LIBRARY_PATH"
+            export LD_PRELOAD_SANITIZER="libtsan.so.0" ;;
+        esac
+        make check-TESTS
+    - name: Upload Tests Log
+      uses: actions/upload-artifact@v3
+      if: ${{ always() }}
+      with:
+        name: ${{ github.job }} (${{ matrix.sanitize }}) logs
+        path: ${{ github.workspace }}/build/test/*.log
diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml
new file mode 100644
index 0000000..0962007
--- /dev/null
+++ b/.github/workflows/code-scanning.yaml
@@ -0,0 +1,163 @@
+name: Code Scanning
+
+on:
+  push:
+  pull_request:
+    branches: [ master ]
+
+env:
+  MAKEFLAGS: -j8
+
+permissions:
+  actions: read
+  contents: read
+  security-events: write
+
+jobs:
+
+  code-ql:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Install Dependencies
+      uses: awalsh128/cache-apt-pkgs-action@v1
+      with:
+        packages: >
+          libasound2-dev
+          libbluetooth-dev
+          libbsd-dev
+          libdbus-1-dev
+          libfdk-aac-dev
+          libglib2.0-dev
+          libmp3lame-dev
+          libmpg123-dev
+          libncurses5-dev
+          libreadline-dev
+          libsbc-dev
+          libspandsp-dev
+    - uses: actions/checkout@v3
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: cpp
+        queries: security-and-quality
+    - name: Create Build Environment
+      run: |
+        mkdir -p ${{ github.workspace }}/{build,m4}
+        autoreconf --install
+    - name: Configure GNU Automake
+      working-directory: ${{ github.workspace }}/build
+      run: |
+        ${{ github.workspace }}/configure \
+          --enable-aac \
+          --enable-faststream \
+          --enable-mp3lame \
+          --enable-mpg123 \
+          --enable-msbc \
+          --enable-ofono \
+          --enable-upower \
+          --enable-aplay \
+          --enable-cli \
+          --enable-rfcomm \
+          --enable-a2dpconf \
+          --enable-hcitop
+    - name: Build
+      working-directory: ${{ github.workspace }}/build
+      run: make
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2
+
+  doc8-lint:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+    - name: Run reStructuredText Linter
+      uses: deep-entertainment/doc8-action@v4
+      with:
+        ignorePaths: ${{ github.workspace }}/doc/bluealsa-api.txt
+        scanPaths: ${{ github.workspace }}
+
+  include-what-you-use:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Install Dependencies
+      uses: awalsh128/cache-apt-pkgs-action@v1
+      with:
+        # XXX: iwyu package depends on clang-14, but is built with clang-13
+        packages: >
+          bear
+          check
+          iwyu libclang-13-dev
+          libasound2-dev
+          libbluetooth-dev
+          libbsd-dev
+          libdbus-1-dev
+          libfdk-aac-dev
+          libglib2.0-dev
+          libmp3lame-dev
+          libmpg123-dev
+          libncurses5-dev
+          libreadline-dev
+          libsbc-dev
+          libspandsp-dev
+    - uses: actions/checkout@v3
+    - name: Create Build Environment
+      run: |
+        mkdir -p ${{ github.workspace }}/{build,m4}
+        autoreconf --install
+    - name: Configure GNU Automake
+      working-directory: ${{ github.workspace }}/build
+      run: |
+        ${{ github.workspace }}/configure \
+          --enable-aac \
+          --enable-debug \
+          --enable-debug-time \
+          --enable-faststream \
+          --enable-mp3lame \
+          --enable-mpg123 \
+          --enable-msbc \
+          --enable-ofono \
+          --enable-upower \
+          --enable-aplay \
+          --enable-cli \
+          --enable-rfcomm \
+          --enable-a2dpconf \
+          --enable-hcitop \
+          --enable-test
+    - name: Build
+      working-directory: ${{ github.workspace }}/build
+      run: bear -- make check TESTS=
+    - name: Run IWYU Check
+      run: |
+        iwyu_tool -p ${{ github.workspace }}/build -- \
+          -Xiwyu --mapping_file=${{ github.workspace }}/.github/iwyu.imp \
+          -Xiwyu --keep=*/config.h \
+          -Xiwyu --no_fwd_decls
+
+  markdown-lint:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+    - name: Run Markdown Linter
+      uses: nosborn/github-action-markdown-cli@v3
+      with:
+        files: ${{ github.workspace }}
+
+  shellcheck:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        fetch-depth: 0
+    - name: Run ShellCheck Scan
+      uses: redhat-plumbers-in-action/differential-shellcheck@v4
+      with:
+        token: ${{ secrets.GITHUB_TOKEN }}
+
+  spellcheck:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+    - name: Run Spell Check
+      uses: rojopolis/spellcheck-github-actions@master
+      with:
+        config_path: .github/spellcheck.yaml
diff --git a/.github/workflows/codecov-report.yaml b/.github/workflows/codecov-report.yaml
index 8bf05f7..cda279d 100644
--- a/.github/workflows/codecov-report.yaml
+++ b/.github/workflows/codecov-report.yaml
@@ -1,30 +1,35 @@
-name: Codecov - Upload Report
+name: Code Coverage
+
 on:
   push:
   pull_request:
     branches: [ master ]
+
+env:
+  MAKEFLAGS: -j8
+
 jobs:
+
   coverage:
-    strategy:
-      fail-fast: false
+    if: github.repository_owner == 'arkq'
     runs-on: ubuntu-latest
     steps:
     - name: Install Dependencies
-      run: |
-        sudo apt update
-        sudo apt install --yes --quiet --no-install-recommends \
-          check \
-          lcov \
-          libasound2-dev \
-          libbluetooth-dev \
-          libdbus-1-dev \
-          libfdk-aac-dev \
-          libglib2.0-dev \
-          libmp3lame-dev \
-          libmpg123-dev \
-          libsbc-dev \
+      uses: awalsh128/cache-apt-pkgs-action@v1
+      with:
+        packages: >
+          check
+          lcov
+          libasound2-dev
+          libbluetooth-dev
+          libdbus-1-dev
+          libfdk-aac-dev
+          libglib2.0-dev
+          libmp3lame-dev
+          libmpg123-dev
+          libsbc-dev
           libspandsp-dev
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
     - name: Create Build Environment
       run: |
         mkdir -p ${{ github.workspace }}/{build,m4}
@@ -33,7 +38,6 @@ jobs:
       working-directory: ${{ github.workspace }}/build
       run: |
         ${{ github.workspace }}/configure \
-          --disable-aplay \
           --enable-aac \
           --enable-faststream \
           --enable-mp3lame \
@@ -41,12 +45,14 @@ jobs:
           --enable-msbc \
           --enable-ofono \
           --enable-upower \
+          --enable-aplay \
+          --enable-cli \
           --enable-test \
           --with-coverage
     - name: Generate Coverage Report
       working-directory: ${{ github.workspace }}/build
       run: make cov
     - name: Upload Coverage to Codecov
-      uses: codecov/codecov-action@v2
+      uses: codecov/codecov-action@v3
       with:
         files: build/cov.info
diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml
deleted file mode 100644
index 9e94bcc..0000000
--- a/.github/workflows/codeql-analysis.yaml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: CodeQL Analysis
-on:
-  push:
-  pull_request:
-    branches: [ master ]
-jobs:
-  analyze:
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-    strategy:
-      matrix:
-        language: [ 'cpp' ]
-      fail-fast: false
-    runs-on: ubuntu-latest
-    steps:
-    - name: Install Dependencies
-      run: |
-        sudo apt update
-        sudo apt install --yes --quiet --no-install-recommends \
-          libasound2-dev \
-          libbluetooth-dev \
-          libbsd-dev \
-          libdbus-1-dev \
-          libfdk-aac-dev \
-          libglib2.0-dev \
-          libmp3lame-dev \
-          libmpg123-dev \
-          libncurses5-dev \
-          libreadline-dev \
-          libsbc-dev \
-          libspandsp-dev
-    - uses: actions/checkout@v2
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v1
-      with:
-        languages: ${{ matrix.language }}
-        queries: security-and-quality
-    - name: Create Build Environment
-      run: |
-        mkdir -p ${{ github.workspace }}/{build,m4}
-        autoreconf --install
-    - name: Configure GNU Automake
-      working-directory: ${{ github.workspace }}/build
-      run: |
-        ${{ github.workspace }}/configure \
-          --enable-aac \
-          --enable-faststream \
-          --enable-mp3lame \
-          --enable-mpg123 \
-          --enable-msbc \
-          --enable-ofono \
-          --enable-upower \
-          --enable-aplay \
-          --enable-cli \
-          --enable-rfcomm \
-          --enable-a2dpconf \
-          --enable-hcitop
-    - name: Build
-      working-directory: ${{ github.workspace }}/build
-      run: make
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v1
diff --git a/AUTHORS b/AUTHORS
index 1e748ce..dbf665e 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -13,6 +13,7 @@ Lars Wendler <polynomial-c@gentoo.org>
 mcz <emcze@ya.ru>
 Michał Kępień <github@kempniu.pl>
 Ming Liu <liu.ming50@gmail.com>
+Nicolas Cavallari <nicolas.cavallari@green-communications.fr>
 Parthiban Nallathambi <pn@denx.de>
 René Rebe <rene@exactcode.com>
 Sebastian Würl <bastiwuerl@gmail.com>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c51750e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,51 @@
+# Contributing To BlueALSA
+
+## Code and manual pages
+
+This project welcomes contributions of code, documentation and testing.
+
+To submit code or manual page contributions please use GitHub Pull Requests.
+The GitHub source code repository is at [https://github.com/arkq/bluez-alsa](https://github.com/arkq/bluez-alsa)
+
+For help with creating a Pull Request (PR), please consult the GitHub
+documentation. In particular:
+
+* [creating forks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks)
+
+* [creating pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request)
+
+The commit message for each commit that you make to your branch should include
+a clear description of the change introduced by that commit. That will make the
+change history log easier to follow when the PR is merged.
+
+If the PR is for a new feature, extensive change or non-trivial bug fix please
+if possible add a simple unit test. If that is not possible then please include
+in the PR description instructions on how to test the code manually.
+
+Before submitting a pull request, if possible please configure your build with
+`--enable-test`; and to catch as many coding errors as possible please compile
+with:
+
+```sh
+make CFLAGS="-Wall -Wextra -Wshadow -Werror"
+```
+
+and then run the unit test suite:
+
+```sh
+make check
+```
+
+When submitting the PR, please provide a description of the problem and its
+fix, or the new feature and its rationale. Help the reviewer understand what to
+expect.
+
+If you have an issue number, please reference it with a syntax `Fixes #123`.
+
+If you wish to help by testing PRs or by making review comments please do so by
+adding comments to the PR.
+
+## Wiki
+
+The project [wiki](https://github.com/arkq/bluez-alsa/wiki) is "public" and
+contributions there are also welcome.
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000..43caac4
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,184 @@
+# BlueALSA Installation
+
+Given its aim of small size and minimum redundancy, BlueALSA makes many of its
+features optional and only includes them when explicitly requested when
+configuring the build. The number of options is therefore large, too large to
+be covered fully here. For a comprehensive installation guide, please look at
+the [Installation from source][] project wiki page. If you've found something
+missing or incorrect, feel free to make a wiki contribution.
+
+[Installation from source]: https://github.com/arkq/bluez-alsa/wiki/Installation-from-source
+
+## Configuration
+
+Firstly, create the `configure` script. Run, in the top level project
+directory:
+
+```sh
+autoreconf --install
+```
+
+then, to see a complete list of all options:
+
+```sh
+./configure --help
+```
+
+Dependencies:
+
+- [alsa-lib](https://www.alsa-project.org/)
+- [bluez](http://www.bluez.org/) >= 5.0
+- [glib](https://wiki.gnome.org/Projects/GLib) with GIO support
+- [sbc](https://git.kernel.org/cgit/bluetooth/sbc.git)
+- [docutils](https://docutils.sourceforge.io) (when man pages build is enabled
+  with `--enable-manpages`)
+- [fdk-aac](https://github.com/mstorsjo/fdk-aac) (when AAC support is enabled
+  with `--enable-aac`)
+- [lc3plus](https://www.iis.fraunhofer.de/en/ff/amm/communication/lc3.html)
+  (when LC3plus support is enabled with `--enable-lc3plus`)
+- [libldac](https://github.com/EHfive/ldacBT) (when LDAC support is enabled
+  with `--enable-ldac`)
+- [libopenaptx](https://github.com/pali/libopenaptx) (when apt-X support is
+  enabled and `--with-libopenaptx` is used)
+- [mp3lame](https://lame.sourceforge.net/) (when MP3 support is enabled with
+  `--enable-mp3lame`)
+- [mpg123](https://www.mpg123.org/) (when MPEG decoding support is enabled with
+  `--enable-mpg123`)
+- [openaptx](https://github.com/arkq/openaptx) (when apt-X support is enabled
+  with `--enable-aptx` and/or `--enable-aptx-hd`)
+- [spandsp](https://www.soft-switch.org) (when mSBC support is enabled with
+  `--enable-msbc`)
+
+Dependencies for client applications (e.g. `bluealsa-aplay` or `bluealsa-cli`):
+
+- [libdbus](https://www.freedesktop.org/wiki/Software/dbus/)
+
+Dependencies for `bluealsa-rfcomm` (when `--enable-rfcomm` is specified during
+configuration):
+
+- [readline](https://tiswww.case.edu/php/chet/readline/rltop.html)
+
+Dependencies for `hcitop` (when `--enable-hcitop` is specified during
+configuration):
+
+- [libbsd](https://libbsd.freedesktop.org/)
+- [ncurses](https://www.gnu.org/software/ncurses/)
+
+If it is intended to use BlueALSA on a system that uses `systemd`, then it is
+recommended to include the option `--enable-systemd` as this will create
+service unit files.
+See the [systemd integration][] wiki page for more information.
+
+[systemd integration]: https://github.com/arkq/bluez-alsa/wiki/Systemd-integration
+
+If intending to run the `bluealsa` daemon as a non-root user then it is
+recommended to use the `--with-bluealsauser=USER` option as this will configure
+the BlueALSA D-Bus policy file with correct permissions for that user account,
+and also include that user in the systemd service unit file when used in
+combination with `--enable-systemd`.
+
+If not using systemd, then some manual setup of the host will be required, see
+[Runtime Environment](#runtime-environment) below.
+
+Once the desired options have been chosen, run:
+
+```sh
+mkdir build && cd build
+../configure [ OPTION ... ]
+```
+
+## Build
+
+When the project is configured, compile it by running in the build directory:
+
+```shell
+make
+```
+
+When building from the git sources, if `git pull` is used to update the source
+tree, then it is recommended to refresh the build in order to update the
+version identifier embedded in the configure files. In the top-level directory
+run:
+
+```shell
+autoreconf --install --force
+```
+
+then in the build directory run `make clean` before running `make`.
+
+## Installation
+
+The built components can be installed on the local system with
+
+```shell
+sudo make install
+```
+
+To install into a directory that can be packaged and copied to other hosts (for
+example a directory called BLUEALSA):
+
+```shell
+sudo make DESTDIR=BLUEALSA install
+```
+
+## Runtime Environment
+
+### Storage directory
+
+When using `systemd`, all the necessary files and directories are created by
+the `bluealsa.service` unit at runtime. If not using `systemd`, or if the
+ `--enable-systemd` option was not used during configuration, then it is
+necessary to manually create the directory used by BlueALSA for persistent
+state storage. This directory should be called `bluealsa` and be located under
+the system local state directory, which is normally `/var/lib`. The directory
+owner must be the user account that the `bluealsa` daemon is run under, and
+to prevent accidental corruption of the state files the permissions should be
+`rwx------`. For example, on a standard file hierarchy, with the `bluealsa`
+daemon running as user `bluealsa`:
+
+```sh
+sudo mkdir /var/lib/bluealsa
+sudo chown bluealsa /var/lib/bluealsa
+sudo chmod 0700 /var/lib/bluealsa
+```
+
+### User accounts
+
+The BlueALSA installation does not create any user accounts.
+
+### D-Bus policy
+
+A D-Bus policy file is required to enable the `bluealsa` daemon to register
+with D-Bus as a service. The default policy file created by the BlueALSA
+installation enables `root` to register the service `org.bluealsa` and enables
+members of the group `audio` to use BlueALSA PCMs and the BlueALSA mixer. If
+the option `--with-bluealsauser=USER` was used when configuring then the
+policy file enables user USER instead of `root` to register the `org.bluealsa`
+service. If that option was not used, then it is necessary to edit the policy
+file to grant permission to a non-root user. The policy file is located at
+
+`/etc/dbus-1/system.d/bluealsa.conf`.
+
+For example:
+
+```xml
+<!-- This configuration file specifies the required security policies
+     for BlueALSA core daemon to work. -->
+
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+
+  <!-- ../system.conf have denied everything, so we just punch some holes -->
+
+  <policy user="bluealsa">
+    <allow own_prefix="org.bluealsa"/>
+    <allow send_destination="org.bluealsa"/>
+  </policy>
+
+  <policy group="audio">
+    <allow send_destination="org.bluealsa"/>
+  </policy>
+
+</busconfig>
+```
diff --git a/LICENSE b/LICENSE
index 23b4a7b..95a5c55 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 The MIT License
 
-Copyright (c) 2016-2022 Arkadiusz Bokowy <arkadiusz.bokowy@gmail.com>
+Copyright (c) 2016-2023 Arkadiusz Bokowy <arkadiusz.bokowy@gmail.com>
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile.am b/Makefile.am
index 73c6906..f783af5 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1,5 +1,5 @@
 # BlueALSA - Makefile.am
-# Copyright (c) 2016-2021 Arkadiusz Bokowy
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
 
 ACLOCAL_AMFLAGS = -I m4
 SUBDIRS = misc src utils
@@ -15,7 +15,7 @@ endif
 if WITH_COVERAGE
 cov:
 	$(MAKE) $(AM_MAKEFLAGS) check CFLAGS="$(CFLAGS) -O0 --coverage"
-	$(LCOV) --capture -d src -d test --exclude '/usr/*' --exclude "*/test/*" -o cov.info
+	$(LCOV) --capture -d src -d utils -d test --exclude '/usr/*' --exclude "*/test/*" -o cov.info
 	$(GENHTML) -o coverage -t $(PACKAGE) cov.info
 clean-local:
 	find $(top_builddir) -name "*.gcno" -delete
diff --git a/NEWS b/NEWS
index f5f6142..c0493e7 100644
--- a/NEWS
+++ b/NEWS
@@ -1,6 +1,24 @@
 unreleased
 ==========
 
+bluez-alsa v4.1.0 (2023-05-23)
+==============================
+
+- removed deprecated org.bluealsa.Manger1 D-Bus interface
+- persistent storage for PCM volume and mute state
+- PCM volume control with oFono HFP-AG and HFP-HF profiles
+- transport running state exported in PCM D-Bus interface
+- A2DP codec configuration blob exported in PCM D-Bus interface
+- optional non-dynamic operation mode for ALSA control plug-in
+- optional extended controls for ALSA control plug-in
+- changed RFCOMM D-Bus API features property to array of strings
+- fix for SCO link establishment for oFono HFP-AG profile
+- fix for volume control for HSP-HS and HFP-HF profiles
+- stability fixes for ALSA PCM I/O and control plug-ins
+- bluealsa-aplay: fix for volume synchronization
+- lots of fixes for race conditions (TSAN)
+- lots of updates to the manual pages
+
 bluez-alsa v4.0.0 (2022-06-03)
 ==============================
 
diff --git a/README.md b/README.md
index eb4d56d..8802be3 100644
--- a/README.md
+++ b/README.md
@@ -1,108 +1,140 @@
 # Bluetooth Audio ALSA Backend
 
-[![Build Status](https://github.com/Arkq/bluez-alsa/actions/workflows/build-and-test.yaml/badge.svg)](https://github.com/Arkq/bluez-alsa/actions/workflows/build-and-test.yaml)
-[![Code Coverage](https://codecov.io/gh/Arkq/bluez-alsa/branch/master/graph/badge.svg)](https://codecov.io/gh/Arkq/bluez-alsa)
-
-This project is a rebirth of a direct integration between [BlueZ](http://www.bluez.org/) and
-[ALSA](https://www.alsa-project.org/). Since BlueZ >= 5, the build-in integration has been removed
-in favor of 3rd party audio applications. From now on, BlueZ acts as a middleware between an
-audio application, which implements Bluetooth audio profile, and a Bluetooth audio device.
-
-The current status quo is, that in order to stream audio from/to a Bluetooth device, one has to
-install [PulseAudio](https://www.freedesktop.org/wiki/Software/PulseAudio), or use BlueZ < 5.
-However, BlueZ version 4 is considered to be deprecated, so the only reasonable way to achieve
-this goal is to install PulseAudio.
-
-With this application (later named as BlueALSA), one can achieve the same goal as with PulseAudio,
-but with less dependencies and more bare-metal-like. BlueALSA registers all known Bluetooth audio
-profiles in BlueZ, so in theory every Bluetooth device (with audio capabilities) can be connected.
-In order to access the audio stream, one has to connect to the ALSA PCM device called `bluealsa`.
-Please note that this PCM device is based on the [ALSA software PCM I/O
-plugin](https://www.alsa-project.org/alsa-doc/alsa-lib/pcm_external_plugins.html) - it will not be
-available in the [ALSA Kernel proc
-interface](https://www.kernel.org/doc/html/latest/sound/designs/procfile.html).
-
-## Installation
-
-```sh
-autoreconf --install
-mkdir build && cd build
-../configure --enable-aac --enable-ofono --enable-debug
+[![Build Status](https://github.com/arkq/bluez-alsa/actions/workflows/build-and-test.yaml/badge.svg)](https://github.com/arkq/bluez-alsa/actions/workflows/build-and-test.yaml)
+[![Code Coverage](https://codecov.io/gh/arkq/bluez-alsa/branch/master/graph/badge.svg)](https://app.codecov.io/gh/arkq/bluez-alsa)
+
+## About BlueALSA
+
+This project is a rebirth of a direct integration between [BlueZ][] and
+[ALSA][]. Since BlueZ >= 5, the built-in integration has been removed in favor
+of 3rd party audio applications. From now on, BlueZ acts as a middleware
+between an audio application, which implements Bluetooth audio profile, and a
+Bluetooth audio device.
+
+The current status quo is, that in order to stream audio from/to a Bluetooth
+device, one has to install a general-purpose audio server such as [PipeWire][]
+or [PulseAudio][], or use BlueZ version 4 which is deprecated and unmaintained.
+
+[BlueZ]: http://www.bluez.org/
+[ALSA]: https://www.alsa-project.org/
+[PipeWire]: https://pipewire.org/
+[PulseAudio]: https://www.freedesktop.org/wiki/Software/PulseAudio
+
+This project created and maintains a product called BlueALSA, with which one
+can achieve the same Bluetooth audio profile support as with PulseAudio, but
+with fewer dependencies and at a lower level in the software stack.  BlueALSA
+registers all known Bluetooth audio profiles in BlueZ, so in theory every
+Bluetooth device (with audio capabilities) can be connected.
+
+BlueALSA is designed specifically for use on small, low-powered, dedicated
+audio or audio/visual systems where the high-level audio management features of
+PulseAudio or PipeWire are not required. The target system must be able to
+function correctly with all its audio applications interfacing directly with
+ALSA, with only one application at a time using each Bluetooth audio stream.
+In such systems BlueALSA adds Bluetooth audio support to the existing
+ALSA sound card support. Note this means that the applications are constrained
+by the capabilities of the ALSA API, and the higher-level audio processing
+features of audio servers such as PulseAudio and Pipewire are not available.
+
+BlueALSA consists of the daemon `bluealsa`, ALSA plug-ins, and a number of
+utilities. The basic context is shown in this diagram:
+
+```mermaid
+flowchart TD
+classDef external fill:#eee,stroke:#333,stroke-width:4px,color:black;
+classDef bluealsa fill:#bbf,stroke:#333,stroke-width:4px,color:black;
+
+A[Bluetooth Adapter] <--> B((bluetoothd\ndaemon))
+A <--> C((bluealsa daemon))
+B <--> C
+C <--> D((bluealsa-aplay))
+D --> E([ALSA libasound])
+E --> K[Speakers]
+C <--> F((bluealsa\nALSA plug-ins))
+C <--> G((bluealsa-cli))
+F <--> H([ALSA libasound])
+H <--> I((ALSA\napplications))
+C <--> J((other\nD-Bus clients))
+
+class A,B,E,H,I,J,K external;
+class C,D,F,G bluealsa;
 ```
 
-or if you intend to stream audio from a Linux distribution using PulseAudio < 13.0 (see [this
-issue](https://github.com/Arkq/bluez-alsa/issues/13))
+The heart of BlueALSA is the daemon `bluealsa` which interfaces with the BlueZ
+Bluetooth daemon `bluetoothd` and the local Bluetooth adapter. It handles the
+profile connection and configuration logic for A2DP, HFP and HSP and presents
+the resulting audio streams to applications via D-Bus.
 
-```sh
-../configure --enable-aac --enable-ofono --enable-debug --disable-payloadcheck
-```
+BlueALSA includes ALSA plug-ins which hide all the D-Bus specifics and permit
+applications to use the ALSA PCM and mixer interfaces, so that existing ALSA
+applications can access Bluetooth audio devices in the same way as they use
+sound card PCMs and mixers.
 
-then
+BlueALSA also includes a number of utility applications. Of particular note
+are:
 
-```sh
-make && make install
-```
+* bluealsa-aplay\
+   an application to simplify the task of building a Bluetooth speaker using
+   BlueALSA.
 
-Dependencies:
+* bluealsa-cli\
+   an application to allow command-line management of the BlueALSA system.
 
-- [alsa-lib](https://www.alsa-project.org/)
-- [bluez](http://www.bluez.org/) >= 5.0
-- [glib](https://wiki.gnome.org/Projects/GLib) with GIO support
-- [sbc](https://git.kernel.org/cgit/bluetooth/sbc.git)
-- [docutils](https://docutils.sourceforge.io) (when man pages build is enabled with
-  `--enable-manpages`)
-- [fdk-aac](https://github.com/mstorsjo/fdk-aac) (when AAC support is enabled with `--enable-aac`)
-- [lc3plus](https://www.iis.fraunhofer.de/en/ff/amm/communication/lc3.html) (when LC3plus support
-  is enabled with `--enable-lc3plus`)
-- [libldac](https://github.com/EHfive/ldacBT) (when LDAC support is enabled with `--enable-ldac`)
-- [libopenaptx](https://github.com/pali/libopenaptx) (when apt-X support is enabled and
-  `--with-libopenaptx` is used)
-- [mp3lame](https://lame.sourceforge.net/) (when MP3 support is enabled with `--enable-mp3lame`)
-- [mpg123](https://www.mpg123.org/) (when MPEG decoding support is enabled with `--enable-mpg123`)
-- [openaptx](https://github.com/Arkq/openaptx) (when apt-X support is enabled with
-  `--enable-aptx` and/or `--enable-aptx-hd`)
-- [spandsp](https://www.soft-switch.org) (when mSBC support is enabled with `--enable-msbc`)
+* bluealsa-rfcomm\
+   a command-line application which provides access to the RFCOMM terminal for
+   HFP/HSP devices.
 
-Dependencies for client applications (e.g. `bluealsa-aplay` or `bluealsa-cli`):
+## Installation
 
-- [libdbus](https://www.freedesktop.org/wiki/Software/dbus/)
+Build and install instructions are included in the file
+[INSTALL.md](INSTALL.md) and more detailed guidance is available in the
+[wiki](https://github.com/arkq/bluez-alsa/wiki/Installation-from-source).
 
-Dependencies for `bluealsa-rfcomm` (when `--enable-rfcomm` is specified during configuration):
+## Usage
 
-- [readline](https://tiswww.case.edu/php/chet/readline/rltop.html)
+### bluealsa daemon
 
-Dependencies for `hcitop` (when `--enable-hcitop` is specified during configuration):
+The main component of BlueALSA is a program called `bluealsa`. By default, this
+program shall be run as a root during system startup. It will register
+`org.bluealsa` service in the D-Bus system bus, which can be used for accessing
+configured audio devices. In general, BlueALSA acts as a proxy between BlueZ
+and ALSA.
 
-- [libbsd](https://libbsd.freedesktop.org/)
-- [ncurses](https://www.gnu.org/software/ncurses/)
+The `bluealsa` daemon must be running in order to pair, connect, and use
+remote Bluetooth audio devices. In order to stream audio to e.g. a Bluetooth
+headset, firstly one has to connect the device. If you are not familiar with
+the Bluetooth pairing and connecting procedures on Linux, there is a basic
+guide in the wiki:
+[Bluetooth pairing and connecting](https://github.com/arkq/bluez-alsa/wiki/Bluetooth-Pairing-And-Connecting).
 
-For a comprehensive installation guide, please look at the [Installation from
-source](https://github.com/Arkq/bluez-alsa/wiki/Installation-from-source) bluez-alsa wiki page. If
-you've found something missing or incorrect, fill free to make a wiki contribution. Alternatively,
-if you are using Debian-based distribution, take a look at the
-[build-and-test.yaml](.github/workflows/build-and-test.yaml) GitHub workflow file, it might give
-you a hint about required packages.
+For details of command-line options to `bluealsa`, consult the [bluealsa manual
+page](doc/bluealsa.8.rst).
 
-## Configuration & Usage
+### ALSA plug-ins
 
-The main component of BlueALSA is a program called `bluealsa`. By default, this program shall be
-run as a root during system startup. It will register `org.bluealsa` service in the D-Bus system
-bus, which can be used for accessing configured audio devices. In general, BlueALSA acts as a
-proxy between BlueZ and ALSA.
+When a Bluetooth audio device is connected one can use the `bluealsa`
+virtual PCM device with ALSA applications just like any other PCM device:
 
-For details of command-line options to `bluealsa`, consult the [bluealsa man
-page](./doc/bluealsa.8.rst).
+```sh
+aplay -D bluealsa Bourree_in_E_minor.wav
+```
 
-In order to stream audio to the e.g. Bluetooth headset, firstly one has to connect the device. The
-most straightforward method is to use BlueZ CLI utility called `bluetoothctl`. When the device is
-connected one can use the `bluealsa` virtual PCM device as follows:
+If there is more than one Bluetooth device connected, the target one can be
+specified as a parameter to the PCM:
 
 ```sh
-aplay -D bluealsa:DEV=XX:XX:XX:XX:XX:XX,PROFILE=a2dp Bourree_in_E_minor.wav
+aplay -D bluealsa:XX:XX:XX:XX:XX:XX, Bourree_in_E_minor.wav
 ```
 
-Setup parameters of the bluealsa PCM device can be set in the local `.asoundrc` configuration file
-like this:
+Please note that this PCM device is based on the [ALSA software PCM I/O
+plug-in][] - it has no associated sound card, and it will not be available in
+the [ALSA Kernel proc interface][].
+
+[ALSA software PCM I/O plug-in]: https://www.alsa-project.org/alsa-doc/alsa-lib/pcm_external_plugins.html
+[ALSA Kernel proc interface]: https://www.kernel.org/doc/html/latest/sound/designs/procfile.html
+
+Setup parameters of the bluealsa PCM device can be set in the local `.asoundrc`
+configuration file like this:
 
 ```sh
 cat ~/.asoundrc
@@ -112,102 +144,111 @@ defaults.bluealsa.profile "a2dp"
 defaults.bluealsa.delay 10000
 ```
 
-BlueALSA also allows to capture audio from the connected Bluetooth device. To do so, one has to
-use the capture PCM device, e.g.:
+BlueALSA also allows to capture audio from the connected Bluetooth device. To
+do so, one has to use the capture PCM device, e.g.:
 
 ```sh
-arecord -D bluealsa capture.wav
+arecord -D bluealsa -f s16_le -c 2 -r 48000 capture.wav
 ```
 
-Using this feature, it is possible to create Bluetooth-powered speaker. It is required to forward
-audio signal from the BlueALSA capture PCM to some other playback PCM (e.g. build-id audio card).
-In order to simplify this task, there is a program called `bluealsa-aplay`, which acts as a simple
-BlueALSA player. Connect your Bluetooth device (e.g. smartphone) and do as follows:
+In addition to A2DP profile, used for high quality audio, BlueALSA also allows
+to use phone audio connection via SCO link. One can use either built-in HSP/HFP
+support, which implements only audio related part of the specification, or use
+[oFono][] service as a back-end. In order to open SCO audio connection one
+shall switch to `sco` profile like follows:
+
+[oFono]: https://01.org/ofono
 
 ```sh
-bluealsa-aplay XX:XX:XX:XX:XX:XX
+aplay -D bluealsa:DEV=XX:XX:XX:XX:XX:XX,PROFILE=sco Bourree_in_E_minor.wav
 ```
 
-For details of command-line options to `bluealsa-aplay`, consult the [bluealsa-aplay man
-page](./doc/bluealsa-aplay.1.rst).
-
-In addition to A2DP profile, used for high quality audio, BlueALSA also allows to use phone audio
-connection via SCO link. One can use either build-in HSP/HFP support, which implements only audio
-related part of the specification, or use [oFono](https://01.org/ofono) service as a back-end. In
-order to open SCO audio connection one shall switch to `sco` profile like follows:
+In order to control input or output audio level, one can use provided
+`bluealsa` control plug-in. This plug-in allows adjusting the volume of the
+audio stream or simply mute/unmute it, e.g.:
 
 ```sh
-aplay -D bluealsa:DEV=XX:XX:XX:XX:XX:XX,PROFILE=sco Bourree_in_E_minor.wav
+amixer -D bluealsa sset '<control name>' 70%
 ```
 
-The list of available BlueALSA PCMs (provided by connected Bluetooth devices with audio
-capabilities) can be obtained directly from [BlueALSA D-Bus API](doc/bluealsa-api.txt) or using
-`bluealsa-aplay` as a convenient wrapper as follows:
+where the control name is the name of a connected Bluetooth device with a
+control element suffix, e.g.:
 
 ```sh
-bluealsa-aplay -L
+amixer -D bluealsa sset 'Jabra MOVE v2.3.0 A2DP' 50%
 ```
 
-In order to control input or output audio level, one can use provided `bluealsa` control plugin.
-This plugin allows adjusting the volume of the audio stream or simply mute/unmute it, e.g.:
+For full details of the BlueALSA ALSA PCM device and mixer device consult the
+[BlueALSA plug-ins manual page](doc/bluealsa-plugins.7.rst).
+
+There are also a number of articles on the [bluez-alsa project wiki][] giving
+more examples of using these plug-ins.
+
+[bluez-alsa project wiki]: https://github.com/arkq/bluez-alsa/wiki
+
+For more advanced ALSA configuration, consult the [asoundrc on-line
+documentation][] provided by the AlsaProject wiki page.
+
+[asoundrc on-line documentation]: https://www.alsa-project.org/main/index.php/Asoundrc
+
+### bluealsa-aplay
+
+It is possible to create Bluetooth-powered speaker using BlueALSA. For this it
+is required to forward the audio signal from the BlueALSA capture PCM to some
+other playback PCM (e.g. built-in audio card).  In order to simplify this task,
+BlueALSA includes a program called `bluealsa-aplay`, which acts as a simple
+BlueALSA player. Connect your Bluetooth device (e.g. smartphone) and do as
+follows:
 
 ```sh
-amixer -D bluealsa sset '<control name>' 70%
+bluealsa-aplay XX:XX:XX:XX:XX:XX
 ```
 
-where the control name is the name of a connected Bluetooth device with a control element suffix,
-e.g.:
+For details of command-line options to `bluealsa-aplay`, consult the
+[bluealsa-aplay manual page](doc/bluealsa-aplay.1.rst). There are also some
+articles on the [bluez-alsa project wiki][] giving examples of its use.
+
+The list of available BlueALSA PCMs (provided by connected Bluetooth devices
+with audio capabilities) can be obtained directly from [BlueALSA D-Bus
+API](doc/bluealsa-api.txt) or using `bluealsa-aplay` as a convenient wrapper as
+follows:
 
 ```sh
-amixer -D bluealsa sset 'Jabra MOVE v2.3.0 - A2DP' 50%
+bluealsa-aplay -L
 ```
 
-For more advanced ALSA configuration, consult the [asoundrc on-line
-documentation](https://www.alsa-project.org/main/index.php/Asoundrc) provided by the AlsaProject
-wiki page.
-
-## Troubleshooting
-
-1. Using BlueALSA alongside with PulseAudio.
-
-   Due to BlueZ limitations, it seems, that it is not possible to use BlueALSA and PulseAudio to
-   handle Bluetooth audio together. BlueZ can not handle more than one application which registers
-   audio profile in the Bluetooth stack. However, it is possible to run BlueALSA and PulseAudio
-   alongside, but Bluetooth support has to be disabled in the PulseAudio. Any Bluetooth related
-   module has to be unloaded - e.g. `bluetooth-discover`, `bluez5-discover`.
-
-2. ALSA thread-safe API (alsa-lib >= 1.1.2, <= 1.1.3).
-
-   Starting from ALSA library 1.1.2, it is possible to enable thread-safe API functions. It is a
-   noble change, but the implementation leaves a lot to be desired. This "minor" change does not
-   affect hardware audio devices (because for hardware devices, this change is disabled), but it
-   affects A LOT all software plug-ins. Random deadlocks are inevitable. My personal advice is to
-   disable it during alsa-lib configuration step (`./configure --disable-thread-safety`), or if
-   it is not possible (installation from a package repository), disable it via an environmental
-   variable, as follows: `export LIBASOUND_THREAD_SAFE=0`.
-
-3. Couldn't acquire D-Bus name: org.bluealsa
-
-   It is not possible to run more than one instance of the BlueALSA server per D-Bus interface. If
-   one tries to run second instance, it will fail with the `"Couldn't acquire D-Bus name:
-   org.bluealsa"` error message. This message might also appear when D-Bus policy does not allow
-   acquiring "org.bluealsa" name for a particular user - by default only root is allowed to start
-   BlueALSA server.
-
-4. Couldn't get BlueALSA PCM: PCM not found
-
-   In contrast to standard ALSA sound cards, BlueALSA does not expose all PCMs right away. In the
-   first place it is required to connect remote Bluetooth device with desired Bluetooth profile -
-   run `bluealsa --help` for the list of available profiles. For querying currently connected audio
-   profiles (and connected devices), run `bluealsa-aplay --list-devices`. The common misconception
-   is an attempt to use A2DP playback device as a capture one in case where A2DP is not listed in
-   the "List of CAPTURE Bluetooth Devices" section.
-
-   Additionally, the cause of the "PCM not found" error might be an incorrect ALSA PCM name. Run
-   `bluealsa-aplay --list-pcms` for the list of currently available ALSA PCM names - it might give
-   you a hint what is wrong with your `.asoundrc` entry. Also, take a look at the "[Using the
-   bluealsa ALSA pcm plugin](https://github.com/Arkq/bluez-alsa/wiki/Using-the-bluealsa-ALSA-pcm-plugin)"
-   bluez-alsa wiki page.
+## Contributing
+
+This project welcomes contributions of code, documentation and testing.
+
+Please see the [CONTRIBUTING](CONTRIBUTING.md) guide for details.
+
+## Bug reports, feature requests, and requests for help
+
+The most commonly encountered errors are discussed in the
+[TROUBLESHOOTING] guide. Please check that file to see if there is already a
+solution for your issue.
+
+If you are unable to find a solution in that document or by reading the
+[manual pages][], then please search [previous issues][] (both open and
+closed), and consult the [wiki][] before raising a new issue. Unfortunately
+the wiki is not indexed by web search engines, so searching on-line for your
+issue will not discover the information in there.
+
+If reporting a problem as a new issue, please use the appropriate
+[bluez-alsa GitHub issue reporting template][] and complete each section of
+the template as fully as possible.
+
+[TROUBLESHOOTING]: ./TROUBLESHOOTING.md
+[manual pages]: doc/
+[previous issues]: https://github.com/arkq/bluez-alsa/issues
+[wiki]: https://github.com/arkq/bluez-alsa/wiki
+[bluez-alsa GitHub issue reporting template]: https://github.com/arkq/bluez-alsa/issues/new/choose
+
+## License
+
+BlueALSA is licensed under the terms of the MIT license. See the [LICENSE
+file](LICENSE) for details.
 
 ## Resources
 
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 0000000..412d6c1
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,148 @@
+# Troubleshooting BlueALSA
+
+This document presents solutions to some of the most commonly encountered
+errors when using BlueALSA.
+
+## 1. Couldn't acquire D-Bus name: org.bluealsa
+
+The BlueALSA server registers a unique "well-known service name" with D-Bus,
+which is used by client applications to identify the correct service instance.
+By default it uses the name "org.bluealsa". There are three reasons why
+starting the service may fail with the
+`"Couldn't acquire D-Bus name: org.bluealsa"` error message:
+
+- The BlueALSA D-Bus policy file is not installed, or is in the wrong
+location.\
+In a default install, the file should be
+`/etc/dbus-1/system.d/bluealsa.conf`. Check with your distribution
+documentation in case D-Bus uses a different location on your system.
+Re-install BlueALSA if the file is missing.
+
+- The user account that the BlueALSA service is started under is not
+permitted to do so by the D-Bus policy.\
+The BlueALSA D-Bus policy file must contain a rule that permits the BlueALSA
+service account to register names with the prefix `org.bluealsa`. The default
+BlueALSA D-Bus policy file permits only `root` to register the prefix
+`org.bluealsa`. To permit some other user account the D-Bus policy must be
+updated. For example to permit the BlueALSA service to run under the account
+name `bluealsa` in addition to being able to run as `root`:
+
+   ```xml
+   <busconfig>
+
+     <policy user="bluealsa">
+       <allow own_prefix="org.bluealsa"/>
+       <allow send_destination="org.bluealsa"/>
+     </policy>
+
+     <policy user="root">
+       <allow own_prefix="org.bluealsa"/>
+       <allow send_destination="org.bluealsa"/>
+     </policy>
+
+     <policy group="audio">
+       <allow send_destination="org.bluealsa"/>
+     </policy>
+
+   </busconfig>
+   ```
+
+- Another instance of the BlueALSA service is already running.\
+To run a second instance of the BlueALSA service, it must use a different
+well-known service name. This will also require updating the BlueALSA D-Bus
+policy file. See the manual page [bluealsa(8)][] for more information and an
+example of running multiple `bluealsa` instances.
+
+If the D-Bus policy file is edited, then it is necessary to refresh the D-Bus
+service for the change to take effect. On most systems this can be achieved
+with (as `root`) :
+
+```sh
+systemctl reload dbus.service
+```
+
+[bluealsa(8)]: doc/bluealsa.8.rst
+
+## 2. Couldn't get BlueALSA PCM: PCM not found
+
+In contrast to standard ALSA sound cards, BlueALSA does not expose all PCMs
+right away. In the first place it is required to connect remote Bluetooth
+device with desired Bluetooth profile - run `bluealsa --help` for the list
+of available profiles. For querying currently connected audio profiles (and
+connected devices), run `bluealsa-aplay --list-devices`. The common
+misconception is an attempt to use A2DP playback device as a capture one in
+case where A2DP is not listed in the "List of CAPTURE Bluetooth Devices"
+section.
+
+Additionally, the cause of the "PCM not found" error might be an incorrect
+ALSA PCM name. Run `bluealsa-aplay --list-pcms` for the list of currently
+available ALSA PCM names - it might give you a hint what is wrong with your
+`.asoundrc` entry. Also, take a look at the [bluealsa-plugins manual
+page](doc/bluealsa-plugins.7.rst).
+
+## 3. Couldn't get BlueALSA PCM: Rejected send message
+
+This error message indicates that the user does not have permission to use
+the BlueALSA service. BlueALSA client applications require permission from
+D-Bus to communicate with the BlueALSA service. This permission is granted
+by a D-Bus policy configuration file. A default BlueALSA installation will
+grant permission only to members of the `audio` group and `root` (this is in
+line with normal practice on ALSA systems whereby membership of the `audio`
+group is required to use sound card devices).
+
+There are several reasons why this permission is not granted:
+
+- The D-Bus service has not been refreshed after installing BlueALSA.\
+Try `sudo systemctl reload dbus.service`. If that does not work, try
+rebooting.
+
+- The user is not a member of the `audio` group.
+
+- The user session was created before the user was added to the `audio`
+ group.\
+Log out, then log in again.
+
+- The BlueALSA D-Bus policy file is not installed, or is in the wrong
+location.\
+In a default install, the file should be
+`/etc/dbus-1/system.d/bluealsa.conf`. Check with your distribution
+documentation in case D-Bus uses a different location on your system.
+Re-install BlueALSA if the file is missing.
+
+## 4. Using BlueALSA alongside PulseAudio or PipeWire
+
+It is not advisable to run BlueALSA if either PulseAudio or PipeWire are also
+running with their own Bluetooth modules enabled. If one would like to have a
+deterministic setup, it is first necessary to disable Bluetooth in those
+applications.
+
+On startup, the BlueALSA service will issue warnings if some other application
+has already registered the Bluetooth Audio profiles:
+
+```text
+bluealsa: W: UUID already registered in BlueZ [hci0]: 0000110A-0000-1000-8000-00805F9B34FB
+bluealsa: W: UUID already registered in BlueZ [hci0]: 0000110B-0000-1000-8000-00805F9B34FB
+bluealsa: W: UUID already registered in BlueZ: 0000111F-0000-1000-8000-00805F9B34FB
+```
+
+However, as it is normal practice to start BlueALSA at boot and to start
+PulseAudio only when the user logs in, these warnings may not appear in the
+logs.
+
+In the unlikely event that one should need to run BlueALSA at the same time as
+PulseAudio, there are some hints on how to disable the PulseAudio Bluetooth
+modules in the wiki: [PulseAudio integration][]
+
+[PulseAudio integration]: https://github.com/arkq/bluez-alsa/wiki/PulseAudio-integration
+
+## 5. ALSA thread-safe API (alsa-lib >= 1.1.2, <= 1.1.3)
+
+ALSA library versions 1.1.2 and 1.1.3 had a bug in their thread-safe API
+functions. This bug does not affect hardware audio devices, but it affects
+many software plug-ins. Random deadlocks are inevitable. The best advice is
+to use a more recent alsa-lib release, or if that is not possible then
+disable the thread locking code via an environment variable, as follows:
+
+```shell
+export LIBASOUND_THREAD_SAFE=0
+```
diff --git a/configure.ac b/configure.ac
index 92dc5ee..1a9e1df 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1,10 +1,10 @@
 # BlueALSA - configure.ac
-# Copyright (c) 2016-2022 Arkadiusz Bokowy
+# Copyright (c) 2016-2023 Arkadiusz Bokowy
 
 AC_PREREQ([2.60])
 AC_INIT([BlueALSA],
-	[m4_normalize(esyscmd([test -d .git && git describe --always --dirty || echo v4.0.0]))],
-	[arkadiusz.bokowy@gmail.com], [bluez-alsa], [https://github.com/Arkq/bluez-alsa])
+	[m4_normalize(esyscmd([test -d .git && git describe --always --dirty || echo v4.1.0]))],
+	[arkadiusz.bokowy@gmail.com], [bluez-alsa], [https://github.com/arkq/bluez-alsa])
 AM_INIT_AUTOMAKE([foreign subdir-objects -Wall -Werror])
 
 AC_CONFIG_SRCDIR([src/bluealsa-config.c])
@@ -37,6 +37,7 @@ AC_ARG_ENABLE([debug],
 	AS_HELP_STRING([--enable-debug], [enable debugging support]))
 AM_CONDITIONAL([ENABLE_DEBUG], [test "x$enable_debug" = "xyes"])
 AM_COND_IF([ENABLE_DEBUG], [
+	AC_CHECK_FUNCS([gettid])
 	AC_CHECK_LIB([SegFault], [backtrace])
 	AC_DEFINE([DEBUG], [1], [Define to 1 if the debugging is enabled.])
 ])
@@ -53,7 +54,9 @@ AC_ARG_WITH([coverage],
 AM_CONDITIONAL([WITH_COVERAGE], [test "x$with_coverage" = "xyes"])
 AM_COND_IF([WITH_COVERAGE], [
 	AC_PATH_PROG([LCOV], [lcov])
+	AS_IF([test "x$LCOV" = "x"], [AC_MSG_ERROR([[--with-coverage requires lcov]])])
 	AC_PATH_PROG([GENHTML], [genhtml])
+	AS_IF([test "x$GENHTML" = "x"], [AC_MSG_ERROR([[--with-coverage requires genhtml]])])
 ])
 
 # in-place call-stack unwinding
@@ -217,6 +220,7 @@ AC_ARG_ENABLE([systemd],
 AM_CONDITIONAL([ENABLE_SYSTEMD], [test "x$enable_systemd" = "xyes"])
 AM_COND_IF([ENABLE_SYSTEMD], [
 	PKG_CHECK_MODULES([SYSTEMD], [systemd >= 200])
+	AC_DEFINE([ENABLE_SYSTEMD], [1], [Define to 1 if systemd is enabled.])
 ])
 
 AC_ARG_ENABLE([upower],
@@ -227,7 +231,8 @@ AM_COND_IF([ENABLE_UPOWER], [
 ])
 
 AC_ARG_ENABLE([payloadcheck],
-	[AS_HELP_STRING([--disable-payloadcheck], [disable RTP payload type check (workaround for a PulseAudio bug)])])
+	[AS_HELP_STRING([--disable-payloadcheck], [disable RTP payload type check (workaround
+		for PulseAudio < 13.0 bug)])])
 AM_CONDITIONAL([ENABLE_PAYLOADCHECK], [test "x$enable_payloadcheck" != "xno"])
 AM_COND_IF([ENABLE_PAYLOADCHECK], [
 	AC_DEFINE([ENABLE_PAYLOADCHECK], [1], [Define to 1 if PAYLOADCHECK is enabled.])
@@ -244,6 +249,10 @@ AM_CONDITIONAL([ENABLE_CLI], [test "x$enable_cli" = "xyes"])
 AC_ARG_ENABLE([rfcomm],
 	[AS_HELP_STRING([--enable-rfcomm], [enable building of bluealsa-rfcomm tool])])
 AM_CONDITIONAL([ENABLE_RFCOMM], [test "x$enable_rfcomm" = "xyes"])
+AM_COND_IF([ENABLE_RFCOMM], [
+	AC_CHECK_HEADERS([readline/readline.h readline/history.h],
+		[], [AC_MSG_ERROR([readline header files not found])])
+])
 
 AC_ARG_ENABLE([a2dpconf],
 	[AS_HELP_STRING([--enable-a2dpconf], [enable building of a2dpconf tool])])
@@ -269,6 +278,8 @@ AC_ARG_ENABLE([test],
 	[AS_HELP_STRING([--enable-test], [enable unit test])])
 AM_CONDITIONAL([ENABLE_TEST], [test "x$enable_test" = "xyes"])
 AM_COND_IF([ENABLE_TEST], [
+	AC_SEARCH_LIBS([dlsym], [dl],
+		[], [AC_MSG_ERROR([unable to find dlsym() function])])
 	PKG_CHECK_MODULES([CHECK], [check >= 0.9.10])
 	PKG_CHECK_MODULES([SNDFILE], [sndfile >= 1.0],
 		AC_DEFINE([HAVE_SNDFILE], [1], [Define to 1 if you have the sndfile library.]), [:])
@@ -292,14 +303,28 @@ AC_ARG_WITH([alsaplugindir],
 	[alsaplugindir=$($PKG_CONFIG --variable=libdir alsa)/alsa-lib])
 AC_SUBST([ALSA_PLUGIN_DIR], [$alsaplugindir])
 
+# If no --localstatedir and --prefix options were given, use the /var as a
+# default value for the local state directory.
+if test "$localstatedir" = "\${prefix}/var"; then
+	test "x$prefix" = xNONE && AC_SUBST([localstatedir], [/var])
+fi
+
+AC_SUBST([localstoragedir], [$localstatedir/lib])
+if test "$localstatedir" = "\${prefix}/var"; then
+	AC_SUBST([localstoragedir], [$prefix/var/lib])
+fi
+
 # Unfortunately, for ALSA >= 1.1.7 the directory for add-on configuration files
 # is hard-coded as /etc/alsa/conf.d (unless the distribution has patched the
 # source codes). So, we will use that value as a default, unless (for backwards
 # compatibility) the user overrides it with --prefix or --sysconfdir option.
 if test "$sysconfdir" = "\${prefix}/etc"; then
-	test "x$prefix" = xNONE && sysconfdir=/etc
+	test "x$prefix" = xNONE && AC_SUBST([sysconfdir], [/etc])
 fi
 
+AC_DEFINE_UNQUOTED(BLUEALSA_STORAGE_DIR, "${localstoragedir}/bluealsa",
+	[Directory for the storage files.])
+
 AC_ARG_WITH([alsaconfdir],
 	AS_HELP_STRING([--with-alsaconfdir=DIR], [path to ALSA add-on configuration files]),
 	[alsaconfdir="$withval"],
@@ -322,18 +347,34 @@ AC_SUBST([SYSTEMD_SYSTEM_UNIT_DIR], [$systemdsystemunitdir])
 
 AC_ARG_WITH([systemdbluealsaargs],
 	AS_HELP_STRING([--with-systemdbluealsaargs=ARGS], [bluealsa arguments to be used in
-		bluealsa.service, defaults to '-p a2dp-source -p a2dp-sink' if not specified]),
+		bluealsa.service, defaults to '-S -p a2dp-source -p a2dp-sink' if not specified]),
 	[systemdbluealsaargs="${withval}"],
-	[systemdbluealsaargs="-p a2dp-source -p a2dp-sink"])
+	[systemdbluealsaargs="-S -p a2dp-source -p a2dp-sink"])
 AC_SUBST([SYSTEMD_BLUEALSA_ARGS], [$systemdbluealsaargs])
 
 AC_ARG_WITH([systemdbluealsaaplayargs],
 	AS_HELP_STRING([--with-systemdbluealsaaplayargs=ARGS], [bluealsa-aplay arguments to
-		be used in bluealsa-aplay.service, defaults to empty if not specified]),
+		be used in bluealsa-aplay.service, defaults to '-S' if not specified]),
 	[systemdbluealsaaplayargs="${withval}"],
-	[systemdbluealsaaplayargs=""])
+	[systemdbluealsaaplayargs="-S"])
 AC_SUBST([SYSTEMD_BLUEALSA_APLAY_ARGS], [$systemdbluealsaaplayargs])
 
+AC_ARG_WITH([bluealsauser],
+	AS_HELP_STRING([--with-bluealsauser=USER], [set up installation to run bluealsa as user
+		USER, defaults to root if not specified. When used with bluez <= 5.50, USER must be a
+		member of the "bluetooth" group.]),
+	[bluealsauser="${withval}"],
+	[bluealsauser="root"])
+AC_SUBST([BLUEALSA_USER], [$bluealsauser])
+
+AC_ARG_WITH([bluealsaaplayuser],
+	AS_HELP_STRING([--with-bluealsaaplayuser=USER], [set up installation to run bluealsa-aplay
+		as user USER, defaults to root if not specified. USER must be a member of the "audio"
+		group.]),
+	[bluealsaaplayuser="${withval}"],
+	[bluealsaaplayuser="root"])
+AC_SUBST([BLUEALSA_APLAY_USER], [$bluealsaaplayuser])
+
 AC_CONFIG_FILES([
 	Makefile
 	doc/Makefile
@@ -345,7 +386,8 @@ AC_CONFIG_FILES([
 	utils/aplay/Makefile
 	utils/cli/Makefile
 	utils/rfcomm/Makefile
-	test/Makefile])
+	test/Makefile
+	test/mock/Makefile])
 AC_OUTPUT
 
 # warn user that alsa-lib thread-safety makes troubles
diff --git a/debian/changelog b/debian/changelog
index 2953227..e068092 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+bluez-alsa (4.1.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Fri, 23 Jun 2023 19:49:34 -0000
+
 bluez-alsa (4.0.0-2) unstable; urgency=medium
 
   * Enable libopenaptx.
diff --git a/doc/bluealsa-api.txt b/doc/bluealsa-api.txt
index 891cb1a..cc397e3 100644
--- a/doc/bluealsa-api.txt
+++ b/doc/bluealsa-api.txt
@@ -5,22 +5,6 @@ Service         org.bluealsa[.unique ID]
 Interface       org.bluealsa.Manager1
 Object path     [variable prefix]/
 
-Methods         array{object, dict} GetPCMs() [Deprecated]
-
-                        Returns the array of available PCM objects and
-                        associated properties.
-
-Signals         void PCMAdded(object path, dict props) [Deprecated]
-
-                        Signal emitted when new PCM is added. It contains
-                        the object path and associated properties.
-
-                void PCMRemoved(object path) [Deprecated]
-
-                        Signal emitted when device has been removed. The
-                        object path is only for a reference - it has been
-                        already removed.
-
 Properties      string Version [readonly]
 
                         Version of BlueALSA service.
@@ -72,7 +56,7 @@ Methods         fd, fd Open()
                         ongoing stream (or PCM counterpart: sink, source) will
                         be terminated.
 
-                        For A2DP codecs, client can override build-in logic
+                        For A2DP codecs, client can override built-in logic
                         for selecting codec configuration by providing the
                         configuration blob via the "Configuration" property.
 
@@ -103,6 +87,11 @@ Properties      object Device [readonly]
 
                         Possible values: "sink" or "source"
 
+               boolean Running [readonly]
+
+                        This property is true when the Bluetooth transport for
+                        this PCM is acquired and able to transfer audio samples.
+
                 uint16 Format [readonly]
 
                         Stream format identifier. The highest two bits of the
@@ -125,7 +114,14 @@ Properties      object Device [readonly]
                 uint16 Codec [readonly]
 
                         Bluetooth transport codec. The meaning of this value
-                        depends on the Bluetooth transport type.
+                        depends on the Bluetooth transport type. This property
+                        is available only when transport codec is selected.
+
+                array{byte} CodecConfiguration [readonly]
+
+                        Optional. Bluetooth transport codec configuration blob.
+                        This property is available only for transports which
+                        support codec configuration (e.g. A2DP).
 
                 uint16 Delay [readonly]
 
@@ -172,10 +168,9 @@ Properties      string Transport [readonly]
                         Possible values: "HFP-AG", "HFP-HF", "HSP-AG" or
                                          "HSP-HS"
 
-                uint32 Features [readonly]
+                array{string} Features [readonly]
 
-                        HFP feature bitmask. Note, that this value depends on
-                        the connection mode.
+                        List of features supported by the remote device.
 
                 byte Battery [readonly]
 
diff --git a/doc/bluealsa-aplay.1.rst b/doc/bluealsa-aplay.1.rst
index 9541268..e4c21a5 100644
--- a/doc/bluealsa-aplay.1.rst
+++ b/doc/bluealsa-aplay.1.rst
@@ -6,7 +6,7 @@ bluealsa-aplay
 a simple bluealsa player
 ------------------------
 
-:Date: November 2021
+:Date: January 2023
 :Manual section: 1
 :Manual group: General Commands Manual
 :Version: $VERSION$
@@ -19,14 +19,15 @@ SYNOPSIS
 DESCRIPTION
 ===========
 
-Capture audio streams from Bluetooth devices (via ``bluealsa(8)``) and play them to an ALSA
+Capture audio streams from Bluetooth devices (via ``bluealsa(8)``) and play
+them to an ALSA
 playback device.
 
-By default **bluealsa-aplay** captures audio from all connected Bluetooth devices.
-It is possible to select specific Bluetooth devices by providing a list of *BT-ADDR* MAC
-addresses.
-Using the special MAC address **00:00:00:00:00:00** will disable device filtering - the
-same as the default behavior.
+By default **bluealsa-aplay** captures audio from all connected Bluetooth
+devices.  It is possible to select specific Bluetooth devices by providing a
+list of *BT-ADDR* MAC addresses.
+Using the special MAC address **00:00:00:00:00:00** will disable device
+filtering - the same as the default behavior.
 
 OPTIONS
 =======
@@ -37,6 +38,10 @@ OPTIONS
 -V, --version
     Output the version number and exit.
 
+-S, --syslog
+    Send output to system logger (``syslogd(8)``).
+    By default, log output is sent to stderr.
+
 -v, --verbose
     Make the output more verbose.
 
@@ -48,19 +53,24 @@ OPTIONS
 
 -B NAME, --dbus=NAME
     BlueALSA service name suffix.
-    For more information see ``--dbus=NAME`` option of ``bluealsa(8)`` service daemon.
+    For more information see ``--dbus=NAME`` option of ``bluealsa(8)`` service
+    daemon.
 
 -D NAME, --pcm=NAME
     Select ALSA playback PCM device to use for audio output.
     The default is ``default``.
 
-    **bluealsa-aplay** does not perform any mixing of streams. If multiple devices
-    are connected it opens a new connection to the ALSA PCM device for each stream.
-    Therefore the PCM *NAME* must itself allow multiple open connections and
-    mix the streams together. See option **--single-audio** to change this
-    behavior. Similarly, **bluealsa-aplay** does not apply any
-    transformations to the stream. For this reason it is often necessary to use
-    the ALSA **dmix** and **plug** plugins in the *NAME* PCM.
+    Internally, **bluealsa-aplay** does not perform any audio transformations
+    nor streams mixing. If multiple Bluetooth devices are connected it simply
+    opens a new connection to the ALSA PCM device for each stream. Selected
+    hardware parameters like sampling frequency and number of channels are
+    taken from the audio profile of a particular Bluetooth connection. Note,
+    that each connection can have a different setup.
+
+    If playing multiple streams at the same time is not desired, it is possible
+    to change that behavior by using the **--single-audio** option.
+
+    For more information see the EXAMPLES_ section below.
 
 --pcm-buffer-time=INT
     Set the playback PCM buffer duration time to *INT* microseconds.
@@ -83,19 +93,19 @@ OPTIONS
     The ALSA **rate** plugin, which may be invoked by **plug**, does not always
     produce the exact required effective sample rate because of rounding errors
     in the conversion between period time and period size. This can have a
-    significant impact on synchronization "drift", especially with small
-    period sizes, and can also result in stream underruns (if the effective
-    rate is too fast) or dropped A2DP frames in the **bluealsa(8)** server (if
-    the effective rate is too slow). This effect is avoided if the selected
-    period time results in an exact integer number of frames for both the source
-    rate (Bluetooth) and sink rate (hardware card). For example, in
-    the case of Bluetooth stream sampled at 44100Hz playing to a hardware
-    device that supports only 48000Hz, choosing a period time that is a
-    multiple of 10000 microseconds will result in zero rounding error.
-    (10000 µs at 44100Hz is 441 frames, and at 48000Hz is 480 frames).
-
-    See also DMIX_ section below for more information on rate calculation
-    rounding errors.
+    significant impact on synchronization "drift", especially with small period
+    sizes, and can also result in stream underruns (if the effective rate is
+    too fast) or dropped A2DP frames in the **bluealsa(8)** server (if the
+    effective rate is too slow). This effect is avoided if the selected period
+    time results in an exact integer number of frames for both the source rate
+    (Bluetooth) and sink rate (hardware card). For example, in the case of
+    Bluetooth stream sampled at 44100Hz playing to a hardware device that
+    supports only 48000Hz, choosing a period time that is a multiple of 10000
+    microseconds will result in zero rounding error.  (10000 µs at 44100Hz is
+    441 frames, and at 48000Hz is 480 frames).
+
+    See also dmix_ in the **NOTES** section below for more information on
+    rate calculation rounding errors.
 
 -M NAME, --mixer-device=NAME
     Select ALSA mixer device to use for controlling audio output mute state
@@ -103,14 +113,25 @@ OPTIONS
     In order to use this feature, BlueALSA PCM can not use software volume.
     The default is ``default``.
 
+    See `Volume control`_ in the **NOTES** section below for more information
+    on volume control.
+
 --mixer-name=NAME
-    Set the name of the mixer element.
+    Set the name of the ALSA simple mixer control to use.
     The default is ``Master``.
 
+    To work with ``bluealsa-aplay`` this simple control must provide decibel
+    scaling information for the volume control. Most, but not all, modern sound
+    cards do provide this information.
+
 --mixer-index=NUM
-    Set the index of the mixer channel.
+    Set the index of the ALSA simple mixer control.
     The default is ``0``.
 
+    This is required only if the simple mixer control name applies to multiple
+    simple controls on the same card. This is most common with HDMI devices
+    which may have many playback ports.
+
 --profile-a2dp
     Use A2DP profile (default).
 
@@ -132,16 +153,38 @@ OPTIONS
     PCM to be able to mix audio from multiple sources (i.e., it can be opened
     more than once; for example the ALSA **dmix** plugin).
 
-DMIX
-====
+NOTES
+=====
+
+Volume control
+--------------
+
+If the BlueALSA PCM is using native Bluetooth volume control, then
+**bluealsa-aplay** operates its given ALSA mixer control to implement volume
+change requests received from the remote Bluetooth device.
+
+If the Bluetooth PCM is using soft-volume volume control, then volume
+adjustment will have been applied to the PCM stream within the **bluealsa**
+daemon; so **bluealsa-aplay** does not operate the mixer control in this case.
+
+Native Bluetooth volume control for A2DP relies on AVRCP volume control in
+BlueZ, which has not always been reliably implemented. It is recommended to use
+BlueZ release 5.65 or later to be certain that native A2DP volume control will
+always be available with those devices which provide it.
+
+See ``bluealsa(8)`` for more information on native and soft-volume volume
+control.
+
+dmix
+----
 
 The ALSA `dmix` plugin will ignore the period and buffer times selected by the
 application (because it has to allow connections from multiple applications).
 Instead it will choose its own values, which can lead to rounding errors in the
-period size calculation when used with the ALSA `rate` plugin. To avoid this, it
-is recommended to explicitly define the hardware period size and buffer size for
-dmix in your ALSA configuration. For example, suppose we want a period time of
-100000 µs and a buffer holding 5 periods with an Intel 'PCH' card:
+period size calculation when used with the ALSA `rate` plugin. To avoid this,
+it is recommended to explicitly define the hardware period size and buffer size
+for dmix in your ALSA configuration. For example, suppose we want a period time
+of 100000 µs and a buffer holding 5 periods with an Intel 'PCH' card:
 
 ::
 
@@ -165,16 +208,59 @@ Alternatively we can define a PCM with the required setting:
         }
     }
 
-SEE ALSO
+EXAMPLES
 ========
 
-``bluealsa(8)``, ``bluealsa-rfcomm(1)``
+The simplest usage of **bluealsa-aplay** is to run it with no arguments. It
+will play audio from all connected Bluetooth devices to the ``default`` ALSA
+playback PCM.
+
+::
+
+    bluealsa-aplay
+
+If there is more than one sound card attached one can create a setup where the
+audio of a particular Bluetooth device is played to a specific sound card. The
+setup below shows how to do this using the ``--pcm=NAME`` option and known
+Bluetooth device addresses.
+
+Please note that in the following example we assume that the second card is
+named "USB" and the appropriate mixer control is named "Speaker". Real names
+of attached sound cards can be obtained by running **aplay -l**. A list of
+control names for a card called "USB" can be obtained by running
+**amixer -c USB scontrols**.
+
+::
+
+    bluealsa-aplay --pcm=default 94:B8:6D:AF:CD:EF F8:87:F1:B8:30:85 &
+    bluealsa-aplay --pcm=default:USB C8:F7:33:66:F0:DE &
 
-Project web site at https://github.com/Arkq/bluez-alsa
+Also, it might be desired to specify ALSA mixer device and/or control element
+for each ALSA playback PCM device. This will be mostly useful when BlueALSA PCM
+does not use software volume (for more information see ``--a2dp-volume`` option
+of ``bluealsa(8)`` service daemon).
+
+::
+
+    bluealsa-aplay --pcm=default 94:B8:6D:AF:CD:EF F8:87:F1:B8:30:85 &
+    bluealsa-aplay --pcm=default:USB --mixer-device=hw:USB --mixer-name=Speaker C8:F7:33:66:F0:DE &
+
+Such setup will route ``94:B8:6D:AF:CD:EF`` and ``F8:87:F1:B8:30:85`` Bluetooth
+devices to the ``default`` ALSA playback PCM device and ``C8:F7:33:66:F0:DE``
+device to the USB sound card. For the USB sound card the ``Speaker`` control
+element will be used as a hardware volume control knob.
 
 COPYRIGHT
 =========
 
-Copyright (c) 2016-2021 Arkadiusz Bokowy.
+Copyright (c) 2016-2023 Arkadiusz Bokowy.
 
 The bluez-alsa project is licensed under the terms of the MIT license.
+
+SEE ALSO
+========
+
+``amixer(1)``, ``aplay(1)``, ``bluealsa-rfcomm(1)``, ``bluealsa(8)``
+
+Project web site
+  https://github.com/arkq/bluez-alsa
diff --git a/doc/bluealsa-cli.1.rst b/doc/bluealsa-cli.1.rst
index 0342ed3..e2abaff 100644
--- a/doc/bluealsa-cli.1.rst
+++ b/doc/bluealsa-cli.1.rst
@@ -6,7 +6,7 @@ bluealsa-cli
 a simple command line interface for the BlueALSA D-Bus API
 ----------------------------------------------------------
 
-:Date: January 2022
+:Date: January 2023
 :Manual section: 1
 :Manual group: General Commands Manual
 :Version: $VERSION$
@@ -20,17 +20,16 @@ DESCRIPTION
 ===========
 
 **bluealsa-cli** provides command-line access to the BlueALSA D-Bus API
-"org.bluealsa.Manager1" and "org.bluealsa.PCM1" interfaces and thus
-allows introspection and some control of BlueALSA PCMs while they are running.
-
-The *PCM_PATH* command argument, where required, must be a BlueALSA PCM D-Bus
-path.
+"org.bluealsa.Manager1" and "org.bluealsa.PCM1" interfaces and thus allows
+introspection and some control of BlueALSA PCMs while they are running.
 
 OPTIONS
 =======
 
 -h, --help
-    Output a usage message.
+    Output a usage message. When used before the *COMMAND* prints a list of
+    options and commands. When used as a *COMMAND* *ARG* prints help specific
+    to that *COMMAND*
 
 -V, --version
     Output the version number.
@@ -50,13 +49,19 @@ COMMANDS
 
 If no *COMMAND* is given, the default is **status**.
 
+All commands may be given the *ARG* **--help**, other *ARGs* are described
+against each command below.
+
+The *PCM_PATH* command argument, where required, must be a BlueALSA PCM D-Bus
+path. Use the command **list-pcms** to obtain a list of valid PCM D-Bus paths.
+
 status
     Print properties of the service: service name, build version, in-use
     Bluetooth adapters, available profiles and codecs. Example output:
     ::
 
         Service: org.bluealsa
-        Version: v3.1.0-96-gc86142d
+        Version: v3.1.0
         Adapters: hci0 hci1
         Profiles:
           A2DP-source : SBC AAC
@@ -69,7 +74,7 @@ list-services
 list-pcms
     Print a list of BlueALSA PCM D-Bus paths, one per line.
 
-    If the *--verbose* option is given then the properties of each connected
+    If the **--verbose** option is given then the properties of each connected
     PCM are printed after each path, one per line, in the same format as the
     **info** command.
 
@@ -81,9 +86,12 @@ info *PCM_PATH*
 
     ``Volume: L: 127 R: 127``
 
-    The list of available codecs requires BlueZ SEP support (BlueZ >= 5.52)
+    The list of available A2DP codecs requires BlueZ SEP support
+    (BlueZ >= 5.52)
 
 codec *PCM_PATH* [*CODEC* [*CONFIG*]]
+    Get or set the Bluetooth codec used by the given PCM.
+
     If *CODEC* is given, change the codec to be used by the given PCM. This
     command will terminate the PCM if it is currently running.
 
@@ -95,43 +103,56 @@ codec *PCM_PATH* [*CODEC* [*CONFIG*]]
     this parameter is omitted, BlueALSA will select default configuration based
     on codec capabilities of connected Bluetooth device.
 
-    Selecting a codec and listing available codecs requires BlueZ SEP support
-    (BlueZ >= 5.52).
+    Selecting an A2DP codec and listing available A2DP codecs requires BlueZ
+    SEP support (BlueZ >= 5.52).
+
+    BlueALSA does not support changing the HFP codec from an HFP-HF node. The
+    codec can only be changed from the HFP-AG node. Using the
+    **bluealsa-cli codec** command to set the codec from an HFP-HF node fails,
+    reporting an input/output error.
+
+    Selecting the HFP codec when using oFono is not supported.
 
-volume *PCM_PATH* [*N*] [*N*]
-    If *N* is given, set the loudness component of the volume property of the
-    given PCM.
+volume *PCM_PATH* [*VOLUME* [*VOLUME*]]
+    Get or set the volume value of the given PCM.
 
-    If only one value *N* is given it is applied to all channels.
-    For stereo (2-channel) PCMs the first value *N* is applied to channel 1
-    (Left), and the second value *N* is applied to channel 2 (Right).
-    For mono (1-channel) PCMs the second value *N* is ignored.
+    If *VOLUME* is given, set the loudness component of the volume property of
+    the given PCM.
 
-    Valid A2DP values for *N* are 0-127, valid HFP/HSP values are 0-15.
+    If only one value *VOLUME* is given it is applied to all channels.
+    For stereo (2-channel) PCMs the first value *VOLUME* is applied to channel
+    1 (Left), and the second value *VOLUME* is applied to channel 2 (Right).
+    For mono (1-channel) PCMs the second value *VOLUME* is ignored.
 
-    If no *N* is given, print the current volume setting of the given PCM.
+    Valid A2DP values for *VOLUME* are 0-127, valid HFP/HSP values are 0-15.
 
-mute *PCM_PATH* [y|n] [y|n]
-    If y|n argument(s) are given, set mute component of the volume property of
-    the given PCM - 'y' mutes the volume, 'n' unmutes it. The second y|n
-    argument is used for stereo PCMs as described for ``volume``.
+mute *PCM_PATH* [*STATE* [*STATE*]]
+    Get or set the mute switch of the given PCM.
 
-    If no argument is given, print the current mute setting of the given PCM.
+    If *STATE* argument(s) are given, set mute component of the volume property
+    of the given PCM. The second *STATE* argument is used for stereo PCMs as
+    described for the **volume** command.
 
-soft-volume *PCM_PATH* [y|n]
-    If the y|n argument is given, set the SoftVolume property for the given PCM.
-    This property determines whether BlueALSA will make volume control
+    The *STATE* value can be one of **on**, **yes**, **true**, **y** or **1**
+    for mute on, or **off**, **no**, **false**, **n** or **0** for mute off.
+
+soft-volume *PCM_PATH* [*STATE*]
+    Get or set the SoftVolume property of the given PCM.
+
+    If the *STATE* argument is given, set the SoftVolume property for the given
+    PCM. This property determines whether BlueALSA will make volume control
     internally or will delegate this task to BlueALSA PCM client or connected
-    Bluetooth device respectively for PCM sink or PCM source. The value 'y'
-    enables SoftVolume, 'n' disables it.
+    Bluetooth device respectively for PCM sink or PCM source.
 
-    If no argument is given, print the current SoftVolume property of the given
-    PCM.
+    The *STATE* value can be one of **on**, **yes**, **true**, **y** or **1**
+    for soft-volume on, or **off**, **no**, **false**, **n** or **0** for
+    soft-volume off.
 
-monitor
+monitor [-p[PROPS] | --properties[=PROPS]]
     Listen for D-Bus signals indicating adding/removing BlueALSA interfaces.
-    Also detect service running and service stopped events. Print a line on
-    standard output for each one received.
+    Also detect service running and service stopped events, and optionally
+    PCM property change events. Print a line on standard output for each one
+    received.
 
     PCM event output lines are formed as:
 
@@ -139,6 +160,11 @@ monitor
 
     ``PCMRemoved PCM_PATH``
 
+    If the **--verbose** option is given then the properties of each added PCM
+    are printed after the PCMAdded line, one per line, in the same format as
+    the **info** command. In this case a blank line is printed after the last
+    property.
+
     RFCOMM event output lines are formed as:
 
     ``RFCOMMAdded RFCOMM_PATH``
@@ -151,14 +177,27 @@ monitor
 
     ``ServiceStopped SERVICE_NAME``
 
-    If the *--verbose* option is given then the properties of each added PCM are
-    printed after the PCMAdded line, one per line, in the same format as the
-    **info** command. In this case a blank line is printed after the last
-    property.
-
     When the monitor starts, it begins by printing a ``ServiceRunning`` or
     ``ServiceStopped`` message according to the current state of the service.
 
+    If the **-p** or **--properties** option is given then also detect changes
+    to certain PCM properties. Print a line on standard output for each
+    property change. The output lines are formed as:
+
+    ``PropertyChanged PCM_PATH PROPERTY_NAME VALUE``
+
+    Property names than can be monitored are **Codec**, **Running**,
+    **SoftVolume** and **Volume**.
+
+    The value for Volume is a hexadecimal 16-bit encoding where data for
+    channel 1 is stored in the upper byte, channel 2 is stored in the lower
+    byte. The highest bit of both bytes determines whether channel is muted.
+
+    *PROPS* is an optional comma-separated list of property names to be
+    monitored. If given, only changes to those properties listed will be
+    printed. If this argument is not given then changes to any of the above
+    properties are printed.
+
 open *PCM_PATH*
     Transfer raw audio frames to or from the given PCM. For sink PCMs
     the frames are read from standard input and written to the PCM. For
@@ -166,16 +205,17 @@ open *PCM_PATH*
     output. The format, channels and sampling rate must match the properties
     of the PCM, as no format conversions are performed by this tool.
 
-SEE ALSO
-========
-
-``bluealsa(8)``, ``bluealsa-aplay(1)``, ``bluealsa-rfcomm(1)``
-
-Project web site at https://github.com/Arkq/bluez-alsa
-
 COPYRIGHT
 =========
 
-Copyright (c) 2016-2022 Arkadiusz Bokowy.
+Copyright (c) 2016-2023 Arkadiusz Bokowy.
 
 The bluez-alsa project is licensed under the terms of the MIT license.
+
+SEE ALSO
+========
+
+``bluealsa(8)``, ``bluealsa-aplay(1)``, ``bluealsa-rfcomm(1)``
+
+Project web site
+  https://github.com/arkq/bluez-alsa
diff --git a/doc/bluealsa-plugins.7.rst b/doc/bluealsa-plugins.7.rst
index d9311c4..7d8e9d1 100644
--- a/doc/bluealsa-plugins.7.rst
+++ b/doc/bluealsa-plugins.7.rst
@@ -5,25 +5,36 @@ bluealsa-plugins
 Bluetooth Audio ALSA Plugins
 ----------------------------
 
-:Date: September 2021
+:Date: March 2023
 :Manual section: 7
 :Manual group: Miscellaneous
 :Version: $VERSION$
 
-SYNOPSIS
-========
+DESCRIPTION
+===========
 
-BlueALSA permits applications to access Bluetooth audio devices using the ALSA alsa-lib API. Users of those applications can then use Bluetooth speakers, headphones, headsets and hands-free devices much as if they were local devices. This integration is achieved by two ALSA plugins, one for PCM audio streams and one for CTL volume controls.
+BlueALSA permits applications to access Bluetooth audio devices using the ALSA
+alsa-lib API. Users of those applications can then use Bluetooth speakers,
+headphones, headsets and hands-free devices much as if they were local devices.
+This integration is achieved by two ALSA plugins, one for PCM audio streams and
+one for CTL volume controls.
 
 PCM PLUGIN
 ==========
 
-The BlueALSA ALSA PCM plugin communicates with the ``bluealsa(8)`` service. It can be used to define ALSA PCMs in your own configuration file (e.g. ~/.asoundrc), or you can use the pre-defined **bluealsa** PCM.
+The BlueALSA ALSA PCM plugin communicates with the ``bluealsa(8)`` service.
+It can be used to define ALSA PCMs in your own configuration file (e.g.
+~/.asoundrc), or you can use the predefined **bluealsa** PCM.
 
 The Predefined **bluealsa** PCM
 -------------------------------
 
-The simplest way to use the PCM plugin is with the predefined ALSA PCM device **bluealsa**. The definition of this PCM device is of type ``plug`` so audio format conversion, if required, is done automatically by the PCM. It has parameters DEV, PROFILE, CODEC, VOL, SOFTVOL, DELAY, and SRV. All these parameters have defaults. Parameter values in an ALSA PCM name are specified using the syntax:
+The simplest way to use the PCM plugin is with the predefined ALSA PCM device
+**bluealsa**. The definition of this PCM device is of type ``plug`` so audio
+format conversion, if required, is done automatically by the PCM. It has
+parameters DEV, PROFILE, CODEC, VOL, SOFTVOL, DELAY, and SRV. All these
+parameters have defaults. Parameter values in an ALSA PCM name are specified
+using the syntax:
 
 ::
 
@@ -33,41 +44,74 @@ PCM Parameters
 ~~~~~~~~~~~~~~
 
   DEV
-    The device Bluetooth address in the form *XX:XX:XX:XX:XX:XX*. Device names or aliases are not valid here. The default value is **00:00:00:00:00:00** which selects the most recently connected device of the chosen profile.
+    The device Bluetooth address in the form *XX:XX:XX:XX:XX:XX*. Device names
+    or aliases are not valid here. The default value is **00:00:00:00:00:00**
+    which selects the most recently connected device of the chosen profile.
 
   PROFILE
-    May be either **a2dp** or **sco**. **sco** selects either Hands-Free (HFP) or Headset (HSP) profile, whichever is connected on the selected device. The default is **a2dp**.
+    May be either **a2dp** or **sco**. **sco** selects either Hands-Free (HFP)
+    or Headset (HSP) profile, whichever is connected on the selected device.
+    The default is **a2dp**.
 
   CODEC
-    Specifies the codec to be used by the profile. When a connection is established between a device and a host, BlueALSA negotiates the best available codec with the device; this parameter allows the ALSA configuration to override that selection. The default value is **unchanged** which causes the PCM to use its existing codec setting. The codec name is case insensitive; so for example **aptX**, **aptx**, and **APTX** are all accepted. If the specified codec is not available the plugin issues a warning and uses the default value instead.
+    Specifies the codec to be used by the profile. When a connection is
+    established between a device and a host, BlueALSA negotiates the best
+    available codec with the device; this parameter allows the ALSA
+    configuration to override that selection. The default value is
+    **unchanged** which causes the PCM to use its existing codec setting. The
+    codec name is case insensitive; so for example **aptX**, **aptx**, and
+    **APTX** are all accepted. If the specified codec is not available the
+    plugin issues a warning and uses the default value instead.
+
+    BlueALSA does not support changing the HFP codec from a HFP-HF node, only
+    the HFP-AG node can change the HFP codec.
 
-    For the A2DP profile it is possible to also specify a "configuration" for the codec by appending the configuration as a hex string separated from the codec name by a colon. For example:
+    oFono does not permit the audio agent to select the codec, so this
+    parameter has no effect when BlueALSA is used with oFono for HFP support.
+
+    For the A2DP profile it is possible to also specify a "configuration" for
+    the codec by appending the configuration as a hex string separated from the
+    codec name by a colon. For example:
 
     ::
 
       CODEC=aptx:4f0000000100ff
 
-
   VOL
-    Specifies the initial volume for the PCM when opened. The default value is **unchanged** which causes the PCM to use its existing volume setting. The value is an integer percentage of the maximum volume [0-100]. The mute status can also be set by appending the character '-' to mute the sound or '+' to unmute it. The volume is not restored to its original value when the PCM is closed. For example to set the initial volume to 80% and ensure that mute is disabled for this PCM:
+    Specifies the initial volume for the PCM when opened. The default value is
+    **unchanged** which causes the PCM to use its existing volume setting. The
+    value is an integer percentage of the maximum volume [0-100]. The mute
+    status can also be set by appending the character '-' to mute the sound or
+    '+' to unmute it. The volume is not restored to its original value when the
+    PCM is closed. For example to set the initial volume to 80% and ensure that
+    mute is disabled for this PCM:
 
     ::
 
       VOL=80+
 
   SOFTVOL
-    Enables or disables BlueALSA's software volume feature for this PCM. See the ``bluealsa(8)`` manual page for more information on software volume. This is a boolean option (values **on** or **off**), but also accepts the special value **unchanged** which causes the PCM to use its existing softvol value. The default value is **unchanged**.
+    Enables or disables BlueALSA's software volume feature for this PCM. See
+    the ``bluealsa(8)`` manual page for more information on software volume.
+    This is a boolean option (values **on** or **off**), but also accepts the
+    special value **unchanged** which causes the PCM to use its existing
+    softvol value. The default value is **unchanged**.
 
   DELAY
-    An integer number which is added to the reported latency value in order to manually adjust the audio synchronization. It is not normally required and defaults to **0**.
+    An integer number which is added to the reported latency value in order to
+    manually adjust the audio synchronization. It is not normally required and
+    defaults to **0**.
 
   SRV
-    The D-Bus service name of the BlueALSA daemon. Defaults to **org.bluealsa**. See ``bluealsa(8)`` for more information. Not normally required.
+    The D-Bus service name of the BlueALSA daemon. Defaults to
+    **org.bluealsa**. See ``bluealsa(8)`` for more information. Not normally
+    required.
 
 Setting Different Defaults
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The defaults can be overridden by defining the ones you want to change in your own configuration (e.g. in ~/.asoundrc.conf) for example:
+The defaults can be overridden by defining the ones you want to change in your
+own configuration (e.g. in ~/.asoundrc.conf) for example:
 
 ::
 
@@ -82,13 +126,17 @@ The defaults can be overridden by defining the ones you want to change in your o
 Positional Parameters
 ~~~~~~~~~~~~~~~~~~~~~
 
-ALSA permits arguments to be given as positional parameters as an alternative to explicitly naming them. When using positional parameters it is important that the values are given in the correct sequence - *DEV*, *PROFILE*, *CODEC*, *VOL*, *SOFTVOL*, *DELAY*, *SRV*. For example:
+ALSA permits arguments to be given as positional parameters as an alternative
+to explicitly naming them. When using positional parameters it is important
+that the values are given in the correct sequence - *DEV*, *PROFILE*, *CODEC*,
+*VOL*, *SOFTVOL*, *DELAY*, *SRV*. For example:
 
 ::
 
   bluealsa:01:23:45:67:89:AB,a2dp,unchanged,unchanged,unchanged,0,org.bluealsa
 
-When using positional parameters defaults can only be implied at the end of the id string, so
+When using positional parameters defaults can only be implied at the end of the
+id string, so
 
 ::
 
@@ -105,7 +153,9 @@ is not permitted.
 Defining BlueALSA PCMs
 ----------------------
 
-You can define your own ALSA PCM in the ALSA configuration. To do this, create an ALSA configuration node defining a PCM with type ``bluealsa``. The configuration node has the following fields:
+You can define your own ALSA PCM in the ALSA configuration. To do this, create
+an ALSA configuration node defining a PCM with type ``bluealsa``. The
+configuration node has the following fields:
 
 ::
 
@@ -120,18 +170,36 @@ You can define your own ALSA PCM in the ALSA configuration. To do this, create a
     [service STR]     # DBus name of service (default org.bluealsa)
   }
 
-The **device** and **profile** fields must be specified so that the plugin can select the correct Bluetooth transport; the other fields are optional. Note that the default values for the optional fields are not overridden automatically by the configuration ``defaults.bluealsa.*`` in a PCM defined this way; however the configuration defaults can be referenced by use of ``@func refer`` (see the `ALSA configuration file syntax` documentation for more information).
+The **device** and **profile** fields must be specified so that the plugin can
+select the correct Bluetooth transport; the other fields are optional. Note
+that the default values for the optional fields are not overridden
+automatically by the configuration ``defaults.bluealsa.*`` in a PCM defined
+this way; however the configuration defaults can be referenced by use of
+``@func refer`` (see the `ALSA configuration file syntax` documentation for
+more information).
 
-When choosing a name for your PCM definition, the name **pcm.bluealsa** is pre-defined by the bluez-alsa installation (see section *The Predefined bluealsa PCM* above), so it should not be used as a name for your own PCM devices as doing so will most likely have unexpected or undesirable results.
+When choosing a name for your PCM definition, the name **pcm.bluealsa** is
+predefined by the bluez-alsa installation (see section *The Predefined
+bluealsa PCM* above), so it should not be used as a name for your own PCM
+devices as doing so will most likely have unexpected or undesirable results.
 
-Note that the **volume** field is of type **string**, so the value must be enclosed in double-quotes. See the *PCM Parameters* section above for more information on each field.
+Note that the **volume** field is of type **string**, so the value must be
+enclosed in double-quotes. See the *PCM Parameters* section above for more
+information on each field.
 
-Do not confuse the PCM type **bluealsa** with the PCM named **bluealsa**. The type does not perform any audio conversions, you will have to wrap your own defined PCMs with type **plug** to achieve that; whereas the predefined PCM **pcm.bluealsa** *is* of type **plug**.
+Do not confuse the PCM type **bluealsa** with the PCM named **bluealsa**. The
+type does not perform any audio conversions, you will have to wrap your own
+defined PCMs with type **plug** to achieve that; whereas the predefined PCM
+**pcm.bluealsa** *is* of type **plug**.
 
 Name Hints
 ----------
 
-Applications that follow ALSA guidelines will obtain the list of defined PCMs by using the alsa-lib ``namehints`` API. To make BlueALSA PCMs visible via that API it is necessary to add a "hint" section to the ALSA configuration. If you have defined a new PCM, then the hint goes into the PCM configuration entry as follows:
+Applications that follow ALSA guidelines will obtain the list of defined PCMs
+by using the alsa-lib ``namehints`` API. To make BlueALSA PCMs visible via that
+API it is necessary to add a "hint" section to the ALSA configuration. If you
+have defined a new PCM, then the hint goes into the PCM configuration entry as
+follows:
 
 ::
 
@@ -157,7 +225,8 @@ Now using ``aplay -L`` will include the following in its output:
       My Bluetooth headphones
   #
 
-If you are using the pre-defined bluealsa PCM, then you can create a "namehint" entry in your ~/.asoundrc file like this:
+If you are using the predefined bluealsa PCM, then you can create a "namehint"
+entry in your ~/.asoundrc file like this:
 
 ::
 
@@ -173,7 +242,8 @@ Then ``aplay -L`` shows
   bluealsa:DEV=00:11:22:33:44:55,PROFILE=a2dp
       My Bluetooth headphones
 
-For alsa-lib versions before v1.2.3.2, a bug in the namehint parser means that a **namehint.pcm** entry has to be written as
+For alsa-lib versions before v1.2.3.2, a bug in the namehint parser means that
+a **namehint.pcm** entry has to be written as
 
 ::
 
@@ -181,11 +251,19 @@ For alsa-lib versions before v1.2.3.2, a bug in the namehint parser means that a
       mybluealsadevice "bluealsa:DEV=00:11:22:33:44:55,PROFILE=a2dp|DESCMy Bluetooth headphones"
   }
 
-(note the keyword **DESC** after the pipe symbol and before the description text.)
+(note the keyword **DESC** after the pipe symbol and before the description
+text.)
 
-With that hint in place, the PCM will be listed as both a Capture and Playback device. So ``arecord -L`` will also list it. That is generally OK for HFP/HSP devices, but an A2DP device most often offers only Capture (e.g. a mobile phone) or only Playback (e.g. a Bluetooth speaker). It is possible to use the hint description to limit the listing to only one direction using an undocumented syntax of ALSA configuration files.
+With that hint in place, the PCM will be listed as both a Capture and Playback
+device. So ``arecord -L`` will also list it. That is generally OK for HFP/HSP
+devices, but an A2DP device most often offers only Capture (e.g. a mobile
+phone) or only Playback (e.g. a Bluetooth speaker). It is possible to use the
+hint description to limit the listing to only one direction using an
+undocumented syntax of ALSA configuration files.
 
-If the hint.description value ends with **|IOIDInput** the PCM will only show in listings of Capture devices; if it ends with **|IOIDOutput** the PCM will only show in listings of Playback devices.
+If the hint.description value ends with **|IOIDInput** the PCM will only show
+in listings of Capture devices; if it ends with **|IOIDOutput** the PCM will
+only show in listings of Playback devices.
 
 So we can modify our example above to:
 
@@ -212,22 +290,34 @@ or
       mybluealsadevice "bluealsa:DEV=00:11:22:33:44:55,PROFILE=a2dp|My Bluetooth headphones|IOIDOutput"
   }
 
-Now the ``aplay -L`` output will be exactly the same as before, but ``arecord -L`` will not include bt-headphones in its output.
+Now the ``aplay -L`` output will be exactly the same as before, but ``arecord
+-L`` will not include bt-headphones in its output.
 
-When using the **namehint.pcm** method, the key (**mybluealsadevice** in the above example) must be unique but otherwise is not used. The first part of the value string, before the pipe | symbol, is the string that is to be passed to ALSA applications to identify the PCM (e.g. with ``aplay -D ...``). The next section, after the pipe symbol, is the description that will be presented to the user. The optional **|IOID** section is not included in the description given to the application.
+When using the **namehint.pcm** method, the key (**mybluealsadevice** in the
+above example) must be unique but otherwise is not used. The first part of the
+value string, before the pipe | symbol, is the string that is to be passed to
+ALSA applications to identify the PCM (e.g. with ``aplay -D ...``). The next
+section, after the pipe symbol, is the description that will be presented to
+the user. The optional **|IOID** section is not included in the description
+given to the application.
 
 CTL PLUGIN
 ==========
 
-The BlueALSA ALSA CTL plugin can be used to define ALSA CTLs (mixer devices) in your own configuration file (e.g. ~/.asoundrc), or you can use the pre-defined configuration that is included in the bluez-alsa project.
+The BlueALSA ALSA CTL plugin can be used to define ALSA CTLs (mixer devices) in
+your own configuration file (e.g. ~/.asoundrc), or you can use the predefined
+configuration that is included in the bluez-alsa project.
 
-A BlueALSA CTL device has no associated soundcard, so ``alsamixer`` will not list it in its F6 menu. It can be selected either by starting ``alsamixer`` with
+A BlueALSA CTL device has no associated soundcard, so ``alsamixer`` will not
+list it in its F6 menu. It can be selected either by starting ``alsamixer``
+with
 
 ::
 
   alsamixer -D bluealsa
 
-or by selecting "enter device name .." on the F6 menu then typing out "bluealsa" in the "Device Name" box.
+or by selecting "enter device name .." on the F6 menu then typing out
+"bluealsa" in the "Device Name" box.
 
 
 The CTL has two operating modes, **Default** mode and **Single Device** mode.
@@ -235,55 +325,139 @@ The CTL has two operating modes, **Default** mode and **Single Device** mode.
 Default Mode
 ------------
 
-In this mode when a device connects, the mixer will create new controls for it, and when a device disconnects, the mixer will remove its controls. ``alsamixer(1)`` will show these changes dynamically.
-
-Control names are constructed by combining the device Bluetooth alias with either the profile type ('A2DP' or 'SCO') of the controlled PCM or the word "Battery" for battery level indicators. If two or more connected devices have the same alias then an index number is added to the name to make it unique.
-
-The Bluetooth "alias" of a device is by default the same as its "name". The name is a string defined by the device manufacturer and embedded in its firmware. Typically two identical devices will have identical names. The "alias" is created by BlueZ and stored locally on the host computer. So the alias can be changed using a tool such as ``bluetoothctl(1)`` to make it unique if desired. As manufacturers tend to use long names for their devices the alias can also be useful to give a short "nickname" to a device.
-
-Although this default mode works well with ``alsamixer``, there are some limitations that may make it unsuitable for some applications. In particular:
-
--    If device aliases are not unique then the index number associated with each is not easily predictable in advance; so it can be difficult to programmatically associate a PCM with its volume control.
-
--    A consequence of the alsa-lib implementation of controls is that when one Bluetooth device connects or disconnects it is necessary to remove all controls from all devices in the mixer and create a new set. This invalidates pointers held by applications and can cause application crashes. (Hardware sound cards do not have randomly appearing and disappearing controls, so many, or even most, applications are not programmed correctly to deal with it.)
+In this mode when a device connects, the mixer will create new controls for it,
+and when a device disconnects, the mixer will remove its controls.
+``alsamixer(1)`` will show these changes dynamically.
+
+Control names are constructed by combining the device Bluetooth alias with
+either the profile type ('A2DP' or 'SCO') of the controlled PCM or the word
+"Battery" for battery level indicators. If two or more connected devices have
+the same alias then an index number is added to the name to make it unique.
+
+The Bluetooth "alias" of a device is by default the same as its "name". The
+name is a string defined by the device manufacturer and embedded in its
+firmware. Typically two identical devices will have identical names. The
+"alias" is created by BlueZ and stored locally on the host computer. So the
+alias can be changed using a tool such as ``bluetoothctl(1)`` to make it unique
+if desired. As manufacturers tend to use long names for their devices the alias
+can also be useful to give a short "nickname" to a device.
+
+Although this default mode works well with ``alsamixer``, there are some
+limitations that may make it unsuitable for some applications. In particular:
+
+- If device aliases are not unique then the index number associated with
+  each is not easily predictable in advance; so it can be difficult to
+  programmatically associate a PCM with its volume control.
+
+- A consequence of the alsa-lib implementation of controls is that when one
+  Bluetooth device connects or disconnects it is necessary to remove all
+  controls from all devices in the mixer and create a new set. This invalidates
+  pointers held by applications and can cause application crashes. (Hardware
+  sound cards do not have randomly appearing and disappearing controls, so
+  many, or even most, applications are not programmed correctly to deal with
+  it.)
 
 Single Device Mode
 ------------------
 
-The BlueALSA CTL also implements an alternative mode that presents controls only for one specified device. In this case the control names are simply the profile type of the controlled PCM ('A2DP' or 'SCO') or the word "Battery". There is never any need for index suffixes or device alias. Immediately this overcomes the two main issues of the default mode.
+The BlueALSA CTL also implements an alternative mode that presents controls
+only for one specified device. In this case the control names are simply the
+profile type of the controlled PCM ('A2DP' or 'SCO') or the word "Battery".
+There is never any need for index suffixes or device alias. Immediately this
+overcomes the two main issues of the default mode.
 
-Single device mode is achieved by including the device Bluetooth address as an argument to the ALSA device id, for example:
+Single device mode is achieved by including the device Bluetooth address as an
+argument to the ALSA device id, for example:
 
 ::
 
   alsamixer -D bluealsa:00:11:22:33:44:55
 
-A notable difference between single-device mode and the default mode is in the cases of the device not being connected when the mixer is opened, and when the device disconnects while the mixer is open.
+A notable difference between single-device mode and the default mode is in the
+cases of the device not being connected when the mixer is opened, and when the
+device disconnects while the mixer is open.
 
-For the default mode, the mixer will still open, even if no devices are connected, but will display no controls. In single device mode the open request will fail with an error message.
+For the default mode, the mixer will still open, even if no devices are
+connected, but will display no controls. In single device mode the open request
+will fail with an error message.
 
-Similarly, in default mode when a device disconnects the mixer remains open but removes the set of controls and creates a new control set without the disconnected device. That new set will be empty if no devices remain. If the device then re-connects the mixer will again create a new set of controls with the newly connected device included.
+Similarly, in default mode when a device disconnects the mixer remains open but
+removes the set of controls and creates a new control set without the
+disconnected device. That new set will be empty if no devices remain. If the
+device then re-connects the mixer will again create a new set of controls with
+the newly connected device included.
 
-In single device mode when its device disconnects then the mixer will close. The ``alsamixer`` application will continue running with no associated device or controls, but will not automatically re-open the mixer if the device re-connects. The user can use F6 to open a new device.
+In single device mode when its device disconnects then the mixer will close.
+The ``alsamixer`` application will continue running with no associated device
+or controls, but will not automatically re-open the mixer if the device
+re-connects. The user can use F6 to open a new device.
 
-As a special case, a single device mixer can be opened with the address **00:00:00:00:00:00**. This will create a mixer with controls for the most recently connected device at the time the mixer is opened. Once created, that mixer behaves the same as if it had been opened with the actual address of the device: it does not change to a new device if another is subsequently connected.
+As a special case, a single device mixer can be opened with the address
+**00:00:00:00:00:00**. This will create a mixer with controls for the most
+recently connected device at the time the mixer is opened. Once created, that
+mixer behaves the same as if it had been opened with the actual address of the
+device: it does not change to a new device if another is subsequently
+connected.
 
 The Predefined **bluealsa** CTL
 -------------------------------
 
-The **bluealsa** CTL has parameters DEV, BAT, and SRV. All the parameters have defaults.
+The **bluealsa** CTL has parameters DEV, EXT, BAT, BTT, DYN, and SRV. All the
+parameters have defaults.
 
 CTL Parameters
 ~~~~~~~~~~~~~~
 
   DEV
-    The device Bluetooth address in the form XX:XX:XX:XX:XX:XX. Device names or aliases are not valid here. The default value is **FF:FF:FF:FF:FF:FF** which selects controls from all connected devices (see `Default Mode` above). Also accepts the special address **00:00:00:00:00:00** which selects the most recently connected device.
+    The device Bluetooth address in the form XX:XX:XX:XX:XX:XX. Device names or
+    aliases are not valid here. The default value is **FF:FF:FF:FF:FF:FF**
+    which selects controls from all connected devices (see `Default Mode`_
+    above). Also accepts the special address **00:00:00:00:00:00** which
+    selects the most recently connected device.
+
+  EXT
+    Causes the plugin to include controls for codec and software volume
+    selection. If the value is **yes** then these additional controls are
+    included. The default is **no**. The soft volume controls are called "Mode"
+    and take values "software" and "pass-through"; the playback control has
+    index 0 and capture control index 1. See ``bluealsa(8)`` for more on the
+    soft volume setting , and `Codec selection`_ in the **NOTES** section below
+    for more information on the Codec control.
 
   BAT
-    Causes the plugin to include a (read-only) battery level indicator, provided the device supports this. If the value is **yes** then the battery indicator is enabled, any other value disables it. The default is **yes**
+    Causes the plugin to include a (read-only) battery level indicator,
+    provided the device supports this. If the value is **yes** then the battery
+    indicator is enabled, any other value disables it. The default is **no**.
+
+  BTT
+    Appends Bluetooth transport type (e.g. "-SNK" or "-HFP-AG") to the control
+    element names. When using with the `Default Mode`_ this will reduce the
+    number of available characters for Bluetooth device name, so the default
+    value is **no**.
+
+    In some rare circumstances, when more than one A2DP or HFP/HSP profile is
+    connected with a single Bluetooth device, it might happen that the control
+    element names for such device will not be unique. This might be problematic
+    for control applications which use ALSA High Level Control Interface, e.g.
+    ``amixer`` or ``alsamixer``. Such applications will report error or simply
+    crash. This can be avoided by setting the BTT parameter to **yes**.
+
+  DYN
+    Enables "dynamic" operation. The plugin will add and remove controls as
+    profiles are connected or disconnected. This is the normal behavior, so
+    the default value is "**yes**". This argument is ignored in default mode;
+    in that mode operation is always dynamic. There are some applications that
+    are not programmed to handle dynamic addition or removal of controls, and
+    can fail when such events occur. Setting this argument to **no** in single
+    device mode with such applications can protect them from such failures.
+    When dynamic operation is disabled, the plugin never adds or removes any
+    controls. If a single profile is disconnected, then its associated volume
+    control is put into an inactive state, i.e.: read-only with its value and
+    playback/capture switch set to 0.
 
   SRV
-    The D-Bus service name of the BlueALSA daemon. Defaults to **org.bluealsa**. See ``bluealsa(8)`` for more information.
+    The D-Bus service name of the BlueALSA daemon. Defaults to
+    **org.bluealsa**. See ``bluealsa(8)`` for more information.
 
 The default values can be overridden in the ALSA configuration, for example:
 
@@ -291,47 +465,106 @@ The default values can be overridden in the ALSA configuration, for example:
 
   defaults.bluealsa.ctl.device "00:11:22:33:44:55"
   defaults.bluealsa.ctl.battery "no"
+  defaults.bluealsa.ctl.bttransport "no"
+  defaults.bluealsa.ctl.dynamic "yes"
+  defaults.bluealsa.ctl.extended "no"
 
 Defining BlueALSA CTLs
 ----------------------
 
-You can define your own ALSA CTL in the ALSA configuration. To do this, create an ALSA configuration node defining a CTL with type ``bluealsa``. The configuration node has the following fields:
+You can define your own ALSA CTL in the ALSA configuration. To do this, create
+an ALSA configuration node defining a CTL with type ``bluealsa``. The
+configuration node has the following fields:
 
 ::
 
   ctl.name {
-    type bluealsa # Bluetooth PCM
-    [device STR]  # Device address (default "FF:FF:FF:FF:FF:FF")
-    [battery STR] # Include battery level indicator (yes/no, default no)
-    [service STR] # D-Bus name of service (default "org.bluealsa")
+    type bluealsa     # Bluetooth PCM
+    [device STR]      # Device address (default "FF:FF:FF:FF:FF:FF")
+    [extended STR]    # Include additional controls (yes/no, default no)
+    [battery STR]     # Include battery level indicator (yes/no, default no)
+    [bttransport STR] # Append BT transport to element names (yes/no, default no)
+    [dynamic STR]     # Enable dynamic operation (yes/no, default yes)
+    [service STR]     # D-Bus name of service (default "org.bluealsa")
   }
 
-All the fields (except **type**) are optional. See the *CTL Parameters* section above for more information on each field. Note that the **battery** default value is **no** when used in this way. As for PCM definitions above, the default values for the optional fields are hard-coded into the plugin; they are not overridden by the configuration ``defaults.bluealsa.`` settings.
+All the fields (except **type**) are optional. See the `CTL Parameters`_
+section above for more information on each field. As for PCM definitions above,
+the default values for the optional fields are hard-coded into the plugin; they
+are not overridden by the configuration ``defaults.bluealsa.`` settings.
+
+NOTES
+=====
+
+Codec selection
+---------------
+
+When used on a HFP gateway node, there may be a brief delay with HFP PCMs
+after connection until the codec is selected. This delay is typically less
+than two seconds. During this time interval it is not possible to open the
+PCM plugin, it will fail with "Resource temporarily unavailable" (EAGAIN).
+
+Codec switching
+---------------
+
+Changing the codec used by a BlueALSA transport causes the PCM(s) running on
+that transport to terminate. Therefore using a Codec control can have
+undesirable consequences. Unfortunately the ``alsamixer(1)`` UI does not
+present a separate pick-list for enumerated types, so merely browsing the list
+of codecs using this control actually issues a Codec change request every time
+a different codec is displayed. This is not ideal, so the use of this control
+type with ``alsamixer(1)`` is not recommended. The control type does however
+work well with other mixer applications such as ``amixer(1)``.
+
+Note that BlueALSA does not support changing the HFP codec from a HFP-HF node,
+only the HFP-AG node can change the HFP codec.
+
+Transport acquisition
+---------------------
+
+The audio connection of a profile is not established immediately that a device
+connects. The A2DP source device, or HFP/HSP gateway device, must first
+"acquire" the profile transport.
+
+When the BlueALSA PCM plugin is used on a source A2DP or gateway HFP/HSP node,
+then **bluealsa(8)** will automatically acquire the transport and begin audio
+transfer when the plugin starts the PCM.
+
+When used on an A2DP sink or HFP/HSP HF/HS node then **bluealsa(8)** must wait
+for the remote device to acquire the transport. During this waiting time the
+PCM plugin behaves as if the device "clock" is stopped, it does not generate
+any poll() events, and the application will be blocked when writing or reading
+to/from the PCM. For applications playing audio from a file or recording audio
+to a file this is not normally an issue; but when streaming between some other
+device and a BlueALSA device this may lead to very large latency (delay) or
+trigger underruns or overruns in the other device.
 
 FILES
 =====
 
 /etc/alsa/conf.d/20-bluealsa.conf
     BlueALSA device configuration file.
-    ALSA additional configuration, defines the ``bluealsa`` PCM and CTL devices.
+    ALSA additional configuration, defines the ``bluealsa`` PCM and CTL
+    devices.
+
+COPYRIGHT
+=========
+
+Copyright (c) 2016-2023 Arkadiusz Bokowy.
+
+The bluez-alsa project is licensed under the terms of the MIT license.
 
 SEE ALSO
 ========
 
-``alsamixer(1)``, ``aplay(1)``, ``bluealsa(8)``, ``bluetoothctl(1)``, ``bluetoothd(8)``
+``alsamixer(1)``, ``amixer(1)``, ``aplay(1)``, ``bluetoothctl(1)``,
+``bluealsa(8)``, ``bluetoothd(8)``
 
 Project web site
-  https://github.com/Arkq/bluez-alsa
+  https://github.com/arkq/bluez-alsa
 
 ALSA configuration file syntax
   https://www.alsa-project.org/alsa-doc/alsa-lib/conf.html
 
 ALSA built-in PCM plugins reference
   https://www.alsa-project.org/alsa-doc/alsa-lib/pcm_plugins.html
-
-COPYRIGHT
-=========
-
-Copyright (c) 2016-2021 Arkadiusz Bokowy.
-
-The bluez-alsa project is licensed under the terms of the MIT license.
diff --git a/doc/bluealsa-rfcomm.1.rst b/doc/bluealsa-rfcomm.1.rst
index 3832e20..1ffdbc3 100644
--- a/doc/bluealsa-rfcomm.1.rst
+++ b/doc/bluealsa-rfcomm.1.rst
@@ -6,7 +6,7 @@ bluealsa-rfcomm
 a simple RFCOMM terminal for bluealsa
 -------------------------------------
 
-:Date: August 2020
+:Date: March 2023
 :Manual section: 1
 :Manual group: General Commands Manual
 :Version: $VERSION$
@@ -19,9 +19,10 @@ SYNOPSIS
 DESCRIPTION
 ===========
 
-**bluealsa-rfcomm** provides access to HSP/HFP RFCOMM terminal for connected Bluetooth device
-specified by the *DEVICE-PATH*. The *DEVICE-PATH* can be either a Bluetooth device D-Bus
-path defined by BlueZ (org.bluez) or BlueALSA (org.bluealsa) service.
+**bluealsa-rfcomm** provides access to HSP/HFP RFCOMM terminal for connected
+Bluetooth device specified by the *DEVICE-PATH*. The *DEVICE-PATH* can be
+either a Bluetooth device D-Bus path defined by BlueZ (org.bluez) or BlueALSA
+(org.bluealsa) service.
 
 OPTIONS
 =======
@@ -36,8 +37,11 @@ OPTIONS
     BlueALSA service name suffix. For more information see ``--dbus``
     option of ``bluealsa(8)`` service daemon.
 
-EXAMPLE
-=======
+-p, --properties
+    Print the properties of the given *DEVICE-PATH*
+
+EXAMPLES
+========
 
 ::
 
@@ -47,16 +51,17 @@ EXAMPLE
     > AT+CKPD=200
     disconnected
 
-SEE ALSO
-========
-
-``bluealsa(8)``, ``bluealsa-aplay(1)``
-
-Project web site at https://github.com/Arkq/bluez-alsa
-
 COPYRIGHT
 =========
 
-Copyright (c) 2016-2020 Arkadiusz Bokowy.
+Copyright (c) 2016-2023 Arkadiusz Bokowy.
 
 The bluez-alsa project is licensed under the terms of the MIT license.
+
+SEE ALSO
+========
+
+``bluealsa-aplay(1)`` ``bluealsa(8)``
+
+Project web site
+  https://github.com/arkq/bluez-alsa
diff --git a/doc/bluealsa.8.rst b/doc/bluealsa.8.rst
index f8d3d66..57fc89d 100644
--- a/doc/bluealsa.8.rst
+++ b/doc/bluealsa.8.rst
@@ -6,7 +6,7 @@ bluealsa
 Bluetooth Audio ALSA Backend
 ----------------------------
 
-:Date: March 2022
+:Date: January 2023
 :Manual section: 8
 :Manual group: System Manager's Manual
 :Version: $VERSION$
@@ -19,9 +19,10 @@ SYNOPSIS
 DESCRIPTION
 ===========
 
-**bluealsa** is a Linux daemon to give applications access to Bluetooth audio streams using the
-Bluetooth A2DP, HFP and/or HSP profiles.
-It provides a D-Bus API to applications, and can be used by ALSA applications via libasound plugins.
+**bluealsa** is a Linux daemon to give applications access to Bluetooth audio
+streams using the Bluetooth A2DP, HFP and/or HSP profiles.
+It provides a D-Bus API to applications, and can be used by ALSA applications
+via libasound plugins (see **bluealsa-plugins(7)** for details).
 
 OPTIONS
 =======
@@ -32,19 +33,20 @@ OPTIONS
 -V, --version
     Output the version number and exit.
 
--B NAME, --dbus=NAME
-    BlueALSA D-Bus service name suffix.
-    Without this option, **bluealsa** registers itself as an "org.bluealsa" D-Bus service.
-    For more information see the EXAMPLE_ below.
-
 -S, --syslog
     Send output to system logger (``syslogd(8)``).
     By default, log output is sent to stderr.
 
+-B NAME, --dbus=NAME
+    BlueALSA D-Bus service name suffix.
+    Without this option, **bluealsa** registers itself as an "org.bluealsa"
+    D-Bus service.  For more information see the EXAMPLES_ below.
+
 -i hciX, --device=hciX
-    HCI device to use. Can be specified multiple times to select more than one HCI.
-    Because HCI numbering can change after a system reboot, this option also accepts
-    HCI MAC address for the *hciX* value, e.g.: ``--device=00:11:22:33:44:55``
+    HCI device to use. Can be specified multiple times to select more than one
+    HCI.  Because HCI numbering can change after a system reboot, this option
+    also accepts HCI MAC address for the *hciX* value, for example:
+    ``--device=00:11:22:33:44:55``
 
     Without this option, the default is to use all available HCI devices.
 
@@ -53,101 +55,121 @@ OPTIONS
     May be given multiple number of times to enable multiple profiles.
 
     It is mandatory to enable at least one Bluetooth profile.
-    For the list of supported profiles see the PROFILES_ section below.
+    For the list of supported profiles see Profiles_ in the **NOTES** section
+    below.
 
 -c NAME, --codec=NAME
     Enable or disable *NAME* Bluetooth audio codec.
-    May be given multiple number of times to enable (or disable) multiple codecs.
+    May be given multiple number of times to enable (or disable) multiple
+    codecs.
 
-    In order to disable given audio codec (remove it from the list of audio codecs
-    enabled by default), the *NAME* has to be prefixed with **-** (minus) character.
-    It is not possible to disable SBC and CVSD codecs which are mandatory for A2DP
-    and HFP/HSP respectively.
+    In order to disable given audio codec (remove it from the list of audio
+    codecs enabled by default), the *NAME* has to be prefixed with **-**
+    (minus) character.  It is not possible to disable SBC and CVSD codecs which
+    are mandatory for A2DP and HFP/HSP respectively.
 
-    By default BlueALSA enables SBC, AAC (if AAC support is compiled-in), CVSD and
-    mSBC.
+    By default BlueALSA enables SBC, AAC (if AAC support is compiled-in), CVSD
+    and mSBC (if mSBC support is compiled-in).
     For the list of supported audio codecs see the "Available BT audio codecs"
     section of the **bluealsa** command-line help message.
 
 --initial-volume=NUM
-    Set the initial volume to *NUM* % when the device is connected.
+    Set the initial volume to *NUM* % when a device is first connected.
     *NUM* must be an integer in the range from **0** to **100**.
-    The default value is **100** (full volume).
 
-    Having headphones volume reset to max whenever they connect can lead to
-    an unpleasant experience. This option allows the user to choose an
-    alternative initial volume level. Only one value can be specified and
-    each device on connect will have the volume level of all its PCMs set
-    to this value (%). However, a device with native volume control may
-    then immediately override this level.
+    By default the volume of all PCMs of a device is set to 100% (full volume)
+    when the device is first connected. For some devices, particularly
+    headphones, this can lead to an unpleasant experience. This option allows
+    the user to choose an alternative initial volume level. Only one value can
+    be specified and each device on first connect will have the volume level of
+    all its PCMs set to this value. However, a device with native volume
+    control may then immediately override this level. On subsequent connects
+    the volume will be set to the remembered value from the last disconnection.
+    See `Volume control`_ in the **NOTES** section below for more information.
 
 --keep-alive=SEC
-    Keep Bluetooth transport alive for *SEC* number of seconds after streaming was closed.
+    Keep Bluetooth transport alive for *SEC* number of seconds after streaming
+    was closed.
 
-    This option is required when using ``bluealsa`` with applications that close
-    and then immediately re-open the same PCM as part of their initialization;
-    for example applications built with the ``portaudio`` portability library
-    and many other "portable" applications.
+    This option is required when using ``bluealsa`` with applications that
+    close and then immediately re-open the same PCM as part of their
+    initialization; for example applications built with the ``portaudio``
+    portability library and many other "portable" applications.
 
     It can also be useful when playing short audio files in quick succession.
-    It will reduce the gap between playbacks caused by Bluetooth audio transport acquisition.
+    It will reduce the gap between playbacks caused by Bluetooth audio
+    transport acquisition.
 
 --a2dp-force-mono
     Force monophonic sound for A2DP profile.
 
 --a2dp-force-audio-cd
     Force 44.1 kHz sampling frequency for A2DP profile.
-    Some Bluetooth devices can handle streams sampled at either 48kHz or 44.1kHz, in which case
-    they normally default to using 48kHz.
-    With this option, **bluealsa** will request such a device uses only 44.1 kHz sample rate.
+    Some Bluetooth devices can handle streams sampled at either 48kHz or
+    44.1kHz, in which case they normally default to using 48kHz.
+    With this option, **bluealsa** will request such a device uses only 44.1
+    kHz sample rate.
 
 --a2dp-volume
     Enable native A2DP volume control.
-    By default **bluealsa** will use its own internal scaling algorithm to attenuate the volume.
-    This option disables that internal scaling and instead passes the volume change request to the
-    A2DP device.
-    This feature can also be controlled during runtime via BlueALSA D-Bus API.
+    By default **bluealsa** will use its own internal scaling algorithm to
+    attenuate the volume.  This option disables that internal scaling and
+    instead passes the volume change request to the A2DP device.
+    This feature can also be controlled during runtime for individual PCMs via
+    the BlueALSA D-Bus API or by the BlueALSA ALSA plugins; and if so the
+    changed setting will be remembered. See `Volume control`_ in the **NOTES**
+    section below for more information.
     Note that this feature might not work with all Bluetooth headsets.
 
 --sbc-quality=MODE
-    Set SBC encoder quality, where *MODE* can be one of:
+    Set SBC encoder quality.
+    Default value is **high**.
+
+    The *MODE* can be one of:
 
     - **low** - low audio quality (mono: 114 kbps, stereo: 213 kbps)
     - **medium** - medium audio quality (mono: 132 kbps, stereo: 237 kbps)
-    - **high** - high audio quality (mono: 198 kbps, stereo: 345 kbps) (**default**)
+    - **high** - high audio quality (mono: 198 kbps, stereo: 345 kbps)
     - **xq** - SBC Dual Channel HD (SBC XQ) (452 kbps)
     - **xq+** - SBC Dual Channel HD (SBC XQ+) (551 kbps)
 
 --mp3-algorithm=TYPE
-    Select LAME encoder internal algorithm, where *TYPE* can be one of:
+    Select LAME encoder internal algorithm.
+    Default value is **expensive**.
+
+    The *TYPE* can be one of:
 
     - **fast** - OK quality, really fast
     - **cheap** - good quality, fast
-    - **expensive** - near-best quality, not too slow (**default**)
+    - **expensive** - near-best quality, not too slow
     - **best** - best quality, slow
 
-    If CPU power consumption is not an issue, one might safely select **best** as the algorithm
-    type.
-    Also, please note that the true quality is determined by the selected bit rate or used VBR
-    quality option (**--mp3-vbr-quality**).
+    If CPU power consumption is not an issue, one might safely select **best**
+    as the algorithm type.
+    Also, please note that the true quality is determined by the selected bit
+    rate or used VBR quality option (**--mp3-vbr-quality**).
 
 --mp3-vbr-quality=MODE
-    Set variable bit rate (VBR) quality, where *MODE* can be one of:
+    Set variable bit rate (VBR) quality.
+    Default value is **standard**.
+
+    The *MODE* can be one of:
 
     - **low** - low audio quality (100-130 kbps)
     - **medium** - medium audio quality (140-185 kbps)
-    - **standard** - standard audio quality (170-210 kbps) (**default**)
+    - **standard** - standard audio quality (170-210 kbps)
     - **high** - high audio quality (190-250 kbps)
     - **extreme** - best audio quality, no low-pass filter (220-260 kbps)
 
 --aac-afterburner
-    Enables Fraunhofer AAC afterburner feature, which is a type of analysis by synthesis algorithm.
-    This feature increases the audio quality at the cost of increased processing power and overall
-    memory consumption.
+    Enables Fraunhofer AAC afterburner feature, which is a type of analysis by
+    synthesis algorithm.
+    This feature increases the audio quality at the cost of increased
+    processing power and overall memory consumption.
 
 --aac-bitrate=BPS
-    Set the target bit rate for constant bit rate (CBR) mode or the maximum peak bit rate for
-    variable bit rate (VBR) mode.
+    Set the target bit rate for constant bit rate (CBR) mode or the maximum
+    peak bit rate for variable bit rate (VBR) mode.
     Default value is **220000** bits per second.
 
 --aac-latm-version=NUM
@@ -156,30 +178,33 @@ OPTIONS
 
     The *NUM* can be one of:
 
-    - **0** - LATM syntax specified by ISO-IEC 14496-3 (2001), should work with all older BT devices
-    - **1** - LATM syntax specified by ISO-IEC 14496-3 (2005), should work with newer BT devices
+    - **0** - LATM syntax specified by ISO-IEC 14496-3 (2001), should work with
+      all older BT devices
+    - **1** - LATM syntax specified by ISO-IEC 14496-3 (2005), should work with
+      newer BT devices
 
 --aac-true-bps
     Enable true "bit per second" bit rate.
 
-    A2DP AAC specification requires that for the constant bit rate (CBR) mode every RTP frame has
-    the same bit rate and for the variable bit rate (VBR) mode the maximum peak bit rate limit is
-    also per RTP frame.
+    A2DP AAC specification requires that for the constant bit rate (CBR) mode
+    every RTP frame has the same bit rate and for the variable bit rate (VBR)
+    mode the maximum peak bit rate limit is also per RTP frame.
     However, a single RTP frame does not contain a single full second of audio.
-    This option enables true bit rate calculation (per second), which means that per RTP frame bit
-    rate may vary even for CBR mode.
-    This feature is not enabled by default, because it violates A2DP AAC specification.
-    Enabling it should result in an enhanced audio quality, but will for sure produce fragmented
-    RTP frames.
-    If RTP fragmentation is not supported by used A2DP sink device (e.g. headphones) one might
-    hear clearly audible clicks in the playback audio.
+    This option enables true bit rate calculation (per second), which means
+    that per RTP frame bit rate may vary even for CBR mode.
+    This feature is not enabled by default, because it violates A2DP AAC
+    specification.
+    Enabling it should result in an enhanced audio quality, but will for sure
+    produce fragmented RTP frames.
+    If RTP fragmentation is not supported by used A2DP sink device (e.g.,
+    headphones) one might hear clearly audible clicks in the playback audio.
     In such case, please do not enable this option.
 
 --aac-vbr
     Prefer variable bit rate mode over constant bit rate mode.
 
-    Please note, that this option does not necessarily mean that the variable bit rate (VBR) mode
-    will be used.
+    Please note, that this option does not necessarily mean that the variable
+    bit rate (VBR) mode will be used.
     Used AAC configuration depends on a remote Bluetooth device capabilities.
 
 --lc3plus-bitrate=BPS
@@ -187,34 +212,77 @@ OPTIONS
     Default value is **396800** bits per second.
 
 --ldac-abr
-    Enables LDAC adaptive bit rate, which will dynamically adjust encoder quality
-    based on the connection stability.
+    Enables LDAC adaptive bit rate, which will dynamically adjust encoder
+    quality based on the connection stability.
 
 --ldac-quality=MODE
-    Specifies LDAC encoder quality, where *MODE* can be one of:
+    Specifies LDAC encoder quality.
+    Default value is **standard**.
+
+    The *MODE* can be one of:
 
     - **mobile** - mobile quality (44.1 kHz: 303 kbps, 48 kHz: 330 kbps)
-    - **standard** - standard quality (44.1 kHz: 606 kbps, 48 kHz: 660 kbps) (**default**)
+    - **standard** - standard quality (44.1 kHz: 606 kbps, 48 kHz: 660 kbps)
     - **high** - high quality (44.1 kHz: 909 kbps, 48 kHz: 990 kbps)
 
 --xapl-resp-name=NAME
     Set the product name send in the XAPL response message.
     By default, the name is set as "BlueALSA".
-    However, some devices (reported with e.g.: Sony WM-1000XM4) will not provide
-    battery level notification unless the product name is set as "iPhone".
+    However, some devices (reported with e.g., Sony WM-1000XM4) will not
+    provide battery level notification unless the product name is set as
+    "iPhone".
 
-PROFILES
-========
+NOTES
+=====
+
+Profiles
+--------
+
+**bluealsa** provides support for Bluetooth Advanced Audio Distribution Profile
+(A2DP), Hands-Free Profile (HFP) and Headset Profile (HSP).
+A2DP profile is dedicated for streaming music (i.e., stereo, 48 kHz or more
+sampling frequency), while HFP and HSP for two-way voice transmission (mono, 8
+kHz or 16 kHz sampling frequency).
+
+The Bluetooth audio profiles are not peer-to-peer; they each have a source or
+gateway role (a2dp-source, hfp-ag, or hsp-ag) and a sink or target role
+(a2dp-sink, hfp-hf, hsp-hs). The source/gateway role is the audio player (e.g.,
+mobile phone), the sink/target role is the audio renderer (e.g., headphones or
+speaker). The **bluealsa** daemon can perform any combination of profiles and
+roles, although it is most common to use it either as a source/gateway:
+
+::
+
+    bluealsa -p a2dp-source -p hfp-ag -p hsp-ag
+
+or as a sink/target:
+
+::
+
+    bluealsa -p a2dp-sink -p hfp-hf -p hsp-hs
+
+or with oFono for HFP support,
+
+source/gateway:
 
-BlueALSA provides support for Bluetooth Advanced Audio Distribution Profile (A2DP),
-Hands-Free Profile (HFP) and Headset Profile (HSP).
-A2DP profile is dedicated for streaming music (i.e. stereo, 48 kHz or more sampling
-frequency), while HFP and HSP for two-way voice transmission (mono, 8 kHz or 16 kHz
-sampling frequency).
-With A2DP, BlueALSA includes mandatory SBC codec and various optional codecs like
-AAC, aptX, and other.
-The full list of available optional codecs, which depends on selected compilation
-options, will be shown with **bluealsa** command-line help message.
+::
+
+    bluealsa -p a2dp-source -p hfp-ofono -p hsp-ag
+
+sink/target:
+
+::
+
+    bluealsa -p a2dp-sink -p hfp-ofono -p hsp-hs
+
+With A2DP, **bluealsa** always includes the mandatory SBC codec and may also
+include various optional codecs like AAC, aptX, and other.
+
+With HFP, **bluealsa** always includes the mandatory CVSD codec and may also
+include the optional mSBC codec.
+
+The full list of available optional codecs, which depends on selected
+compilation options, will be shown with **bluealsa** command-line help message.
 
 The list of profile *NAME*-s accepted by the ``--profile=NAME`` option:
 
@@ -226,8 +294,93 @@ The list of profile *NAME*-s accepted by the ``--profile=NAME`` option:
 - **hsp-ag** Headset Audio Gateway
 - **hsp-hs** - Headset
 
-The **hfp-ofono** is available only when **bluealsa** was compiled with oFono support.
-Enabling HFP over oFono will automatically disable **hfp-hf** and **hfp-ag**.
+The **hfp-ofono** is available only when **bluealsa** was compiled with oFono
+support. Enabling HFP over oFono will automatically disable **hfp-hf** and
+**hfp-ag**.
+
+BlueZ permits only one service to register the HSP and HFP profiles, and that
+service is automatically registered with every HCI device.
+
+For the A2DP profile, BlueZ allows each HCI device to be registered to a
+different service, so it is possible to have multiple instances of
+**bluealsa** offering A2DP support, each with a unique service name given with
+the ``--dbus=`` option, so long as they are registered to different HCI devices
+using the ``--device=`` option. See the EXAMPLES_ below.
+
+A profile connection does not immediately initiate the audio stream(s); audio
+can only flow when the profile transport is "acquired". Acquisition can only be
+performed by the source/gateway role. When acting as source/gateway,
+**bluealsa** acquires the profile transport (i.e., initiates the audio
+connection) when a client opens a PCM. When **bluealsa** is acting as target,
+a client can open a PCM as soon as the profile is connected, but the audio
+stream(s) will not begin until the remote source/gateway has acquired the
+transport.
+
+Volume control
+--------------
+
+The Bluetooth specifications for HFP and HSP include optional support
+for volume control of the target by the gateway device. For A2DP, volume
+control is optionally provided by the AVRCP profile. **bluealsa** provides a
+single, consistent, abstracted interface for volume control of PCMs. This
+interface can use the native Bluetooth features or alternatively **bluealsa**
+also implements its own internal volume control, called "soft-volume". For A2DP
+the default is to use soft-volume, but this can be overridden to use the
+Bluetooth native support where available by using the ``--a2dp-volume`` command
+line option. For HFP/HSP the default is to use Bluetooth native volume control.
+
+When using soft-volume, **bluealsa** scales PCM samples before encoding, and
+after decoding, and does not interact with the Bluetooth AVRCP volume property
+or HFP/HSP volume control. Volume can only be modified by local clients. (Note
+that Bluetooth headphones or speakers with their own volume controls will still
+be able to alter their own volume, but this change will not be notified to
+**bluealsa** local clients, they will only see the soft-volume setting).
+
+When using native volume control, **bluealsa** links the PCM volume setting to
+the AVRCP volume property or HFP/HSP volume control. No scaling of PCM samples
+is applied. Volume can be modified by both local clients and the remote device.
+Local clients will be notified of volume changes made by controls on the
+remote device.
+
+A2DP native volume control does not permit independent values for left and
+right channels, so when a client sets such values **bluealsa** will set the
+Bluetooth volume as the average of the two channels.
+
+Volume level, mute status, and soft-volume selection can all be controlled for
+each PCM by using the D-Bus API (or by using ALSA plugins, see
+**bluealsa-plugins(7)** for more information). The current value of these
+settings for each PCM is stored in the filesystem so that the device can be
+disconnected and later re-connected without losing its volume settings.
+
+When a device is connected, the volume level of its PCMs is set according to
+the following criteria (highest priority first):
+
+    #. saved value from previous connection of the device
+    #. value set by the ``--initial-volume`` command line option
+    #. **100%**
+
+its mute status according to:
+
+    #. saved value from previous connection
+    #. **false**
+
+and its soft-volume status according to:
+
+    #. saved value from previous connection
+    #. **false** for SCO (i.e., use native volume control).
+    #. **false** for A2DP if the ``--a2dp-volume`` command line option is given
+    #. **true** for A2DP (i.e., use soft-volume control).
+
+When native volume control is enabled, then the remote device may also
+modify the volume level after this initial setting. Mute and soft-volume are
+implemented locally by the **bluealsa** daemon and cannot be modified by the
+remote device.
+
+Note that **bluealsa** relies on support from BlueZ to implement native volume
+control for A2DP using AVRCP, and BlueZ has not always provided robust support
+here. It is recommended to use BlueZ release 5.65 or later to be certain that
+native A2DP volume control will always be available with those devices which
+provide it.
 
 FILES
 =====
@@ -239,8 +392,12 @@ FILES
     only *root* to own this service, and only members of the *audio* group to
     exchange messages with it.
 
-EXAMPLE
-=======
+/var/lib/bluealsa/*XX:XX:XX:XX:XX:XX*
+    BlueALSA volume persistent state storage. Files are named after the
+    Bluetooth device address to which they refer.
+
+EXAMPLES
+========
 
 Emulate Bluetooth headset with A2DP and HSP support:
 
@@ -248,11 +405,11 @@ Emulate Bluetooth headset with A2DP and HSP support:
 
     bluealsa -p a2dp-sink -p hsp-hs
 
-On systems with more than one HCI device, it is possible to expose different profiles
-on different HCI devices.
-A system with three HCI devices might (for example) use *hci0* for an A2DP sink service
-named "org.bluealsa.sink" and both *hci1* and *hci2* for an A2DP source service named
-"org.bluealsa.source".
+On systems with more than one HCI device, it is possible to expose different
+profiles on different HCI devices.
+A system with three HCI devices might (for example) use *hci0* for an A2DP sink
+service named "org.bluealsa.sink" and both *hci1* and *hci2* for an A2DP source
+service named "org.bluealsa.source".
 Such a setup might be created as follows:
 
 ::
@@ -260,8 +417,8 @@ Such a setup might be created as follows:
     bluealsa -B sink -i hci0 -p a2dp-sink &
     bluealsa -B source -i hci1 -i hci2 -p a2dp-source &
 
-Setup like this will also require a change to the BlueALSA D-Bus configuration file in
-order to allow connection with BlueALSA services with suffixed names.
+Setup like this will also require a change to the BlueALSA D-Bus configuration
+file in order to allow connection with BlueALSA services with suffixed names.
 Please add following lines to the BlueALSA D-Bus policy:
 
 ::
@@ -271,18 +428,18 @@ Please add following lines to the BlueALSA D-Bus policy:
     <allow send_destination="org.bluealsa.source" />
     ...
 
-SEE ALSO
-========
-
-``bluetoothctl(1)``, ``bluetoothd(8)``, ``bluealsa-aplay(1)``, ``bluealsa-cli(1)``,
-``bluealsa-plugins(7)``, ``bluealsa-rfcomm(1)``
-
-Project web site
-  https://github.com/Arkq/bluez-alsa
-
 COPYRIGHT
 =========
 
-Copyright (c) 2016-2021 Arkadiusz Bokowy.
+Copyright (c) 2016-2023 Arkadiusz Bokowy.
 
 The bluez-alsa project is licensed under the terms of the MIT license.
+
+SEE ALSO
+========
+
+``bluealsa-aplay(1)``, ``bluealsa-cli(1)``, ``bluealsa-rfcomm(1)``,
+``bluetoothctl(1)``, ``bluealsa-plugins(7)``, ``bluetoothd(8)``
+
+Project web site
+  https://github.com/arkq/bluez-alsa
diff --git a/doc/hcitop.1.rst b/doc/hcitop.1.rst
index b4da0ef..272b640 100644
--- a/doc/hcitop.1.rst
+++ b/doc/hcitop.1.rst
@@ -6,7 +6,7 @@ hcitop
 a simple dynamic view of HCI activity
 -------------------------------------
 
-:Date: February 2021
+:Date: June 2022
 :Manual section: 1
 :Manual group: General Commands Manual
 :Version: $VERSION$
@@ -61,8 +61,8 @@ FLAGS
 =====
 
 An array of flag characters indicating the current status of the HCI. The flags
-are shown in the following order. The indicated letter appears when that flag is
-"TRUE", a blank is shown when the flag is "FALSE".
+are shown in the following order. The indicated letter appears when that flag
+is "TRUE", a blank is shown when the flag is "FALSE".
 
 U
     The interface is "Up".
@@ -91,18 +91,17 @@ Q
 X
     Raw mode is enabled.
 
+COPYRIGHT
+=========
+
+Copyright (c) 2016-2022 Arkadiusz Bokowy.
+
+The bluez-alsa project is licensed under the terms of the MIT license.
 
 SEE ALSO
 ========
 
 ``btmon(1)``, ``hciconfig(1)``, ``hcitool(1)``
 
-**hcitop** is part of the **bluez-alsa** project.
-Project web site at https://github.com/Arkq/bluez-alsa
-
-COPYRIGHT
-=========
-
-Copyright (c) 2016-2021 Arkadiusz Bokowy.
-
-The bluez-alsa project is licensed under the terms of the MIT license.
+Project web site
+  https://github.com/arkq/bluez-alsa
diff --git a/misc/bash-completion/bluealsa b/misc/bash-completion/bluealsa
index 30bea9f..8e49ee6 100644
--- a/misc/bash-completion/bluealsa
+++ b/misc/bash-completion/bluealsa
@@ -1,5 +1,5 @@
+#!/bin/bash
 # bash completion for bluez-alsa project applications
-# vim: ft=sh
 
 # helper function gets available profiles
 # @param $1 the bluealsa executable name
@@ -8,8 +8,8 @@ _bluealsa_profiles() {
 		[[ "$line" = "Available BT profiles:" ]] && start=yes && continue
 		[[ "$start" ]] || continue
 		[[ "$line" ]] || break
-		words=($line)
-		printf "%s" "${words[1]} "
+		read -ra words <<< "$line"
+		echo "${words[1]}"
 	done
 }
 
@@ -21,8 +21,8 @@ _bluealsa_codecs() {
 		[[ "$start" ]] || continue
 		[[ "$line" ]] || break
 		line=${line,,}
-		words=(${line//,/})
-		echo ${words[@]:1}
+		IFS=", " read -ra words <<< "${line,,}"
+		echo "${words[@]:1}"
 	done
 }
 
@@ -31,7 +31,8 @@ _bluealsa_codecs() {
 # @param $2 the bluealsa option to inspect
 _bluealsa_enum_values() {
 	"$1" "${2}=" 2>&1 | while read -r line; do
-		[[ $line =~ \{([^}]*)\} ]] && printf "${BASH_REMATCH[1]//,/}"
+		[[ $line =~ \{([^}]*)\} ]] || continue
+		echo "${BASH_REMATCH[1]//,/}"
 	done
 }
 
@@ -43,55 +44,49 @@ _bluealsa_list_dbus_suffices() {
 	          org.freedesktop.DBus.ListNames 2>/dev/null | \
 		while read -r line; do
 			[[ $line =~ org\.bluealsa\.([^'"']+) ]] || continue
-			printf "%s" "${BASH_REMATCH[1]} "
+			echo "${BASH_REMATCH[1]}"
 		done
 }
 
 # helper function gets codecs for given pcm
-# @param $1 the executable name
-# @param $2 dbus option ( --dbus=aaa )
-# @param $3 pcm path
+# before calling this function, make sure that the bautil_args was properly
+# initialized with the _bluealsa_util_init function
+# @param $1 pcm path
 _bluealsa_pcm_codecs() {
-	"$1" $2 codec "$3" | while read -r line; do
-		[[ $line =~ ^Available\ codecs:\ ([^[]+$) ]] && printf "${BASH_REMATCH[1]}"
-	done
-}
-
-# helper function gets options requiring a value ( --opt=val )
-# @param $1 the executable name
-_bluealsa_valopts() {
-	"$1" --help 2>/dev/null | while read -r line; do
-		[[ "$line" =~ --[^=]*= ]] || continue
-		line=${line/,/}
-		[[ "$line" =~ .*[^=]+= ]]
-		printf "%s" "${BASH_REMATCH[0]//=/}"
-	done
-}
-
-# helper function gets bluealsa-cli commands that do not take a pcm-path arg
-# @param $1 the bluealsa-cli executable name
-_bluealsa_cli_simple_commands() {
-	"$1" --help 2>/dev/null | while read -r line; do
-		[[ "$line" = "Commands:" ]] && start=yes && continue
-		[[ "$start" ]] || continue
-		[[ "$line" ]] || break
-		[[ $line =~ \<pcm-path\> ]] && continue
-		words=($line)
-		printf "%s" "${words[0]} "
+	"${bautil_args[@]}" codec "$1" | while read -r line; do
+		[[ $line =~ ^Available\ codecs:\ ([^[]+$) ]] || continue
+		echo "${BASH_REMATCH[1]}"
 	done
 }
 
-# helper function gets bluealsa-cli commands that do take a pcm-path arg
-# @param $1 is the bluealsa-cli executable name
-_bluealsa_cli_path_commands() {
-	"$1" --help 2>/dev/null | while read -r line; do
-		[[ "$line" = "Commands:" ]] && start=yes && continue
-		[[ "$start" ]] || continue
-		[[ "$line" ]] || break
-		[[ $line =~ \<pcm-path\> ]] || continue
-		words=($line)
-		printf "${words[0]} "
-	done
+# helper function completes supported bluealsa-cli monitor properties
+_bluealsa_cli_properties() {
+	local properties=( codec running softvolume volume )
+	if [[ "$cur" == *,* ]]; then
+		local realcur prefix chosen remaining
+		realcur="${cur##*,}"
+		prefix="${cur%,*}"
+		IFS="," read -ra chosen <<< "${prefix,,}"
+		readarray -t remaining < <(printf '%s\n' "${properties[@]}" "${chosen[@]}" | sort | uniq -u)
+		if [[ ${#remaining[@]} -gt 0 ]]; then
+			readarray -t COMPREPLY < <(compgen -W "${remaining[*]}" -- "$realcur")
+			if [[ ${#COMPREPLY[@]} -eq 1 ]] ; then
+				COMPREPLY[0]="$prefix,${COMPREPLY[0]}"
+			fi
+			if [[ ${#remaining[@]} -gt 0 && "$cur" == "${COMPREPLY[0]}" ]] ; then
+				COMPREPLY=( "${COMPREPLY[0]}," )
+			fi
+			if [[ ${#remaining[@]} -gt 1 ]]; then
+				compopt -o nospace
+			fi
+		fi
+	else
+		readarray -t COMPREPLY < <(compgen -W "${properties[*]}" -- "$cur")
+		if [[ ${#COMPREPLY[@]} -eq 1 && "$cur" == "${COMPREPLY[0]}" ]]; then
+			COMPREPLY=("${COMPREPLY[0]},")
+		fi
+		compopt -o nospace
+	fi
 }
 
 # helper function gets ALSA pcms
@@ -102,25 +97,28 @@ _bluealsa_aplay_pcms() {
 	while read -r; do
 		[[ "$REPLY" == " "* ]] && continue
 		[[ "$REPLY" == "$cur"* ]] || continue
-		printf "%s\n" "${REPLY// /\\ }"
-	done <<< $(aplay -L 2>/dev/null)
+		echo "${REPLY// /\\ }"
+	done < <(aplay -L 2>/dev/null)
 }
 
 # helper function gets rfcomm dbus paths
 # @param $1 the full bluealsa service name ( org.bluealsa* )
 _bluealsa_rfcomm_paths() {
 	busctl --list tree "$1" 2>/dev/null | while read -r line; do
-		[[ "$line" = /org/bluealsa/hci[0-9]/dev*/rfcomm ]] && printf "%s" "${line/\/rfcomm/}"
+		[[ "$line" = /org/bluealsa/hci[0-9]/dev*/rfcomm ]] || continue
+		echo "${line/\/rfcomm/}"
 	done
 }
 
-# helper function - Loop through words of command line.
-# puts dbus arg ( --dbus=aaa ) into variable dbus_opt
+# helper function gets options from command line
+# @param $1 the executable name
+# puts exec and dbus arg ( --dbus=aaa ) into array variable bautil_args
 # puts service name ( org.bluealsa.aaa ) into variable service
 # puts offset of first non-option argument into variable nonopt_offset
 # @return 0 if no errors found, 1 if dbus check failed
 _bluealsa_util_init() {
-	local valopts=$(_bluealsa_valopts $1)
+	local valopts="-B --dbus"
+	bautil_args=( "$1" )
 	service="org.bluealsa"
 	nonopt_offset=0
 	local i
@@ -131,14 +129,14 @@ _bluealsa_util_init() {
 					break
 				elif (( i == COMP_CWORD - 1 )) ; then
 					[[ "${COMP_WORDS[i+1]}" = = ]] && break
-					dbus_opt="--dbus=${COMP_WORDS[i+1]}"
+					bautil_args+=( "--dbus=${COMP_WORDS[i+1]}" )
 					service="org.bluealsa.${COMP_WORDS[i+1]}"
 					break
 				else
 					[[ "${COMP_WORDS[i+1]}" = = ]] && (( i++ ))
 					if [[ "${COMP_WORDS[i+1]}" ]] ; then
 						(( i++ ))
-						dbus_opt="--dbus=${COMP_WORDS[i]}"
+						bautil_args+=( "--dbus=${COMP_WORDS[i]}" )
 						service="org.bluealsa.${COMP_WORDS[i]}"
 						continue
 					fi
@@ -148,13 +146,13 @@ _bluealsa_util_init() {
 				if (( i == COMP_CWORD )) ; then
 					break
 				elif (( i == COMP_CWORD - 1 )) ; then
-					dbus_opt="--dbus=${COMP_WORDS[i+1]}"
+					bautil_args+=( "--dbus=${COMP_WORDS[i+1]}" )
 					service="org.bluealsa.${COMP_WORDS[i+1]}"
 					break
 				else
 					if [[ "${COMP_WORDS[i+1]}" ]] ; then
 						(( i++ ))
-						dbus_opt="--dbus=${COMP_WORDS[i]}"
+						bautil_args+=( "--dbus=${COMP_WORDS[i]}" )
 						service="org.bluealsa.${COMP_WORDS[i]}"
 						continue
 					fi
@@ -178,43 +176,44 @@ _bluealsa_util_init() {
 
 # helper function completes options
 _bluealsa_complete_options() {
-	COMPREPLY=( $(compgen -W "$(_parse_help $1)" -- $cur) )
-	[[ $COMPREPLY == *= ]] && compopt -o nospace
+	readarray -t COMPREPLY < <(compgen -W "$(_parse_help "$1")" -- "$cur")
+	[[ ${COMPREPLY[0]} == *= ]] && compopt -o nospace
 }
 
 # completion function for bluealsa
 # complete available devices and profiles in addition to options
 _bluealsa() {
-	local cur prev words cword split list
-	local prefix
 
+	local cur prev words cword split
 	_init_completion -s || return
 
+	local prefix list
+
 	case "$prev" in
 		--device|-i)
-			COMPREPLY=( $(compgen -W "$(ls -I *:* /sys/class/bluetooth)" -- $cur) )
+			readarray -t list < <(ls -I '*:*' /sys/class/bluetooth)
+			readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 			return
 			;;
 		--profile|-p)
 			[[ $cur =~ ^[+-].* ]] && prefix=${cur:0:1} && cur=${cur:1}
-			COMPREPLY=( $(compgen -P "$prefix" -W "$(_bluealsa_profiles $1)" -- $cur) )
+			readarray -t list < <(_bluealsa_profiles "$1")
+			readarray -t COMPREPLY < <(compgen -P "$prefix" -W "${list[*]}" -- "$cur")
 			return
 			;;
 		--codec|-c)
 			[[ $cur =~ ^[+-].* ]] && prefix=${cur:0:1} && cur=${cur:1}
-			COMPREPLY=( $(compgen -P "$prefix" -W "$(_bluealsa_codecs $1)" -- $cur) )
+			readarray -t list < <(_bluealsa_codecs "$1")
+			readarray -t COMPREPLY < <(compgen -P "$prefix" -W "${list[*]}" -- "$cur")
 			return
 			;;
-		--sbc-quality|--mp3-algorithm|--mp3-vbr-quality|--ldac-quality)
-			COMPREPLY=( $(compgen -W "$(_bluealsa_enum_values $1 $prev)" -- $cur) )
-			return
-			;;
-		--aac-latm-version)
-			COMPREPLY=( $(compgen -W "0 1" -- $cur) )
+		--sbc-quality|--aac-latm-version|--mp3-algorithm|--mp3-vbr-quality|--ldac-quality)
+			readarray -t list < <(_bluealsa_enum_values "$1" "$prev")
+			readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 			return
 			;;
 		--xapl-resp-name)
-			COMPREPLY=( $(compgen -W "BlueALSA iPhone" -- $cur) )
+			readarray -t COMPREPLY < <(compgen -W "BlueALSA iPhone" -- "$cur")
 			return
 			;;
 		--*)
@@ -222,7 +221,7 @@ _bluealsa() {
 			;;
 	esac
 
-	_bluealsa_complete_options $1
+	_bluealsa_complete_options "$1"
 }
 
 # completion function for bluealsa-aplay
@@ -230,15 +229,17 @@ _bluealsa() {
 # - does not complete MAC addresses
 # requires aplay to list ALSA pcms
 _bluealsa_aplay() {
-	local cur prev words cword split
 
+	local cur prev words cword split
 	_init_completion -s -n : || return
 
+	local list
+
 	case "$prev" in
 		--dbus|-B)
 			_have dbus-send || return
-			list=$(_bluealsa_list_dbus_suffices)
-			COMPREPLY=( $(compgen -W "$list" -- $cur) )
+			readarray -t list < <(_bluealsa_list_dbus_suffices)
+			readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 			return
 			;;
 		--pcm|-D)
@@ -247,8 +248,7 @@ _bluealsa_aplay() {
 			# do not attempt completion on words containing ' or "
 			[[ "$cur" =~ [\'\"] ]] && return
 
-			local IFS=$'\n'
-			COMPREPLY=( $(_bluealsa_aplay_pcms "$cur" ) )
+			readarray -t COMPREPLY < <(_bluealsa_aplay_pcms "$cur")
 
 			# ALSA pcm names can contain '=' and ':', both of which cause
 			# problems for bash completion if it considers them to be word
@@ -259,7 +259,7 @@ _bluealsa_aplay() {
 				local equal_prefix=${cur%"${cur##*=}"}
 				local i=${#COMPREPLY[*]}
 				while [[ $((--i)) -ge 0 ]]; do
-					COMPREPLY[$i]=${COMPREPLY[$i]#"$equal_prefix"}
+					COMPREPLY[i]=${COMPREPLY[$i]#"$equal_prefix"}
 				done
 			fi
 			__ltrim_colon_completions "$cur"
@@ -270,96 +270,190 @@ _bluealsa_aplay() {
 			;;
 	esac
 
-	_bluealsa_complete_options $1
+	_bluealsa_complete_options "$1"
 }
 
 # completion function for bluealsa-cli
 # complete available dbus suffices, command names and pcm paths in addition to options
 _bluealsa_cli() {
+
 	local cur prev words cword split
-	local dbus_opt service
+	_init_completion -s || return
+
+	local bautil_args service
 	local -i nonopt_offset
+	_bluealsa_util_init "$1" || return
 
-	_init_completion -s || return
+	# the command names supported by this version of bluealsa-cli
+	local simple_commands="list-pcms list-services monitor status"
+	local path_commands="codec info mute open soft-volume volume"
 
-	_bluealsa_util_init $1 || return
+	# options that may appear before or after the command
+	local global_shortopts="-h"
+	local global_longopts="--help"
 
-	# get the command names supported by this version of bluealsa-cli
-	local simple_commands="$(_bluealsa_cli_simple_commands $1)"
-	local path_commands="$(_bluealsa_cli_path_commands $1)"
+	# options that may appear only before the command
+	local base_shortopts="-B -V -q -v"
+	local base_longopts="--dbus= --version --quiet --verbose"
 
-	case "$prev" in
-		--dbus|-B)
-			_have dbus-send || return
-			list=$(_bluealsa_list_dbus_suffices)
-			COMPREPLY=( $(compgen -W "$list" -- $cur) )
-			return
-			;;
-		--*)
-			[[ ${COMP_WORDS[COMP_CWORD]} = = ]] && return
-			;;
-	esac
+	local command path list
 
+	# process pre-command options
 	case "$nonopt_offset" in
-		0) :
-		;;
+		0)
+			case "$prev" in
+				--dbus|-B)
+					_have dbus-send || return
+					readarray -t list < <(_bluealsa_list_dbus_suffices)
+					readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
+					return
+					;;
+			esac
+			case "$cur" in
+				-|--*)
+					readarray -t COMPREPLY < <(compgen -W "$global_longopts $base_longopts" -- "$cur")
+					[[ "${COMPREPLY[0]}" == *= ]] && compopt -o nospace
+					;;
+				-?)
+					readarray -t COMPREPLY < <(compgen -W "$global_shortopts $base_shortopts" -- "$cur")
+					;;
+			esac
+			return
+			;;
 		"$COMP_CWORD")
 			# list available commands
-			COMPREPLY=( $(compgen -W "$simple_commands $path_commands" -- "$cur") )
+			readarray -t COMPREPLY < <(compgen -W "$simple_commands $path_commands" -- "$cur")
 			return
 			;;
-		$((COMP_CWORD - 1)))
-			# if previous word was path command then list available paths
-			if [[ "$path_commands" =~ "$prev" ]]; then
-				COMPREPLY=( $(compgen -W "$("$1" $dbus_opt list-pcms 2>/dev/null)" -- $cur) )
-				return
-			fi
-			;;
-		$((COMP_CWORD - 2)))
-			# Attempt to enumerate command arguments
-			local path="$prev"
-			case ${COMP_WORDS[nonopt_offset]} in
-				codec)
-					COMPREPLY=( $(compgen -W "$(_bluealsa_pcm_codecs "$1" "$dbus_opt" "$path")" -- $cur) )
+	esac
+
+	# check for valid command
+	command="${COMP_WORDS[nonopt_offset]}"
+	[[ "$simple_commands $path_commands" =~ $command ]] || return
+
+	# process command-specific options
+	case "$command" in
+		monitor)
+			case "$prev" in
+				--properties)
+					_bluealsa_cli_properties
+					return
+					;;
+			esac
+			global_longopts+=" --properties"
+			global_shortopts+=" -p"
+			case "$cur" in
+				-p)
+					COMPREPLY=( "--properties" )
+					compopt -o nospace
+					return
+					;;
+				-p?*)
+					COMPREPLY=( "--properties=${cur:2}" )
+					compopt -o nospace
 					return
 					;;
-				mute|soft-volume)
-					COMPREPLY=( $(compgen -W "n y" -- $cur) )
+				--properties)
+					COMPREPLY=( "--properties=" )
+					compopt -o nospace
 					return
 					;;
-				*)
+				-)
+					COMPREPLY=( "--" )
+					compopt -o nospace
 					return
 					;;
 			esac
 			;;
-		$((COMP_CWORD - 3)))
-			[[ ${COMP_WORDS[nonopt_offset]} = mute ]] || return
-			COMPREPLY=( $(compgen -W "n y" -- $cur) )
+	esac
+
+	# find path argument
+	for (( i=(nonopt_offset + 1); i < COMP_CWORD; i++ )); do
+		[[ "${COMP_WORDS[i]}" == -* ]] && continue
+		path="${COMP_WORDS[i]}"
+		path_offset=i
+		break
+	done
+
+	# process global options and path
+	if [[ -z "$path" ]] ; then
+		case "$cur" in
+			-|--*)
+				readarray -t COMPREPLY < <(compgen -W "$global_longopts" -- "$cur")
+				[[ "${COMPREPLY[0]}" == --properties* ]] && compopt -o nospace
+				return
+				;;
+			-?)
+				readarray -t COMPREPLY < <(compgen -W "$global_shortopts" -- "$cur")
+				return
+				;;
+		esac
+		if [[ "$path_commands" =~ $command ]]; then
+			readarray -t list < <("${bautil_args[@]}" list-pcms 2>/dev/null)
+			readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 			return
+		fi
+	fi
+
+	# process command positional arguments
+	case "$command" in
+		codec)
+			if (( COMP_CWORD == path_offset + 1 )) ; then
+				readarray -t list < <(_bluealsa_pcm_codecs "$path")
+				readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
+			fi
+			;;
+		mute)
+			if (( COMP_CWORD < path_offset + 3 )) ; then
+				if [[ "$cur" == "" ]] ; then
+					COMPREPLY=( true false )
+				else
+					readarray -t COMPREPLY < <(compgen -W "on yes true 1 off no false 0" -- "${cur,,}")
+				fi
+			fi
+			;;
+		soft-volume)
+			if (( COMP_CWORD < path_offset + 2 )) ; then
+				if [[ "$cur" == "" ]] ; then
+					COMPREPLY=( true false )
+				else
+					readarray -t COMPREPLY < <(compgen -W "on yes true 1 off no false 0" -- "${cur,,}")
+				fi
+			fi
+			;;
+		monitor)
+			case "$prev" in
+				--properties)
+					_bluealsa_cli_properties
+					;;
+				-p)
+					return
+					;;
+			esac
 			;;
 	esac
-
-	(( nonopt_offset > 0 )) || _bluealsa_complete_options $1
 }
 
 # completion function for bluealsa-rfcomm
 # complete available dbus suffices and device paths in addition to options
 # requires busctl (part of elogind or systemd) to list device paths
 _bluealsa_rfcomm() {
-	local cur prev words cword split
-	local dbus_opt service
-	local -i nonopt_offset
 
+	local cur prev words cword split
 	_init_completion -s || return
 
-	_bluealsa_util_init $1 || return
+	local bautil_args service
+	local -i nonopt_offset
+	_bluealsa_util_init "$1" || return
+
+	local list
 
 	# check for dbus service suffix first
 	case "$prev" in
 		--dbus|-B)
 			_have dbus-send || return
-			list=$(_bluealsa_list_dbus_suffices)
-			COMPREPLY=( $(compgen -W "$list" -- $cur) )
+			readarray -t list < <(_bluealsa_list_dbus_suffices)
+			readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 			return
 			;;
 		--*)
@@ -369,17 +463,21 @@ _bluealsa_rfcomm() {
 
 	if (( nonopt_offset == COMP_CWORD )) ; then
 		_have busctl || return
-		COMPREPLY=( $(compgen -W "$(_bluealsa_rfcomm_paths $service)" -- $cur) )
+		readarray -t list < <(_bluealsa_rfcomm_paths "$service")
+		readarray -t COMPREPLY < <(compgen -W "${list[*]}" -- "$cur")
 		return
 	fi
 
 	# do not list options if path was found
-	(( $nonopt_offset > 0 )) || _bluealsa_complete_options $1
+	(( nonopt_offset > 0 )) && return
+
+	_bluealsa_complete_options "$1"
 }
 
 # completion function for a2dpconf and hcitop
 # complete only the options
 _bluealsa_others() {
+	# shellcheck disable=SC2034
 	local cur prev words cword split
 	_init_completion -s || return
 	case "$prev" in
@@ -387,7 +485,7 @@ _bluealsa_others() {
 			[[ ${COMP_WORDS[COMP_CWORD]} = = ]] && return
 			;;
 	esac
-	_bluealsa_complete_options $1
+	_bluealsa_complete_options "$1"
 }
 
 complete -F _bluealsa bluealsa
diff --git a/misc/systemd/Makefile.am b/misc/systemd/Makefile.am
index 3a5ebd2..01243d6 100644
--- a/misc/systemd/Makefile.am
+++ b/misc/systemd/Makefile.am
@@ -3,6 +3,8 @@
 
 systemdbluealsaargs = $(SYSTEMD_BLUEALSA_ARGS)
 systemdbluealsaaplayargs = $(SYSTEMD_BLUEALSA_APLAY_ARGS)
+bluealsauser = $(BLUEALSA_USER)
+bluealsaaplayuser = $(BLUEALSA_APLAY_USER)
 
 systemdsystemunitdir = $(SYSTEMD_SYSTEM_UNIT_DIR)
 
@@ -16,7 +18,9 @@ MOSTLYCLEANFILES = $(dist_systemdsystemunit_DATA)
 SYSTEMD_SERVICE_SUBS = \
 	s,[@]bindir[@],$(bindir),g; \
 	s,[@]systemdbluealsaargs[@],$(systemdbluealsaargs),g; \
-	s,[@]systemdbluealsaaplayargs[@],$(systemdbluealsaaplayargs),g;
+	s,[@]systemdbluealsaaplayargs[@],$(systemdbluealsaaplayargs),g; \
+	s,[@]bluealsauser[@],$(bluealsauser),g; \
+	s,[@]bluealsaaplayuser[@],$(bluealsaaplayuser),g;
 
 .in:
 	$(SED) -e '$(SYSTEMD_SERVICE_SUBS)' < $< > $@
diff --git a/misc/systemd/bluealsa-aplay.service.in b/misc/systemd/bluealsa-aplay.service.in
index f8792fc..8a187ac 100644
--- a/misc/systemd/bluealsa-aplay.service.in
+++ b/misc/systemd/bluealsa-aplay.service.in
@@ -11,10 +11,12 @@ Requisite=dbus.service
 # $ sudo systemctl edit bluealsa-aplay
 # [Service]
 # ExecStart=
-# ExecStart=@bindir@/bluealsa-aplay --pcm=my-playback-pcm
+# ExecStart=@bindir@/bluealsa-aplay -S --pcm=my-playback-pcm
 
 [Service]
 Type=simple
+User=@bluealsaaplayuser@
+Group=audio
 ExecStart=@bindir@/bluealsa-aplay @systemdbluealsaaplayargs@
 Restart=on-failure
 
@@ -28,13 +30,16 @@ MemoryDenyWriteExecute=true
 NoNewPrivileges=true
 PrivateTmp=true
 PrivateUsers=true
+ProtectClock=true
 ProtectControlGroups=true
 ProtectHome=true
 ProtectHostname=true
 ProtectKernelLogs=true
 ProtectKernelModules=true
 ProtectKernelTunables=true
+ProtectProc=invisible
 ProtectSystem=strict
+RemoveIPC=true
 RestrictAddressFamilies=AF_UNIX
 RestrictNamespaces=true
 RestrictRealtime=true
diff --git a/misc/systemd/bluealsa.service.in b/misc/systemd/bluealsa.service.in
index d181d95..5109101 100644
--- a/misc/systemd/bluealsa.service.in
+++ b/misc/systemd/bluealsa.service.in
@@ -12,30 +12,35 @@ After=bluetooth.service
 # $ sudo systemctl edit bluealsa
 # [Service]
 # ExecStart=
-# ExecStart=@bindir@/bluealsa --keep-alive=5 -p a2dp-sink
+# ExecStart=@bindir@/bluealsa -S --keep-alive=5 -p a2dp-sink
 
 [Service]
 Type=dbus
 BusName=org.bluealsa
+User=@bluealsauser@
 ExecStart=@bindir@/bluealsa @systemdbluealsaargs@
 Restart=on-failure
 
 # Sandboxing
-CapabilityBoundingSet=
+AmbientCapabilities=CAP_NET_RAW
+CapabilityBoundingSet=CAP_NET_RAW
 IPAddressDeny=any
 LockPersonality=true
 MemoryDenyWriteExecute=true
 NoNewPrivileges=true
 PrivateDevices=true
 PrivateTmp=true
-PrivateUsers=true
+PrivateUsers=false
+ProtectClock=true
 ProtectControlGroups=true
 ProtectHome=true
 ProtectHostname=true
 ProtectKernelLogs=true
 ProtectKernelModules=true
 ProtectKernelTunables=true
+ProtectProc=invisible
 ProtectSystem=strict
+RemoveIPC=true
 RestrictAddressFamilies=AF_UNIX AF_BLUETOOTH
 RestrictNamespaces=true
 RestrictRealtime=true
@@ -46,5 +51,9 @@ SystemCallFilter=@system-service
 SystemCallFilter=~@resources @privileged
 UMask=0077
 
+# Setup state directory for persistent storage
+ReadWritePaths=/var/lib/bluealsa
+StateDirectory=bluealsa
+
 [Install]
 WantedBy=bluetooth.target
diff --git a/src/Makefile.am b/src/Makefile.am
index 9f35a5e..df9b890 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -5,6 +5,7 @@ bin_PROGRAMS = bluealsa
 SUBDIRS = asound
 
 dbusconfdir = @DBUS_CONF_DIR@
+dbusbluealsauser = @BLUEALSA_USER@
 dist_dbusconf_DATA = bluealsa.conf
 
 bluealsa_SOURCES = \
@@ -35,6 +36,7 @@ bluealsa_SOURCES = \
 	io.c \
 	rtp.c \
 	sco.c \
+	storage.c \
 	utils.c \
 	main.c
 
@@ -127,3 +129,12 @@ LDADD = \
 	@MPG123_LIBS@ \
 	@SBC_LIBS@ \
 	@SPANDSP_LIBS@
+
+SUFFIXES = .conf.in .conf
+MOSTLYCLEANFILES = $(dist_dbusconf_DATA)
+
+DBUSCONF_SUBS = \
+	s,[@]bluealsauser[@],$(dbusbluealsauser),g;
+
+.conf.in.conf:
+	$(SED) -e '$(DBUSCONF_SUBS)' < $< > $@
diff --git a/src/a2dp-aac.c b/src/a2dp-aac.c
index fc2332e..ad8f22b 100644
--- a/src/a2dp-aac.c
+++ b/src/a2dp-aac.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-aac.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,10 +10,7 @@
 
 #include "a2dp-aac.h"
 
-#if ENABLE_AAC
-
 #include <errno.h>
-#include <endian.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stddef.h>
@@ -63,7 +60,7 @@ struct a2dp_codec a2dp_aac_sink = {
 	.dir = A2DP_SINK,
 	.codec_id = A2DP_CODEC_MPEG24,
 	.capabilities.aac = {
-		/* NOTE: AAC Long Term Prediction and AAC Scalable are
+		/* NOTE: AAC Long Term Prediction and AAC Scalable might be
 		 *       not supported by the FDK-AAC library. */
 		.object_type =
 			AAC_OBJECT_TYPE_MPEG2_AAC_LC |
@@ -99,7 +96,7 @@ struct a2dp_codec a2dp_aac_source = {
 	.dir = A2DP_SOURCE,
 	.codec_id = A2DP_CODEC_MPEG24,
 	.capabilities.aac = {
-		/* NOTE: AAC Long Term Prediction and AAC Scalable are
+		/* NOTE: AAC Long Term Prediction and AAC Scalable might be
 		 *       not supported by the FDK-AAC library. */
 		.object_type =
 			AAC_OBJECT_TYPE_MPEG2_AAC_LC |
@@ -133,6 +130,21 @@ struct a2dp_codec a2dp_aac_source = {
 
 void a2dp_aac_init(void) {
 
+	LIB_INFO info[16] = { 0 };
+	info[ARRAYSIZE(info) - 1].module_id = ~FDK_NONE;
+
+	aacDecoder_GetLibInfo(info);
+	aacEncGetLibInfo(info);
+
+	unsigned int caps_dec = FDKlibInfo_getCapabilities(info, FDK_AACDEC);
+	unsigned int caps_enc = FDKlibInfo_getCapabilities(info, FDK_AACENC);
+	debug("FDK-AAC lib capabilities: dec:%#x enc:%#x", caps_dec, caps_enc);
+
+	if (caps_dec & CAPF_ER_AAC_SCAL)
+		a2dp_aac_sink.capabilities.aac.object_type |= AAC_OBJECT_TYPE_MPEG4_AAC_SCA;
+	if (caps_enc & CAPF_ER_AAC_SCAL)
+		a2dp_aac_source.capabilities.aac.object_type |= AAC_OBJECT_TYPE_MPEG4_AAC_SCA;
+
 	if (config.a2dp.force_mono)
 		a2dp_aac_source.capabilities.aac.channels = AAC_CHANNELS_1;
 	if (config.a2dp.force_44100)
@@ -173,12 +185,13 @@ static unsigned int a2dp_aac_get_fdk_vbr_mode(
 	return 5;
 }
 
-static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_AACENCODER handle;
@@ -187,8 +200,8 @@ static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 
 	const a2dp_aac_t *configuration = &t->a2dp.configuration.aac;
 	const unsigned int bitrate = AAC_GET_BITRATE(*configuration);
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	/* create AAC encoder without the Meta Data module */
 	if ((err = aacEncOpen(&handle, 0x07, channels)) != AACENC_OK) {
@@ -284,7 +297,7 @@ static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 
 	const unsigned int aac_frame_size = aacinf.inputChannels * aacinf.frameLength;
-	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t->a2dp.pcm.format);
+	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t_pcm->format);
 	if (ffb_init(&pcm, aac_frame_size, sample_size) == -1 ||
 			ffb_init_uint8_t(&bt, RTP_HEADER_LEN + aacinf.maxOutBufBytes) == -1) {
 		error("Couldn't create data buffers: %s", strerror(errno));
@@ -324,13 +337,22 @@ static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 	AACENC_OutArgs out_args = { 0 };
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				in_args.numInSamples = -1;
+				/* flush encoder internal buffers */
+				while (aacEncEncode(handle, NULL, &out_buf, &in_args, &out_args) == AACENC_OK)
+					continue;
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -389,7 +411,7 @@ static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 			rtp_state_update(&rtp, pcm_frames);
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* If the input buffer was not consumed, we have to append new data to
 			 * the existing one. Since we do not use ring buffer, we will simply
@@ -402,8 +424,6 @@ static void *a2dp_aac_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -414,12 +434,14 @@ fail_open:
 	return NULL;
 }
 
-static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_AACDECODER handle;
@@ -432,8 +454,8 @@ static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_cleanup_push(PTHREAD_CLEANUP(aacDecoder_Close), handle);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 #ifdef AACDECODER_LIB_VL0
 	if ((err = aacDecoder_SetParam(handle, AAC_PCM_MIN_OUTPUT_CHANNELS, channels)) != AAC_DEC_OK) {
@@ -472,7 +494,7 @@ static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 	int markbit_quirk = -3;
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -489,7 +511,7 @@ static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 		int missing_rtp_frames = 0;
 		rtp_state_sync_stream(&rtp, rtp_header, &missing_rtp_frames, NULL);
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -540,8 +562,8 @@ static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 				warn("AAC channels mismatch: %u != %u", aacinf->numChannels, channels);
 
 			const size_t samples = (size_t)aacinf->frameSize * channels;
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 			/* update local state with decoded PCM frames */
@@ -556,8 +578,6 @@ static void *a2dp_aac_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -571,14 +591,12 @@ fail_open:
 
 int a2dp_aac_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_aac_enc_thread, "ba-a2dp-aac", true);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_aac_dec_thread, "ba-a2dp-aac", true);
 
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-aptx-hd.c b/src/a2dp-aptx-hd.c
index ae1d283..e54426f 100644
--- a/src/a2dp-aptx-hd.c
+++ b/src/a2dp-aptx-hd.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-aptx-hd.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,10 +9,8 @@
  */
 
 #include "a2dp-aptx-hd.h"
+/* IWYU pragma: no_include "config.h" */
 
-#if ENABLE_APTX_HD
-
-#include <endian.h>
 #include <errno.h>
 #include <pthread.h>
 #include <stdbool.h>
@@ -27,7 +25,6 @@
 #include "codec-aptx.h"
 #include "io.h"
 #include "rtp.h"
-#include "utils.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/ffb.h"
@@ -104,12 +101,13 @@ void a2dp_aptx_hd_transport_init(struct ba_transport *t) {
 
 }
 
-static void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_APTX handle;
@@ -124,8 +122,8 @@ static void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 	pthread_cleanup_push(PTHREAD_CLEANUP(aptxhdenc_destroy), handle);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	const size_t aptx_pcm_samples = 4 * channels;
 	const size_t aptx_code_len = 2 * 3 * sizeof(uint8_t);
 	const size_t mtu_write = t->mtu_write;
@@ -145,13 +143,18 @@ static void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, samplerate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -208,7 +211,7 @@ static void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
 			rtp.ts_pcm_frames += pcm_frames;
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* reinitialize output buffer */
 			ffb_rewind(&bt);
@@ -225,8 +228,6 @@ static void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -237,12 +238,14 @@ fail_init:
 }
 
 #if HAVE_APTX_HD_DECODE
-static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_APTX handle;
@@ -257,8 +260,8 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 	pthread_cleanup_push(PTHREAD_CLEANUP(aptxhddec_destroy), handle);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	/* Note, that we are allocating space for one extra output packed, which is
 	 * required by the aptx_decode_sync() function of libopenaptx library. */
@@ -273,7 +276,7 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, samplerate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -290,7 +293,7 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 		int missing_rtp_frames = 0;
 		rtp_state_sync_stream(&rtp, rtp_header, &missing_rtp_frames, NULL);
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -301,8 +304,6 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 		while (rtp_payload_len >= 6) {
 
 			size_t decoded = ffb_len_in(&pcm);
-			ssize_t len;
-
 			if ((len = aptxhddec_decode(handle, rtp_payload, rtp_payload_len, pcm.tail, &decoded)) <= 0) {
 				error("Apt-X decoding error: %s", strerror(errno));
 				continue;
@@ -315,8 +316,8 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 		}
 
 		const size_t samples = ffb_len_out(&pcm);
-		io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-		if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+		io_pcm_scale(t_pcm, pcm.data, samples);
+		if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 			error("FIFO write error: %s", strerror(errno));
 
 		/* update local state with decoded PCM frames */
@@ -326,8 +327,6 @@ static void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -340,16 +339,14 @@ fail_init:
 
 int a2dp_aptx_hd_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_aptx_hd_enc_thread, "ba-a2dp-aptx-hd", true);
 
 #if HAVE_APTX_HD_DECODE
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_aptx_hd_dec_thread, "ba-a2dp-aptx-hd", true);
 #endif
 
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-aptx.c b/src/a2dp-aptx.c
index 096b97f..b51c0d3 100644
--- a/src/a2dp-aptx.c
+++ b/src/a2dp-aptx.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-aptx.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,8 +9,7 @@
  */
 
 #include "a2dp-aptx.h"
-
-#if ENABLE_APTX
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <pthread.h>
@@ -25,7 +24,6 @@
 #include "a2dp.h"
 #include "codec-aptx.h"
 #include "io.h"
-#include "utils.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/ffb.h"
@@ -102,12 +100,13 @@ void a2dp_aptx_transport_init(struct ba_transport *t) {
 
 }
 
-static void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_APTX handle;
@@ -122,7 +121,7 @@ static void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 	pthread_cleanup_push(PTHREAD_CLEANUP(aptxenc_destroy), handle);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
+	const unsigned int channels = t_pcm->channels;
 	const size_t aptx_pcm_samples = 4 * channels;
 	const size_t aptx_code_len = 2 * sizeof(uint16_t);
 	const size_t mtu_write = t->mtu_write;
@@ -134,13 +133,18 @@ static void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -189,7 +193,7 @@ static void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
 			asrsync_sync(&io.asrs, pcm_samples / channels);
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* reinitialize output buffer */
 			ffb_rewind(&bt);
@@ -206,8 +210,6 @@ static void *a2dp_aptx_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -218,12 +220,14 @@ fail_init:
 }
 
 #if HAVE_APTX_DECODE
-static void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_APTX handle;
@@ -247,7 +251,7 @@ static void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -256,7 +260,7 @@ static void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
 			goto fail;
 		}
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm))
+		if (!ba_transport_pcm_is_active(t_pcm))
 			continue;
 
 		uint8_t *input = bt.data;
@@ -266,8 +270,6 @@ static void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
 		while (input_len >= 4) {
 
 			size_t decoded = ffb_len_in(&pcm);
-			ssize_t len;
-
 			if ((len = aptxdec_decode(handle, input, input_len, pcm.tail, &decoded)) <= 0) {
 				error("Apt-X decoding error: %s", strerror(errno));
 				continue;
@@ -280,16 +282,14 @@ static void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) {
 		}
 
 		const size_t samples = ffb_len_out(&pcm);
-		io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-		if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+		io_pcm_scale(t_pcm, pcm.data, samples);
+		if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 			error("FIFO write error: %s", strerror(errno));
 
 	}
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -302,16 +302,14 @@ fail_init:
 
 int a2dp_aptx_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_aptx_enc_thread, "ba-a2dp-aptx", true);
 
 #if HAVE_APTX_DECODE
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_aptx_dec_thread, "ba-a2dp-aptx", true);
 #endif
 
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-faststream.c b/src/a2dp-faststream.c
index 4341888..410a588 100644
--- a/src/a2dp-faststream.c
+++ b/src/a2dp-faststream.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-faststream.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,8 +10,6 @@
 
 #include "a2dp-faststream.h"
 
-#if ENABLE_FASTSTREAM
-
 #include <errno.h>
 #include <pthread.h>
 #include <stdbool.h>
@@ -27,7 +25,6 @@
 #include "a2dp.h"
 #include "codec-sbc.h"
 #include "io.h"
-#include "utils.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/ffb.h"
@@ -107,21 +104,22 @@ void a2dp_faststream_transport_init(struct ba_transport *t) {
 
 }
 
-static void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	/* determine encoder operation mode: music or voice */
-	const bool is_voice = t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport_pcm *t_a2dp_pcm = is_voice ? &t->a2dp.pcm_bc : &t->a2dp.pcm;
+	const bool is_voice = t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK;
 
 	sbc_t sbc;
-	if ((errno = -sbc_init_a2dp_faststream(&sbc, 0, &t->a2dp.configuration.faststream,
-					sizeof(t->a2dp.configuration.faststream), is_voice)) != 0) {
+	const a2dp_faststream_t *configuration = &t->a2dp.configuration.faststream;
+	if ((errno = -sbc_init_a2dp_faststream(&sbc, 0, configuration,
+					sizeof(*configuration), is_voice)) != 0) {
 		error("Couldn't initialize FastStream SBC codec: %s", strerror(errno));
 		goto fail_init;
 	}
@@ -132,7 +130,7 @@ static void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 	pthread_cleanup_push(PTHREAD_CLEANUP(sbc_finish), &sbc);
 
-	const unsigned int channels = t_a2dp_pcm->channels;
+	const unsigned int channels = t_pcm->channels;
 	const size_t sbc_frame_len = sbc_get_frame_length(&sbc);
 	const size_t sbc_frame_samples = sbc_get_codesize(&sbc) / sizeof(int16_t);
 
@@ -143,13 +141,20 @@ static void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t samples = ffb_len_in(&pcm);
-		if ((samples = io_poll_and_read_pcm(&io, t_a2dp_pcm, pcm.tail, samples)) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				sbc_reinit_a2dp_faststream(&sbc, 0, configuration,
+						sizeof(*configuration), is_voice);
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -202,7 +207,7 @@ static void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
 			asrsync_sync(&io.asrs, pcm_frames);
 
 			/* update busy delay (encoding overhead) */
-			t_a2dp_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* If the input buffer was not consumed (due to codesize limit), we
 			 * have to append new data to the existing one. Since we do not use
@@ -216,8 +221,6 @@ static void *a2dp_faststream_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -227,17 +230,18 @@ fail_init:
 	return NULL;
 }
 
-static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	/* determine decoder operation mode: music or voice */
-	const bool is_voice = t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE;
-	struct ba_transport_pcm *t_a2dp_pcm = is_voice ? &t->a2dp.pcm_bc : &t->a2dp.pcm;
+	const bool is_voice = t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE;
 
 	sbc_t sbc;
 	if ((errno = -sbc_init_a2dp_faststream(&sbc, 0, &t->a2dp.configuration.faststream,
@@ -262,7 +266,7 @@ static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.tail, len)) <= 0) {
@@ -271,7 +275,7 @@ static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 			goto fail;
 		}
 
-		if (!ba_transport_pcm_is_active(t_a2dp_pcm))
+		if (!ba_transport_pcm_is_active(t_pcm))
 			continue;
 
 		uint8_t *input = bt.data;
@@ -280,9 +284,7 @@ static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 		/* decode retrieved SBC frames */
 		while (input_len >= sbc_frame_len) {
 
-			ssize_t len;
 			size_t decoded;
-
 			if ((len = sbc_decode(&sbc, input, input_len,
 							pcm.data, ffb_blen_in(&pcm), &decoded)) < 0) {
 				error("FastStream SBC decoding error: %s", sbc_strerror(len));
@@ -293,8 +295,8 @@ static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 			input_len -= len;
 
 			const size_t samples = decoded / sizeof(int16_t);
-			io_pcm_scale(t_a2dp_pcm, pcm.data, samples);
-			if (io_pcm_write(t_a2dp_pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 		}
@@ -303,8 +305,6 @@ static void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -320,7 +320,7 @@ int a2dp_faststream_transport_start(struct ba_transport *t) {
 	struct ba_transport_thread *th_dec = &t->thread_dec;
 	int rv = 0;
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
 		if (t->a2dp.configuration.faststream.direction & FASTSTREAM_DIRECTION_MUSIC)
 			rv |= ba_transport_thread_create(th_enc, a2dp_faststream_enc_thread, "ba-a2dp-fs-m", true);
 		if (t->a2dp.configuration.faststream.direction & FASTSTREAM_DIRECTION_VOICE)
@@ -328,7 +328,7 @@ int a2dp_faststream_transport_start(struct ba_transport *t) {
 		return rv;
 	}
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK) {
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK) {
 		if (t->a2dp.configuration.faststream.direction & FASTSTREAM_DIRECTION_MUSIC)
 			rv |= ba_transport_thread_create(th_dec, a2dp_faststream_dec_thread, "ba-a2dp-fs-m", true);
 		if (t->a2dp.configuration.faststream.direction & FASTSTREAM_DIRECTION_VOICE)
@@ -339,5 +339,3 @@ int a2dp_faststream_transport_start(struct ba_transport *t) {
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-lc3plus.c b/src/a2dp-lc3plus.c
index 1d46637..6f54498 100644
--- a/src/a2dp-lc3plus.c
+++ b/src/a2dp-lc3plus.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-lc3plus.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,15 +9,14 @@
  */
 
 #include "a2dp-lc3plus.h"
+/* IWYU pragma: no_include "config.h" */
 
-#if ENABLE_LC3PLUS
-
-#include <endian.h>
 #include <errno.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stddef.h>
 #include <stdint.h>
+#include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
 
@@ -28,7 +27,6 @@
 #include "a2dp.h"
 #include "audio.h"
 #include "bluealsa-config.h"
-#include "codec-sbc.h"
 #include "io.h"
 #include "rtp.h"
 #include "utils.h"
@@ -169,7 +167,7 @@ static int a2dp_lc3plus_get_frame_dms(const a2dp_lc3plus_t *conf) {
 	}
 }
 
-static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 
 	/* Cancellation should be possible only in the carefully selected place
 	 * in order to prevent memory leaks and resources not being released. */
@@ -177,12 +175,13 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	const a2dp_lc3plus_t *configuration = &t->a2dp.configuration.lc3plus;
 	const int lc3plus_frame_dms = a2dp_lc3plus_get_frame_dms(configuration);
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	const unsigned int rtp_ts_clockrate = 96000;
 
 	/* check whether library supports selected configuration */
@@ -236,6 +235,7 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 
 	int32_t *pcm_ch1 = malloc(lc3plus_ch_samples * sizeof(int32_t));
 	int32_t *pcm_ch2 = malloc(lc3plus_ch_samples * sizeof(int32_t));
+	int32_t *pcm_ch_buffers[2] = { pcm_ch1, pcm_ch2 };
 	pthread_cleanup_push(PTHREAD_CLEANUP(free), pcm_ch1);
 	pthread_cleanup_push(PTHREAD_CLEANUP(free), pcm_ch2);
 
@@ -257,13 +257,23 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, rtp_ts_clockrate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				int encoded = 0;
+				memset(pcm_ch1, 0, lc3plus_ch_samples * sizeof(*pcm_ch1));
+				memset(pcm_ch2, 0, lc3plus_ch_samples * sizeof(*pcm_ch2));
+				/* flush encoder internal buffers by feeding it with silence */
+				lc3plus_enc24(handle, pcm_ch_buffers, rtp_payload, &encoded);
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -289,9 +299,8 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 				lc3plus_frames < ((1 << 4) - 1)) {
 
 			int encoded = 0;
-			int32_t *in_buffers[2] = { pcm_ch1, pcm_ch2 };
 			audio_deinterleave_s24_4le(input, lc3plus_ch_samples, channels, pcm_ch1, pcm_ch2);
-			if ((err = lc3plus_enc24(handle, in_buffers, bt.tail, &encoded)) != LC3PLUS_OK) {
+			if ((err = lc3plus_enc24(handle, pcm_ch_buffers, bt.tail, &encoded)) != LC3PLUS_OK) {
 				error("LC3plus encoding error: %s", lc3plus_strerror(err));
 				break;
 			}
@@ -361,7 +370,7 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 			rtp_state_update(&rtp, pcm_frames);
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* If the input buffer was not consumed (due to codesize limit), we
 			 * have to append new data to the existing one. Since we do not use
@@ -375,8 +384,6 @@ static void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -389,7 +396,8 @@ fail_init:
 	return NULL;
 }
 
-static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 
 	/* Cancellation should be possible only in the carefully selected place
 	 * in order to prevent memory leaks and resources not being released. */
@@ -397,11 +405,12 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	const a2dp_lc3plus_t *configuration = &t->a2dp.configuration.lc3plus;
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	const unsigned int rtp_ts_clockrate = 96000;
 
 	/* check whether library supports selected configuration */
@@ -440,6 +449,7 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 
 	int32_t *pcm_ch1 = malloc(lc3plus_ch_samples * sizeof(int32_t));
 	int32_t *pcm_ch2 = malloc(lc3plus_ch_samples * sizeof(int32_t));
+	int32_t *pcm_ch_buffers[2] = { pcm_ch1, pcm_ch2 };
 	pthread_cleanup_push(PTHREAD_CLEANUP(free), pcm_ch1);
 	pthread_cleanup_push(PTHREAD_CLEANUP(free), pcm_ch2);
 
@@ -460,7 +470,7 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 	bool rtp_media_fragment_skip = false;
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -488,7 +498,7 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 			ffb_rewind(&bt_payload);
 		}
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -502,15 +512,14 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 
 		while (missing_pcm_frames > 0) {
 
-			int32_t *out_buffers[2] = { pcm_ch1, pcm_ch2 };
-			lc3plus_dec24(handle, bt_payload.data, 0, out_buffers, 1);
+			lc3plus_dec24(handle, bt_payload.data, 0, pcm_ch_buffers, 1);
 			audio_interleave_s24_4le(pcm_ch1, pcm_ch2, lc3plus_ch_samples, channels, pcm.data);
 
 			warn("Missing LC3plus data, loss concealment applied");
 
 			const size_t samples = lc3plus_frame_samples;
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 			missing_pcm_frames -= lc3plus_ch_samples;
@@ -557,8 +566,7 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 		/* Decode retrieved LC3plus frames. */
 		while (lc3plus_frames--) {
 
-			int32_t *out_buffers[2] = { pcm_ch1, pcm_ch2 };
-			err = lc3plus_dec24(handle, lc3plus_payload, lc3plus_frame_len, out_buffers, 0);
+			err = lc3plus_dec24(handle, lc3plus_payload, lc3plus_frame_len, pcm_ch_buffers, 0);
 			audio_interleave_s24_4le(pcm_ch1, pcm_ch2, lc3plus_ch_samples, channels, pcm.data);
 
 			if (err == LC3PLUS_DECODE_ERROR)
@@ -571,8 +579,8 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 			lc3plus_payload += lc3plus_frame_len;
 
 			const size_t samples = lc3plus_frame_samples;
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 			/* update local state with decoded PCM frames */
@@ -587,8 +595,6 @@ static void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -604,14 +610,12 @@ fail_init:
 
 int a2dp_lc3plus_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_lc3plus_enc_thread, "ba-a2dp-lc3p", true);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_lc3plus_dec_thread, "ba-a2dp-lc3p", true);
 
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-ldac.c b/src/a2dp-ldac.c
index a9d4e44..74bcc50 100644
--- a/src/a2dp-ldac.c
+++ b/src/a2dp-ldac.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-ldac.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,10 +10,7 @@
 
 #include "a2dp-ldac.h"
 
-#if ENABLE_LDAC
-
 #include <errno.h>
-#include <endian.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stddef.h>
@@ -118,12 +115,13 @@ void a2dp_ldac_transport_init(struct ba_transport *t) {
 
 }
 
-static void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_LDAC_BT handle;
@@ -143,9 +141,9 @@ static void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ldac_ABR_free_handle), handle_abr);
 
 	const a2dp_ldac_t *configuration = &t->a2dp.configuration.ldac;
-	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t->a2dp.pcm.format);
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t_pcm->format);
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	const size_t ldac_pcm_samples = LDACBT_ENC_LSU * channels;
 
 	if (ldacBT_init_handle_encode(handle, t->mtu_write, config.ldac_eqmid,
@@ -185,13 +183,21 @@ static void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, samplerate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				int tmp;
+				/* flush encoder internal buffers */
+				ldacBT_encode(handle, NULL, &tmp, rtp_payload, &tmp, &tmp);
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -261,7 +267,7 @@ static void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
 			rtp_state_update(&rtp, pcm_frames);
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 		}
 
@@ -275,8 +281,6 @@ static void *a2dp_ldac_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -290,12 +294,14 @@ fail_open_ldac:
 }
 
 #if HAVE_LDAC_DECODE
-static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	HANDLE_LDAC_BT handle;
@@ -307,9 +313,9 @@ static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ldacBT_free_handle), handle);
 
 	const a2dp_ldac_t *configuration = &t->a2dp.configuration.ldac;
-	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t->a2dp.pcm.format);
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(t_pcm->format);
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	if (ldacBT_init_handle_decode(handle, configuration->channel_mode, samplerate, 0, 0, 0) == -1) {
 		error("Couldn't initialize LDAC decoder: %s", ldacBT_strerror(ldacBT_get_error_code(handle)));
@@ -332,7 +338,7 @@ static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, samplerate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -349,7 +355,7 @@ static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 		int missing_rtp_frames = 0;
 		rtp_state_sync_stream(&rtp, rtp_header, &missing_rtp_frames, NULL);
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -373,8 +379,8 @@ static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 			rtp_payload_len -= used;
 
 			const size_t samples = decoded / sample_size;
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 			/* update local state with decoded PCM frames */
@@ -386,8 +392,6 @@ static void *a2dp_ldac_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -401,16 +405,14 @@ fail_open:
 
 int a2dp_ldac_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_ldac_enc_thread, "ba-a2dp-ldac", true);
 
 #if HAVE_LDAC_DECODE
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_ldac_dec_thread, "ba-a2dp-ldac", true);
 #endif
 
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-mpeg.c b/src/a2dp-mpeg.c
index 0a0bd8f..04bd74e 100644
--- a/src/a2dp-mpeg.c
+++ b/src/a2dp-mpeg.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-mpeg.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,11 +9,9 @@
  */
 
 #include "a2dp-mpeg.h"
-
-#if ENABLE_MPEG
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
-#include <endian.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stddef.h>
@@ -189,12 +187,13 @@ void a2dp_mpeg_transport_init(struct ba_transport *t) {
 }
 
 #if ENABLE_MP3LAME
-static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	lame_t handle;
@@ -206,8 +205,8 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(lame_close), handle);
 
 	const a2dp_mpeg_t *configuration = &t->a2dp.configuration.mpeg;
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	MPEG_mode mode = NOT_SET;
 
 	lame_set_num_channels(handle, channels);
@@ -284,9 +283,10 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 
 	const size_t mpeg_pcm_samples = lame_get_framesize(handle);
 	const size_t rtp_headers_len = RTP_HEADER_LEN + sizeof(rtp_mpeg_audio_header_t);
-	/* It is hard to tell the size of the buffer required, but
-	 * empirical test shows that 2KB should be sufficient. */
-	const size_t mpeg_frame_len = 2048;
+	/* It is hard to tell the size of the buffer required, but empirical test
+	 * shows that 2KB should be sufficient for encoding. However, encoder flush
+	 * function requires a little bit more space. */
+	const size_t mpeg_frame_len = 4 * 1024;
 
 	if (ffb_init_int16_t(&pcm, mpeg_pcm_samples) == -1 ||
 			ffb_init_uint8_t(&bt, rtp_headers_len + mpeg_frame_len) == -1) {
@@ -305,13 +305,19 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, 90000);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				lame_encode_flush(handle, rtp_payload, mpeg_frame_len);
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -349,7 +355,7 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 				ffb_rewind(&bt);
 				ffb_seek(&bt, RTP_HEADER_LEN + sizeof(*rtp_mpeg_audio_header) + chunk_len);
 
-				ssize_t len = ffb_blen_out(&bt);
+				len = ffb_blen_out(&bt);
 				if ((len = io_bt_write(th, bt.data, len)) <= 0) {
 					if (len == -1)
 						error("BT write error: %s", strerror(errno));
@@ -377,7 +383,7 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 		rtp_state_update(&rtp, pcm_frames);
 
 		/* update busy delay (encoding overhead) */
-		t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+		t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 		/* If the input buffer was not consumed (due to frame alignment), we
 		 * have to append new data to the existing one. Since we do not use
@@ -389,8 +395,6 @@ static void *a2dp_mp3_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -403,12 +407,14 @@ fail_init:
 #endif
 
 #if ENABLE_MP3LAME || ENABLE_MPG123
-static void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 #if ENABLE_MPG123
@@ -425,8 +431,8 @@ static void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
 
 	pthread_cleanup_push(PTHREAD_CLEANUP(mpg123_delete), handle);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	mpg123_param(handle, MPG123_RESYNC_LIMIT, -1, 0);
 	mpg123_param(handle, MPG123_ADD_FLAGS, MPG123_QUIET, 0);
@@ -455,8 +461,8 @@ static void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
 		goto fail_init;
 	}
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 	pthread_cleanup_push(PTHREAD_CLEANUP(hip_decode_exit), handle);
 
 	/* NOTE: Size of the output buffer is "hard-coded" in hip_decode(). What is
@@ -483,7 +489,7 @@ static void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, 90000);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -500,7 +506,7 @@ static void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) {
 		int missing_rtp_frames = 0;
 		rtp_state_sync_stream(&rtp, rtp_header, &missing_rtp_frames, NULL);
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -531,8 +537,8 @@ decode:
 		}
 
 		const size_t samples = len / sizeof(int16_t);
-		io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-		if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+		io_pcm_scale(t_pcm, pcm.data, samples);
+		if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 			error("FIFO write error: %s", strerror(errno));
 
 		/* update local state with decoded PCM frames */
@@ -555,8 +561,8 @@ decode:
 		}
 
 		if (channels == 1) {
-			io_pcm_scale(&t->a2dp.pcm, pcm_l, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm_l, samples) == -1)
+			io_pcm_scale(t_pcm, pcm_l, samples);
+			if (io_pcm_write(t_pcm, pcm_l, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 		}
 		else {
@@ -567,8 +573,8 @@ decode:
 				((int16_t *)pcm.data)[i * 2 + 1] = pcm_r[i];
 			}
 
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 		}
@@ -582,8 +588,6 @@ decode:
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -599,14 +603,14 @@ fail_init:
 
 int a2dp_mpeg_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
 #if ENABLE_MP3LAME
 		if (t->a2dp.configuration.mpeg.layer == MPEG_LAYER_MP3)
 			return ba_transport_thread_create(&t->thread_enc, a2dp_mp3_enc_thread, "ba-a2dp-mp3", true);
 #endif
 	}
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK) {
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK) {
 #if ENABLE_MPG123
 		return ba_transport_thread_create(&t->thread_dec, a2dp_mpeg_dec_thread, "ba-a2dp-mpeg", true);
 #elif ENABLE_MP3LAME
@@ -618,5 +622,3 @@ int a2dp_mpeg_transport_start(struct ba_transport *t) {
 	g_assert_not_reached();
 	return -1;
 }
-
-#endif
diff --git a/src/a2dp-sbc.c b/src/a2dp-sbc.c
index 8896b32..1580db5 100644
--- a/src/a2dp-sbc.c
+++ b/src/a2dp-sbc.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp-sbc.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,8 +9,8 @@
  */
 
 #include "a2dp-sbc.h"
+/* IWYU pragma: no_include "config.h" */
 
-#include <endian.h>
 #include <errno.h>
 #include <pthread.h>
 #include <stdbool.h>
@@ -28,7 +28,6 @@
 #include "codec-sbc.h"
 #include "io.h"
 #include "rtp.h"
-#include "utils.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/ffb.h"
@@ -154,17 +153,18 @@ void a2dp_sbc_transport_init(struct ba_transport *t) {
 
 }
 
-static void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
+void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
 
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	sbc_t sbc;
-	if ((errno = -sbc_init_a2dp(&sbc, 0, &t->a2dp.configuration.sbc,
-					sizeof(t->a2dp.configuration.sbc))) != 0) {
+	const a2dp_sbc_t *configuration = &t->a2dp.configuration.sbc;
+	if ((errno = -sbc_init_a2dp(&sbc, 0, configuration, sizeof(*configuration))) != 0) {
 		error("Couldn't initialize SBC codec: %s", strerror(errno));
 		goto fail_init;
 	}
@@ -175,10 +175,9 @@ static void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 	pthread_cleanup_push(PTHREAD_CLEANUP(sbc_finish), &sbc);
 
-	const a2dp_sbc_t *configuration = &t->a2dp.configuration.sbc;
 	const size_t sbc_frame_samples = sbc_get_codesize(&sbc) / sizeof(int16_t);
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	/* initialize SBC encoder bit-pool */
 	sbc.bitpool = sbc_a2dp_get_bitpool(configuration, config.sbc_quality);
@@ -221,13 +220,20 @@ static void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
 	rtp_state_init(&rtp, samplerate, samplerate);
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
-
-		ssize_t samples;
-		if ((samples = io_poll_and_read_pcm(&io, &t->a2dp.pcm,
-						pcm.tail, ffb_len_in(&pcm))) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		ssize_t samples = ffb_len_in(&pcm);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				sbc_reinit_a2dp(&sbc, 0, configuration, sizeof(*configuration));
+				sbc.bitpool = sbc_a2dp_get_bitpool(configuration, config.sbc_quality);
+				ffb_rewind(&pcm);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
 			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
@@ -289,7 +295,7 @@ static void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
 			rtp_state_update(&rtp, pcm_frames);
 
 			/* update busy delay (encoding overhead) */
-			t->a2dp.pcm.delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* If the input buffer was not consumed (due to codesize limit), we
 			 * have to append new data to the existing one. Since we do not use
@@ -303,8 +309,6 @@ static void *a2dp_sbc_enc_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -314,7 +318,8 @@ fail_init:
 	return NULL;
 }
 
-static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
+__attribute__ ((weak))
+void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 
 	/* Cancellation should be possible only in the carefully selected place
 	 * in order to prevent memory leaks and resources not being released. */
@@ -322,6 +327,7 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	sbc_t sbc;
@@ -337,8 +343,8 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &bt);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &pcm);
 
-	const unsigned int channels = t->a2dp.pcm.channels;
-	const unsigned int samplerate = t->a2dp.pcm.sampling;
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
 
 	if (ffb_init_int16_t(&pcm, sbc_get_codesize(&sbc)) == -1 ||
 			ffb_init_uint8_t(&bt, t->mtu_read) == -1) {
@@ -355,7 +361,7 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 #endif
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -372,7 +378,7 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 		int missing_rtp_frames = 0;
 		rtp_state_sync_stream(&rtp, rtp_header, &missing_rtp_frames, NULL);
 
-		if (!ba_transport_pcm_is_active(&t->a2dp.pcm)) {
+		if (!ba_transport_pcm_is_active(t_pcm)) {
 			rtp.synced = false;
 			continue;
 		}
@@ -384,9 +390,7 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 		size_t frames = rtp_media_header->frame_count;
 		while (frames--) {
 
-			ssize_t len;
 			size_t decoded;
-
 			if ((len = sbc_decode(&sbc, rtp_payload, rtp_payload_len,
 							pcm.data, ffb_blen_in(&pcm), &decoded)) < 0) {
 				error("SBC decoding error: %s", sbc_strerror(len));
@@ -404,8 +408,8 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 			rtp_payload_len -= len;
 
 			const size_t samples = decoded / sizeof(int16_t);
-			io_pcm_scale(&t->a2dp.pcm, pcm.data, samples);
-			if (io_pcm_write(&t->a2dp.pcm, pcm.data, samples) == -1)
+			io_pcm_scale(t_pcm, pcm.data, samples);
+			if (io_pcm_write(t_pcm, pcm.data, samples) == -1)
 				error("FIFO write error: %s", strerror(errno));
 
 			/* update local state with decoded PCM frames */
@@ -417,8 +421,6 @@ static void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -430,10 +432,10 @@ fail_init:
 
 int a2dp_sbc_transport_start(struct ba_transport *t) {
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
 		return ba_transport_thread_create(&t->thread_enc, a2dp_sbc_enc_thread, "ba-a2dp-sbc", true);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
 		return ba_transport_thread_create(&t->thread_dec, a2dp_sbc_dec_thread, "ba-a2dp-sbc", true);
 
 	g_assert_not_reached();
diff --git a/src/a2dp.c b/src/a2dp.c
index c19602f..abb22fa 100644
--- a/src/a2dp.c
+++ b/src/a2dp.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - a2dp.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "a2dp.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <limits.h>
@@ -18,19 +19,32 @@
 
 #include <glib.h>
 
-#include "a2dp-aac.h"
-#include "a2dp-aptx-hd.h"
-#include "a2dp-aptx.h"
-#include "a2dp-faststream.h"
-#include "a2dp-lc3plus.h"
-#include "a2dp-ldac.h"
-#include "a2dp-mpeg.h"
+#if ENABLE_AAC
+# include "a2dp-aac.h"
+#endif
+#if ENABLE_APTX
+# include "a2dp-aptx.h"
+#endif
+#if ENABLE_APTX_HD
+# include "a2dp-aptx-hd.h"
+#endif
+#if ENABLE_FASTSTREAM
+# include "a2dp-faststream.h"
+#endif
+#if ENABLE_LC3PLUS
+# include "a2dp-lc3plus.h"
+#endif
+#if ENABLE_LDAC
+# include "a2dp-ldac.h"
+#endif
+#if ENABLE_MPEG
+# include "a2dp-mpeg.h"
+#endif
 #include "a2dp-sbc.h"
 #include "bluealsa-config.h"
 #include "codec-sbc.h"
 #include "hci.h"
 #include "shared/a2dp-codecs.h"
-#include "shared/defs.h"
 #include "shared/log.h"
 
 struct a2dp_codec * const a2dp_codecs[] = {
@@ -105,30 +119,43 @@ int a2dp_codecs_init(void) {
 	return 0;
 }
 
-static int a2dp_codecs_qsort_cmp(const void *a_, const void *b_) {
-	const struct a2dp_codec *a = *(const struct a2dp_codec **)a_;
-	const struct a2dp_codec *b = *(const struct a2dp_codec **)b_;
-	int ret;
-	if ((ret = a->dir - b->dir) != 0)
-		return ret;
-	if (a->codec_id >= A2DP_CODEC_VENDOR &&
-			b->codec_id >= A2DP_CODEC_VENDOR) {
-		const char *a_name = a2dp_codecs_codec_id_to_string(a->codec_id);
-		const char *b_name = a2dp_codecs_codec_id_to_string(b->codec_id);
-		return strcasecmp(a_name, b_name);
-	}
-	return a->codec_id - b->codec_id;
+static int a2dp_codec_id_cmp(uint16_t a, uint16_t b) {
+	if (a < A2DP_CODEC_VENDOR || b < A2DP_CODEC_VENDOR)
+		return a - b;
+	const char *a_name;
+	if ((a_name = a2dp_codecs_codec_id_to_string(a)) == NULL)
+		return 1;
+	const char *b_name;
+	if ((b_name = a2dp_codecs_codec_id_to_string(b)) == NULL)
+		return -1;
+	return strcasecmp(a_name, b_name);
 }
 
 /**
- * Sort A2DP codecs.
+ * Compare A2DP codecs.
  *
- * This function sorts A2DP codecs according to following rules:
- *  - sort codecs by A2DP direction
- *  - sort codecs by codec ID
- *  - sort vendor codecs alphabetically (case insensitive) */
-void a2dp_codecs_qsort(const struct a2dp_codec ** codecs, size_t nmemb) {
-	qsort(codecs, nmemb, sizeof(*codecs), a2dp_codecs_qsort_cmp);
+ * This function orders A2DP codecs according to following rules:
+ *  - order codecs by A2DP direction
+ *  - order codecs by codec ID
+ *  - order vendor codecs alphabetically (case insensitive) */
+int a2dp_codec_cmp(const struct a2dp_codec *a, const struct a2dp_codec *b) {
+	if (a->dir == b->dir)
+		return a2dp_codec_id_cmp(a->codec_id, b->codec_id);
+	return a->dir - b->dir;
+}
+
+/**
+ * Compare A2DP codecs. */
+int a2dp_codec_ptr_cmp(const struct a2dp_codec **a, const struct a2dp_codec **b) {
+	return a2dp_codec_cmp(*a, *b);
+}
+
+/**
+ * Compare A2DP SEPs. */
+int a2dp_sep_cmp(const struct a2dp_sep *a, const struct a2dp_sep *b) {
+	if (a->dir == b->dir)
+		return a2dp_codec_id_cmp(a->codec_id, b->codec_id);
+	return a->dir - b->dir;
 }
 
 /**
@@ -615,15 +642,21 @@ int a2dp_select_configuration(
 		return errno = EINVAL, -1;
 	}
 
+	a2dp_t tmp;
+	/* save original capabilities blob for later */
+	memcpy(&tmp, capabilities, size);
+
+	/* Narrow capabilities to values supported by BlueALSA. */
+	if (a2dp_filter_capabilities(codec, capabilities, size) != 0)
+		return -1;
+
 	switch (codec->codec_id) {
 	case A2DP_CODEC_SBC: {
-
 		a2dp_sbc_t *cap = capabilities;
-		unsigned int cap_chm = cap->channel_mode;
-		unsigned int cap_freq = cap->frequency;
 
+		const unsigned int cap_chm = cap->channel_mode;
 		if ((cap->channel_mode = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("SBC: No supported channel modes: %#x", cap_chm);
+			error("SBC: No supported channel modes: %#x", tmp.sbc.channel_mode);
 			goto fail;
 		}
 
@@ -632,72 +665,77 @@ int a2dp_select_configuration(
 			if (cap_chm & SBC_CHANNEL_MODE_DUAL_CHANNEL)
 				cap->channel_mode = SBC_CHANNEL_MODE_DUAL_CHANNEL;
 			else
-				warn("SBC XQ: Dual channel mode not supported: %#x", cap_chm);
+				warn("SBC XQ: Dual channel mode not supported: %#x", tmp.sbc.channel_mode);
 		}
 
+		const unsigned int cap_freq = cap->frequency;
 		if ((cap->frequency = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("SBC: No supported sampling frequencies: %#x", cap_freq);
+			error("SBC: No supported sampling frequencies: %#x", tmp.sbc.frequency);
 			goto fail;
 		}
 
-		if (cap->block_length & SBC_BLOCK_LENGTH_16)
+		const uint8_t cap_block_length = cap->block_length;
+		if (cap_block_length & SBC_BLOCK_LENGTH_16)
 			cap->block_length = SBC_BLOCK_LENGTH_16;
-		else if (cap->block_length & SBC_BLOCK_LENGTH_12)
+		else if (cap_block_length & SBC_BLOCK_LENGTH_12)
 			cap->block_length = SBC_BLOCK_LENGTH_12;
-		else if (cap->block_length & SBC_BLOCK_LENGTH_8)
+		else if (cap_block_length & SBC_BLOCK_LENGTH_8)
 			cap->block_length = SBC_BLOCK_LENGTH_8;
-		else if (cap->block_length & SBC_BLOCK_LENGTH_4)
+		else if (cap_block_length & SBC_BLOCK_LENGTH_4)
 			cap->block_length = SBC_BLOCK_LENGTH_4;
 		else {
-			error("SBC: No supported block lengths: %#x", cap->block_length);
+			error("SBC: No supported block lengths: %#x", tmp.sbc.block_length);
 			goto fail;
 		}
 
-		if (cap->subbands & SBC_SUBBANDS_8)
+		const uint8_t cap_subbands = cap->subbands;
+		if (cap_subbands & SBC_SUBBANDS_8)
 			cap->subbands = SBC_SUBBANDS_8;
-		else if (cap->subbands & SBC_SUBBANDS_4)
+		else if (cap_subbands & SBC_SUBBANDS_4)
 			cap->subbands = SBC_SUBBANDS_4;
 		else {
-			error("SBC: No supported sub-bands: %#x", cap->subbands);
+			error("SBC: No supported sub-bands: %#x", tmp.sbc.subbands);
 			goto fail;
 		}
 
-		if (cap->allocation_method & SBC_ALLOCATION_LOUDNESS)
+		const uint8_t cap_allocation_method = cap->allocation_method;
+		if (cap_allocation_method & SBC_ALLOCATION_LOUDNESS)
 			cap->allocation_method = SBC_ALLOCATION_LOUDNESS;
-		else if (cap->allocation_method & SBC_ALLOCATION_SNR)
+		else if (cap_allocation_method & SBC_ALLOCATION_SNR)
 			cap->allocation_method = SBC_ALLOCATION_SNR;
 		else {
-			error("SBC: No supported allocation method: %#x", cap->allocation_method);
+			error("SBC: No supported allocation method: %#x", tmp.sbc.allocation_method);
 			goto fail;
 		}
 
-		cap->min_bitpool = MAX(codec->capabilities.sbc.min_bitpool, cap->min_bitpool);
-		cap->max_bitpool = MIN(codec->capabilities.sbc.max_bitpool, cap->max_bitpool);
-
 		break;
 	}
 
 #if ENABLE_MPEG
 	case A2DP_CODEC_MPEG12: {
-
 		a2dp_mpeg_t *cap = capabilities;
-		unsigned int cap_chm = cap->channel_mode;
-		unsigned int cap_freq = cap->frequency;
 
-		if (cap->layer & MPEG_LAYER_MP3)
+		const uint8_t cap_layer = cap->layer;
+		if (cap_layer & MPEG_LAYER_MP3)
 			cap->layer = MPEG_LAYER_MP3;
+		else if (cap_layer & MPEG_LAYER_MP2)
+			cap->layer = MPEG_LAYER_MP2;
+		else if (cap_layer & MPEG_LAYER_MP1)
+			cap->layer = MPEG_LAYER_MP1;
 		else {
-			error("MPEG: No supported layer: %#x", cap->layer);
+			error("MPEG: No supported layer: %#x", tmp.mpeg.layer);
 			goto fail;
 		}
 
+		const unsigned int cap_chm = cap->channel_mode;
 		if ((cap->channel_mode = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("MPEG: No supported channel modes: %#x", cap_chm);
+			error("MPEG: No supported channel modes: %#x", tmp.mpeg.channel_mode);
 			goto fail;
 		}
 
+		const unsigned int cap_freq = cap->frequency;
 		if ((cap->frequency = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("MPEG: No supported sampling frequencies: %#x", cap_freq);
+			error("MPEG: No supported sampling frequencies: %#x", tmp.mpeg.frequency);
 			goto fail;
 		}
 
@@ -712,34 +750,34 @@ int a2dp_select_configuration(
 
 #if ENABLE_AAC
 	case A2DP_CODEC_MPEG24: {
-
 		a2dp_aac_t *cap = capabilities;
-		unsigned int cap_chm = cap->channels;
-		unsigned int cap_freq = AAC_GET_FREQUENCY(*cap);
 
-		if (cap->object_type & AAC_OBJECT_TYPE_MPEG4_AAC_SCA)
+		const uint8_t cap_object_type = cap->object_type;
+		if (cap_object_type & AAC_OBJECT_TYPE_MPEG4_AAC_SCA)
 			cap->object_type = AAC_OBJECT_TYPE_MPEG4_AAC_SCA;
-		else if (cap->object_type & AAC_OBJECT_TYPE_MPEG4_AAC_LTP)
+		else if (cap_object_type & AAC_OBJECT_TYPE_MPEG4_AAC_LTP)
 			cap->object_type = AAC_OBJECT_TYPE_MPEG4_AAC_LTP;
-		else if (cap->object_type & AAC_OBJECT_TYPE_MPEG4_AAC_LC)
+		else if (cap_object_type & AAC_OBJECT_TYPE_MPEG4_AAC_LC)
 			cap->object_type = AAC_OBJECT_TYPE_MPEG4_AAC_LC;
-		else if (cap->object_type & AAC_OBJECT_TYPE_MPEG2_AAC_LC)
+		else if (cap_object_type & AAC_OBJECT_TYPE_MPEG2_AAC_LC)
 			cap->object_type = AAC_OBJECT_TYPE_MPEG2_AAC_LC;
 		else {
-			error("AAC: No supported object type: %#x", cap->object_type);
+			error("AAC: No supported object type: %#x", tmp.aac.object_type);
 			goto fail;
 		}
 
+		const unsigned int cap_chm = cap->channels;
 		if ((cap->channels = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("AAC: No supported channels: %#x", cap_chm);
+			error("AAC: No supported channels: %#x", tmp.aac.channels);
 			goto fail;
 		}
 
 		unsigned int freq;
+		const unsigned int cap_freq = AAC_GET_FREQUENCY(*cap);
 		if ((freq = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) != 0)
 			AAC_SET_FREQUENCY(*cap, freq);
 		else {
-			error("AAC: No supported sampling frequencies: %#x", cap_freq);
+			error("AAC: No supported sampling frequencies: %#x", AAC_GET_FREQUENCY(tmp.aac));
 			goto fail;
 		}
 
@@ -759,18 +797,17 @@ int a2dp_select_configuration(
 
 #if ENABLE_APTX
 	case A2DP_CODEC_VENDOR_APTX: {
-
 		a2dp_aptx_t *cap = capabilities;
-		unsigned int cap_chm = cap->channel_mode;
-		unsigned int cap_freq = cap->frequency;
 
+		const unsigned int cap_chm = cap->channel_mode;
 		if ((cap->channel_mode = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("apt-X: No supported channel modes: %#x", cap_chm);
+			error("apt-X: No supported channel modes: %#x", tmp.aptx.channel_mode);
 			goto fail;
 		}
 
+		const unsigned int cap_freq = cap->frequency;
 		if ((cap->frequency = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("apt-X: No supported sampling frequencies: %#x", cap_freq);
+			error("apt-X: No supported sampling frequencies: %#x", tmp.aptx.frequency);
 			goto fail;
 		}
 
@@ -780,18 +817,17 @@ int a2dp_select_configuration(
 
 #if ENABLE_APTX_HD
 	case A2DP_CODEC_VENDOR_APTX_HD: {
-
 		a2dp_aptx_hd_t *cap = capabilities;
-		unsigned int cap_chm = cap->aptx.channel_mode;
-		unsigned int cap_freq = cap->aptx.frequency;
 
+		const unsigned int cap_chm = cap->aptx.channel_mode;
 		if ((cap->aptx.channel_mode = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("apt-X HD: No supported channel modes: %#x", cap_chm);
+			error("apt-X HD: No supported channel modes: %#x", tmp.aptx_hd.aptx.channel_mode);
 			goto fail;
 		}
 
+		const unsigned int cap_freq = cap->aptx.frequency;
 		if ((cap->aptx.frequency = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("apt-X HD: No supported sampling frequencies: %#x", cap_freq);
+			error("apt-X HD: No supported sampling frequencies: %#x", tmp.aptx_hd.aptx.frequency);
 			goto fail;
 		}
 
@@ -801,23 +837,24 @@ int a2dp_select_configuration(
 
 #if ENABLE_FASTSTREAM
 	case A2DP_CODEC_VENDOR_FASTSTREAM: {
-
 		a2dp_faststream_t *cap = capabilities;
-		unsigned int cap_freq = cap->frequency_music;
-		unsigned int cap_freq_bc = cap->frequency_voice;
 
+		const unsigned int cap_freq = cap->frequency_music;
 		if ((cap->frequency_music = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("FastStream: No supported sampling frequencies: %#x", cap_freq);
+			error("FastStream: No supported sampling frequencies: %#x",
+					tmp.faststream.frequency_music);
 			goto fail;
 		}
 
+		const unsigned int cap_freq_bc = cap->frequency_voice;
 		if ((cap->frequency_voice = a2dp_codec_select_sampling_freq(codec, cap_freq_bc, true)) == 0) {
-			error("FastStream: No supported back-channel sampling frequencies: %#x", cap_freq_bc);
+			error("FastStream: No supported back-channel sampling frequencies: %#x",
+					tmp.faststream.frequency_voice);
 			goto fail;
 		}
 
 		if ((cap->direction & (FASTSTREAM_DIRECTION_MUSIC | FASTSTREAM_DIRECTION_VOICE)) == 0) {
-			error("FastStream: No supported directions: %#x", cap->direction);
+			error("FastStream: No supported directions: %#x", tmp.faststream.direction);
 		}
 
 		break;
@@ -826,32 +863,33 @@ int a2dp_select_configuration(
 
 #if ENABLE_LC3PLUS
 	case A2DP_CODEC_VENDOR_LC3PLUS: {
-
 		a2dp_lc3plus_t *cap = capabilities;
-		unsigned int cap_chm = cap->channels;
-		unsigned int cap_freq = LC3PLUS_GET_FREQUENCY(*cap);
 
-		if (cap->frame_duration & LC3PLUS_FRAME_DURATION_100)
+		const uint8_t cap_frame_duration = cap->frame_duration;
+		if (cap_frame_duration & LC3PLUS_FRAME_DURATION_100)
 			cap->frame_duration = LC3PLUS_FRAME_DURATION_100;
-		else if (cap->frame_duration & LC3PLUS_FRAME_DURATION_050)
+		else if (cap_frame_duration & LC3PLUS_FRAME_DURATION_050)
 			cap->frame_duration = LC3PLUS_FRAME_DURATION_050;
-		else if (cap->frame_duration & LC3PLUS_FRAME_DURATION_025)
+		else if (cap_frame_duration & LC3PLUS_FRAME_DURATION_025)
 			cap->frame_duration = LC3PLUS_FRAME_DURATION_025;
 		else {
-			error("LC3plus: No supported frame duration: %#x", cap->frame_duration);
+			error("LC3plus: No supported frame duration: %#x", tmp.lc3plus.frame_duration);
 			goto fail;
 		}
 
+		const unsigned int cap_chm = cap->channels;
 		if ((cap->channels = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("LC3plus: No supported channels: %#x", cap_chm);
+			error("LC3plus: No supported channels: %#x", tmp.lc3plus.channels);
 			goto fail;
 		}
 
 		unsigned int freq;
+		const unsigned int cap_freq = LC3PLUS_GET_FREQUENCY(*cap);
 		if ((freq = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) != 0)
 			LC3PLUS_SET_FREQUENCY(*cap, freq);
 		else {
-			error("LC3plus: No supported sampling frequencies: %#x", cap_freq);
+			error("LC3plus: No supported sampling frequencies: %#x",
+					LC3PLUS_GET_FREQUENCY(tmp.lc3plus));
 			goto fail;
 		}
 
@@ -861,18 +899,17 @@ int a2dp_select_configuration(
 
 #if ENABLE_LDAC
 	case A2DP_CODEC_VENDOR_LDAC: {
-
 		a2dp_ldac_t *cap = capabilities;
-		unsigned int cap_chm = cap->channel_mode;
-		unsigned int cap_freq = cap->frequency;
 
+		const unsigned int cap_chm = cap->channel_mode;
 		if ((cap->channel_mode = a2dp_codec_select_channel_mode(codec, cap_chm, false)) == 0) {
-			error("LDAC: No supported channel modes: %#x", cap_chm);
+			error("LDAC: No supported channel modes: %#x", tmp.ldac.channel_mode);
 			goto fail;
 		}
 
+		const unsigned int cap_freq = cap->frequency;
 		if ((cap->frequency = a2dp_codec_select_sampling_freq(codec, cap_freq, false)) == 0) {
-			error("LDAC: No supported sampling frequencies: %#x", cap_freq);
+			error("LDAC: No supported sampling frequencies: %#x", tmp.ldac.frequency);
 			goto fail;
 		}
 
diff --git a/src/a2dp.h b/src/a2dp.h
index 465c7c6..b803edb 100644
--- a/src/a2dp.h
+++ b/src/a2dp.h
@@ -85,7 +85,9 @@ extern struct a2dp_codec * const a2dp_codecs[];
 
 int a2dp_codecs_init(void);
 
-void a2dp_codecs_qsort(const struct a2dp_codec ** codecs, size_t nmemb);
+int a2dp_codec_cmp(const struct a2dp_codec *a, const struct a2dp_codec *b);
+int a2dp_codec_ptr_cmp(const struct a2dp_codec **a, const struct a2dp_codec **b);
+int a2dp_sep_cmp(const struct a2dp_sep *a, const struct a2dp_sep *b);
 
 const struct a2dp_codec *a2dp_codec_lookup(
 		uint16_t codec_id,
diff --git a/src/asound/20-bluealsa.conf b/src/asound/20-bluealsa.conf.in
similarity index 72%
rename from src/asound/20-bluealsa.conf
rename to src/asound/20-bluealsa.conf.in
index 46b009f..a355147 100644
--- a/src/asound/20-bluealsa.conf
+++ b/src/asound/20-bluealsa.conf.in
@@ -15,14 +15,23 @@ defaults.bluealsa.volume "unchanged"
 # By default do not modify the software volume state
 # when a PCM is opened.
 defaults.bluealsa.softvol "unchanged"
+# If Bluetooth sink device (e.g. headphones) supports
+# A2DP v1.3 or later it will report delay by itself,
+# so there is no need to set the delay manually.
 defaults.bluealsa.delay 0
 defaults.bluealsa.service "org.bluealsa"
 # Default for mixer is to show all PCMs
 defaults.bluealsa.ctl.device "FF:FF:FF:FF:FF:FF"
-defaults.bluealsa.ctl.battery "yes"
+# By default do not show additional controls. It is advised to
+# read the documentation for "bluealsa" control plugin before
+# enabling this option.
+defaults.bluealsa.ctl.extended "no"
+defaults.bluealsa.ctl.battery "no"
+defaults.bluealsa.ctl.bttransport "no"
+defaults.bluealsa.ctl.dynamic "yes"
 
 ctl.bluealsa {
-	@args [ DEV BAT SRV ]
+	@args [ DEV EXT BAT BTT DYN SRV ]
 	@args.DEV {
 		type string
 		default {
@@ -30,6 +39,13 @@ ctl.bluealsa {
 			name defaults.bluealsa.ctl.device
 		}
 	}
+	@args.EXT {
+		type string
+		default {
+			@func refer
+			name defaults.bluealsa.ctl.extended
+		}
+	}
 	@args.BAT {
 		type string
 		default {
@@ -37,6 +53,20 @@ ctl.bluealsa {
 			name defaults.bluealsa.ctl.battery
 		}
 	}
+	@args.BTT {
+		type string
+		default {
+			@func refer
+			name defaults.bluealsa.ctl.bttransport
+		}
+	}
+	@args.DYN {
+		type string
+		default {
+			@func refer
+			name defaults.bluealsa.ctl.dynamic
+		}
+	}
 	@args.SRV {
 		type string
 		default {
@@ -46,7 +76,10 @@ ctl.bluealsa {
 	}
 	type bluealsa
 	device $DEV
+	extended $EXT
 	battery $BAT
+	bttransport $BTT
+	dynamic $DYN
 	service $SRV
 	hint {
 		show {
@@ -124,6 +157,6 @@ pcm.bluealsa {
 			@func refer
 			name defaults.namehint.extended
 		}
-		description "Bluetooth Audio Hub"
+		description "Bluetooth Audio"
 	}
 }
diff --git a/src/asound/Makefile.am b/src/asound/Makefile.am
index ab1275a..4b641f1 100644
--- a/src/asound/Makefile.am
+++ b/src/asound/Makefile.am
@@ -1,7 +1,5 @@
 # BlueALSA - Makefile.am
-# Copyright (c) 2016-2021 Arkadiusz Bokowy
-
-EXTRA_DIST = 20-bluealsa.conf
+# Copyright (c) 2016-2023 Arkadiusz Bokowy
 
 asound_module_ctl_LTLIBRARIES = libasound_module_ctl_bluealsa.la
 asound_module_pcm_LTLIBRARIES = libasound_module_pcm_bluealsa.la
@@ -40,3 +38,9 @@ libasound_module_pcm_bluealsa_la_LIBADD = \
 	@ALSA_LIBS@ \
 	@DBUS1_LIBS@ \
 	@LIBUNWIND_LIBS@
+
+SUFFIXES = .conf.in .conf
+MOSTLYCLEANFILES = $(asound_module_conf_DATA)
+
+.conf.in.conf:
+	$(SED) -e '' < $< > $@
diff --git a/src/asound/bluealsa-ctl.c b/src/asound/bluealsa-ctl.c
index c67654d..aa1883a 100644
--- a/src/asound/bluealsa-ctl.c
+++ b/src/asound/bluealsa-ctl.c
@@ -32,26 +32,56 @@
 #include "shared/dbus-client.h"
 #include "shared/defs.h"
 
+/**
+ * Control element type.
+ *
+ * Note: The order of enum values is important - it
+ *       determines control elements ordering. */
 enum ctl_elem_type {
-	CTL_ELEM_TYPE_BATTERY,
 	CTL_ELEM_TYPE_SWITCH,
 	CTL_ELEM_TYPE_VOLUME,
+	CTL_ELEM_TYPE_SOFT_VOLUME,
+	CTL_ELEM_TYPE_CODEC,
+	CTL_ELEM_TYPE_BATTERY,
 };
 
+/**
+ * Control element. */
 struct ctl_elem {
 	enum ctl_elem_type type;
 	struct bt_dev *dev;
 	struct ba_pcm *pcm;
-	char name[44 /* internal ALSA constraint */ + 1];
+	/* element ID exposed by ALSA */
+	int numid;
+	char name[44 /* internal ALSA constraint */];
+	unsigned int index;
+	/* codec list for codec control element */
+	struct ba_pcm_codecs codecs;
 	/* if true, element is a playback control */
 	bool playback;
+	/* For single device mode, if true then the associated profile is connected.
+	 * If false, the element value is zero, and writes are ignored. */
+	bool active;
 };
 
 struct ctl_elem_update {
+	/* PCM associated with the element being updated. This pointer shall not
+	 * be dereferenced, because it might point to already freed memory region. */
+	const struct ba_pcm *pcm;
+	/* the ID of the element */
+	int numid;
+	/* the name of the element being updated */
 	char name[sizeof(((struct ctl_elem *)0)->name)];
+	/* index of the element being updated */
+	unsigned int index;
 	int event_mask;
 };
 
+#define BT_DEV_MASK_NONE   (0)
+#define BT_DEV_MASK_ADD    (1 << 0)
+#define BT_DEV_MASK_REMOVE (1 << 1)
+#define BT_DEV_MASK_UPDATE (1 << 2)
+
 struct bt_dev {
 	char device_path[sizeof(((struct ba_pcm *)0)->device_path)];
 	char rfcomm_path[sizeof(((struct ba_pcm *)0)->device_path)];
@@ -71,7 +101,7 @@ struct bluealsa_ctl {
 	size_t dev_list_size;
 
 	/* list of all BlueALSA PCMs */
-	struct ba_pcm *pcm_list;
+	struct ba_pcm **pcm_list;
 	size_t pcm_list_size;
 
 	/* list of ALSA control elements */
@@ -83,19 +113,28 @@ struct bluealsa_ctl {
 	size_t elem_update_list_size;
 	size_t elem_update_event_i;
 
-	/* Disconnection event pipe.
-	 * This allows us to generate a POLLERR event by closing the read end
-	 * then polling the write end. No actual reads or writes are performed
-	 * on this pipe, so no risk of SIGPIPE.
+	/* Event pipe. Allows us to trigger events internally and to generate a
+	 * POLLERR event by closing the read end then polling the write end.
 	 * Many applications (including alsamixer) interpret POLLERR as
 	 * indicating the mixer device has been disconnected. */
 	int pipefd[2];
 
 	/* if true, show battery meter */
 	bool show_battery;
+	/* if true, append BT transport type to element names */
+	bool show_bt_transport;
+	/* if true, show additional controls */
+	bool show_extended;
 	/* if true, this mixer is for a single Bluetooth device */
 	bool single_device;
+	/* if true, this mixer adds/removes controls dynamically */
+	bool dynamic;
+
+};
 
+static const char *soft_volume_names[] = {
+	"pass-through",
+	"software",
 };
 
 static int str2bdaddr(const char *str, bdaddr_t *ba) {
@@ -124,10 +163,29 @@ static int bluealsa_elem_cmp(const void *p1, const void *p2) {
 	const struct ctl_elem *e2 = (const struct ctl_elem *)p2;
 	int rv;
 
-	if ((rv = strcmp(e1->name, e2->name)) == 0)
-		rv = bacmp(&e1->pcm->addr, &e2->pcm->addr);
+	/* Sort elements by device names. In case were names
+	 * are the same sort by device addresses. */
+	if ((rv = bacmp(&e1->pcm->addr, &e2->pcm->addr)) != 0) {
+		const int dev_rv = strcmp(e1->dev->name, e2->dev->name);
+		return dev_rv != 0 ? dev_rv : rv;
+	}
 
-	return rv;
+	/* Within a single device order elements by:
+	 *  - PCM transport type
+	 *  - playback/capture (if applicable)
+	 *  - element type
+	 * */
+	if ((rv = e1->pcm->transport - e2->pcm->transport))
+		return rv;
+	if (!(e1->type == CTL_ELEM_TYPE_CODEC ||
+				e1->type == CTL_ELEM_TYPE_BATTERY ||
+				e2->type == CTL_ELEM_TYPE_CODEC ||
+				e2->type == CTL_ELEM_TYPE_BATTERY))
+		if ((rv = e1->playback - e2->playback) != 0)
+			return -rv;
+	if ((rv = e1->type - e2->type) != 0)
+		return rv;
+	return 0;
 }
 
 static DBusMessage *bluealsa_dbus_get_property(DBusConnection *conn,
@@ -213,12 +271,38 @@ static int bluealsa_dev_fetch_battery(struct bluealsa_ctl *ctl, struct bt_dev *d
 	dbus_message_iter_init(rep, &iter);
 	dbus_message_iter_recurse(&iter, &iter_val);
 
-	char battery;
-	dbus_message_iter_get_basic(&iter_val, &battery);
-	dev->battery_level = battery;
+	signed char level;
+	dbus_message_iter_get_basic(&iter_val, &level);
+	dev->battery_level = level;
 
 	dbus_message_unref(rep);
-	return battery;
+	return level;
+}
+
+static int bluealsa_pcm_fetch_codecs(struct bluealsa_ctl *ctl, struct ba_pcm *pcm,
+		struct ba_pcm_codecs *codecs) {
+
+	codecs->codecs = NULL;
+	codecs->codecs_len = 0;
+
+	/* Note: We are not checking for errors when calling this function. Failure
+	 *       most likely means that the PCM for which we are fetching codecs is
+	 *       already removed by the BlueALSA server. It will happen when server
+	 *       removes PCM but ALSA control plug-in was not yet able to process
+	 *       elem remove event. */
+	bluealsa_dbus_pcm_get_codecs(&ctl->dbus_ctx, pcm->pcm_path, codecs, NULL);
+
+	/* If the list of codecs could not be fetched, return currently
+	 * selected codec as the only one. This will at least allow the
+	 * user to see the currently selected codec. */
+	if (codecs->codecs_len == 0) {
+		if ((codecs->codecs = malloc(sizeof(*codecs->codecs))) == NULL)
+			return -1;
+		memcpy(codecs->codecs, &pcm->codec, sizeof(*codecs->codecs));
+		codecs->codecs_len = 1;
+	}
+
+	return codecs->codecs_len;
 }
 
 /**
@@ -263,49 +347,172 @@ static struct bt_dev *bluealsa_dev_get(struct bluealsa_ctl *ctl, const struct ba
 	return dev;
 }
 
+static ssize_t bluealsa_pipefd_ping(struct bluealsa_ctl *ctl) {
+	char ping = 1;
+	return write(ctl->pipefd[1], &ping, sizeof(ping));
+}
+
+static ssize_t bluealsa_pipefd_flush(struct bluealsa_ctl *ctl) {
+	char buffer[16];
+	return read(ctl->pipefd[0], buffer, sizeof(buffer));
+}
+
+static int bluealsa_elem_update_list_add(struct bluealsa_ctl *ctl,
+		const struct ctl_elem *elem, unsigned int mask) {
+
+	struct ctl_elem_update *tmp = ctl->elem_update_list;
+	if ((tmp = realloc(tmp, (ctl->elem_update_list_size + 1) * sizeof(*tmp))) == NULL)
+		return -1;
+
+	tmp[ctl->elem_update_list_size].numid = elem->numid;
+	tmp[ctl->elem_update_list_size].pcm = elem->pcm;
+	tmp[ctl->elem_update_list_size].event_mask = mask;
+	*stpncpy(tmp[ctl->elem_update_list_size].name, elem->name,
+			sizeof(tmp[ctl->elem_update_list_size].name) - 1) = '\0';
+	tmp[ctl->elem_update_list_size].index = elem->index;
+
+	ctl->elem_update_list = tmp;
+	ctl->elem_update_list_size++;
+	return 0;
+}
+
+#define bluealsa_event_elem_added(ctl, elem) \
+	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_ADD)
+#define bluealsa_event_elem_removed(ctl, elem) \
+	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_REMOVE)
+#define bluealsa_event_elem_updated(ctl, elem) \
+	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_VALUE)
+
+/**
+ * Add new PCM to the list of known PCMs. */
 static int bluealsa_pcm_add(struct bluealsa_ctl *ctl, const struct ba_pcm *pcm) {
-	struct ba_pcm *tmp = ctl->pcm_list;
-	if ((tmp = realloc(tmp, (ctl->pcm_list_size + 1) * sizeof(*tmp))) == NULL)
+	struct ba_pcm **list = ctl->pcm_list;
+	const size_t list_size = ctl->pcm_list_size;
+	if ((list = realloc(list, (list_size + 1) * sizeof(*list))) == NULL)
+		return -1;
+	ctl->pcm_list = list;
+	if ((list[list_size] = malloc(sizeof(*list[list_size]))) == NULL)
 		return -1;
-	memcpy(&tmp[ctl->pcm_list_size++], pcm, sizeof(*tmp));
-	ctl->pcm_list = tmp;
+	memcpy(list[list_size], pcm, sizeof(*list[list_size]));
+	ctl->pcm_list_size++;
 	return 0;
 }
 
+/**
+ * Remove PCM from the list of known PCMs. */
 static int bluealsa_pcm_remove(struct bluealsa_ctl *ctl, const char *path) {
 	size_t i;
 	for (i = 0; i < ctl->pcm_list_size; i++)
-		if (strcmp(ctl->pcm_list[i].pcm_path, path) == 0)
-			memcpy(&ctl->pcm_list[i], &ctl->pcm_list[--ctl->pcm_list_size], sizeof(*ctl->pcm_list));
+		if (strcmp(ctl->pcm_list[i]->pcm_path, path) == 0) {
+
+			/* clear all pending events associated with removed PCM */
+			for (size_t ii = 0; ii < ctl->elem_update_list_size; ii++)
+				if (ctl->elem_update_list[ii].pcm == ctl->pcm_list[i])
+					ctl->elem_update_list[ii].event_mask = 0;
+
+			/* remove PCM from the list */
+			free(ctl->pcm_list[i]);
+			ctl->pcm_list[i] = ctl->pcm_list[--ctl->pcm_list_size];
+
+		}
 	return 0;
 }
 
+static int bluealsa_pcm_activate(struct bluealsa_ctl *ctl, const struct ba_pcm *pcm) {
+	size_t i;
+	for (i = 0; i < ctl->pcm_list_size; i++)
+		if (strcmp(ctl->pcm_list[i]->pcm_path, pcm->pcm_path) == 0) {
+
+			/* update potentially stalled PCM data */
+			memcpy(ctl->pcm_list[i], pcm, sizeof(*ctl->pcm_list[i]));
+
+			size_t el;
+			/* activate associated elements */
+			for (el = 0; el < ctl->elem_list_size; el++)
+				if (ctl->elem_list[el].pcm == ctl->pcm_list[i]) {
+					ctl->elem_list[el].active = true;
+					bluealsa_event_elem_updated(ctl, &ctl->elem_list[el]);
+				}
+
+			break;
+		}
+	return 0;
+}
+
+static int bluealsa_pcm_deactivate(struct bluealsa_ctl *ctl, const char *path) {
+	size_t i;
+	for (i = 0; i < ctl->elem_list_size; i++)
+		if (strcmp(ctl->elem_list[i].pcm->pcm_path, path) == 0) {
+			ctl->elem_list[i].active = false;
+			bluealsa_event_elem_updated(ctl, &ctl->elem_list[i]);
+		}
+	return 0;
+}
+
+static const char *transport2str(unsigned int transport) {
+	switch (transport) {
+	case BA_PCM_TRANSPORT_A2DP_SOURCE:
+		return "-SRC";
+	case BA_PCM_TRANSPORT_A2DP_SINK:
+		return "-SNK";
+	case BA_PCM_TRANSPORT_HFP_AG:
+		return "-HFP-AG";
+	case BA_PCM_TRANSPORT_HFP_HF:
+		return "-HFP-HF";
+	case BA_PCM_TRANSPORT_HSP_AG:
+		return "-HSP-AG";
+	case BA_PCM_TRANSPORT_HSP_HS:
+		return "-HSP-HS";
+	default:
+		return "";
+	}
+}
+
 /**
  * Update element name based on given string and PCM type.
  *
+ * @param ctl The BlueALSA controller context.
  * @param elem An address to the element structure.
  * @param name A string which should be used as a base for the element name. May
  *   be NULL if no base prefix is required.
- * @param id An unique ID number. If the ID is other than -1, it will be
- *   attached to the element name in order to prevent duplications. */
-static void bluealsa_elem_set_name(struct ctl_elem *elem, const char *name, int id) {
+ * @param with_device_id If true, Bluetooth device ID number will be attached
+ *   to the element name in order to prevent duplications. */
+static void bluealsa_elem_set_name(struct bluealsa_ctl *ctl, struct ctl_elem *elem,
+		const char *name, bool with_device_id) {
+
+	const char *transport = "";
+	if (ctl->show_bt_transport)
+		transport = transport2str(elem->pcm->transport);
 
 	if (name != NULL) {
 		/* multi-device mixer - include device alias in control names */
 
 		const int name_len = strlen(name);
+		/* max name length with reserved space for ALSA suffix */
 		int len = sizeof(elem->name) - 16 - 1;
 		char no[16] = "";
 
-		if (id != -1) {
-			sprintf(no, " #%u", id);
+		if (with_device_id) {
+			sprintf(no, " #%u", bluealsa_dev_get_id(ctl, elem->pcm));
 			len -= strlen(no);
 		}
 
+		/* get the longest possible element label */
+		int label_max_len = sizeof(" A2DP") - 1;
+		if (ctl->show_bt_transport)
+			label_max_len = sizeof(" SCO-HFP-AG") - 1;
+		if (ctl->show_extended)
+			label_max_len += sizeof(" Mode") - 1;
+		if (ctl->show_battery)
+			label_max_len = MAX(label_max_len, sizeof(" | Battery") - 1);
+
+		/* Reserve space for the longest element type description. This applies
+		 * to all elements so the shortened device name will be consistent. */
+		len = MIN(len - label_max_len, name_len);
+		while (isspace(name[len - 1]))
+			len--;
+
 		if (elem->type == CTL_ELEM_TYPE_BATTERY) {
-			len = MIN(len - 10, name_len);
-			while (isspace(name[len - 1]))
-				len--;
 			sprintf(elem->name, "%.*s%s | Battery", len, name, no);
 		}
 		else {
@@ -313,19 +520,13 @@ static void bluealsa_elem_set_name(struct ctl_elem *elem, const char *name, int
 			switch (elem->pcm->transport) {
 			case BA_PCM_TRANSPORT_A2DP_SOURCE:
 			case BA_PCM_TRANSPORT_A2DP_SINK:
-				len = MIN(len - 7, name_len);
-				while (isspace(name[len - 1]))
-					len--;
-				sprintf(elem->name, "%.*s%s - A2DP", len, name, no);
+				sprintf(elem->name, "%.*s%s A2DP%s", len, name, no, transport);
 				break;
 			case BA_PCM_TRANSPORT_HFP_AG:
 			case BA_PCM_TRANSPORT_HFP_HF:
 			case BA_PCM_TRANSPORT_HSP_AG:
 			case BA_PCM_TRANSPORT_HSP_HS:
-				len = MIN(len - 6, name_len);
-				while (isspace(name[len - 1]))
-					len--;
-				sprintf(elem->name, "%.*s%s - SCO", len, name, no);
+				sprintf(elem->name, "%.*s%s SCO%s", len, name, no, transport);
 				break;
 			}
 		}
@@ -338,21 +539,29 @@ static void bluealsa_elem_set_name(struct ctl_elem *elem, const char *name, int
 			switch (elem->pcm->transport) {
 			case BA_PCM_TRANSPORT_A2DP_SOURCE:
 			case BA_PCM_TRANSPORT_A2DP_SINK:
-				strcpy(elem->name, "A2DP");
+				sprintf(elem->name, "A2DP%s", transport);
 				break;
 			case BA_PCM_TRANSPORT_HFP_AG:
 			case BA_PCM_TRANSPORT_HFP_HF:
 			case BA_PCM_TRANSPORT_HSP_AG:
 			case BA_PCM_TRANSPORT_HSP_HS:
-				strcpy(elem->name, "SCO");
+				sprintf(elem->name, "SCO%s", transport);
 				break;
 			}
 	}
 
-	/* ALSA library determines the element type by checking it's
-	 * name suffix. This feature is not well documented, though. */
+	if (elem->type == CTL_ELEM_TYPE_CODEC)
+		strcat(elem->name, " Codec");
 
-	strcat(elem->name, elem->playback ? " Playback" : " Capture");
+	if (elem->type == CTL_ELEM_TYPE_SOFT_VOLUME)
+		strcat(elem->name, " Mode");
+
+	/* ALSA library determines the element type by checking it's
+	 * name suffix. This feature is not well documented, though.
+	 * A codec control is 'Global' (i.e. neither 'Playback' nor
+	 * 'Capture') so we omit the suffix in that case. */
+	if (elem->type != CTL_ELEM_TYPE_CODEC)
+		strcat(elem->name, elem->playback ? " Playback" : " Capture");
 
 	switch (elem->type) {
 	case CTL_ELEM_TYPE_SWITCH:
@@ -362,10 +571,119 @@ static void bluealsa_elem_set_name(struct ctl_elem *elem, const char *name, int
 	case CTL_ELEM_TYPE_VOLUME:
 		strcat(elem->name, " Volume");
 		break;
+	case CTL_ELEM_TYPE_CODEC:
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		strcat(elem->name, " Enum");
+		break;
 	}
 
 }
 
+/**
+ * Create control elements for a given PCM.
+ *
+ * @param ctl The BlueALSA controller context.
+ * @param elem_list An address to the array of element structures. This array
+ *   must have sufficient space for new elements which includes volume element,
+ *   switch element and optional battery indicator element.
+ * @param dev The BT device associated with created elements.
+ * @param pcm The BlueALSA PCM associated with created elements.
+ * @param codecs The list of available PCM codecs. If not empty, additional
+ *   control element for codec selection will be created. The ownership of
+ *   the codec list structure is transferred to associated control element.
+ * @param add_battery_elem If true, add battery level indicator element.
+ * @return The number of elements added. */
+static size_t bluealsa_elem_list_add_pcm_elems(struct bluealsa_ctl *ctl,
+		struct ctl_elem *elem_list, struct bt_dev *dev, struct ba_pcm *pcm,
+		struct ba_pcm_codecs *codecs, bool add_battery_elem) {
+
+	const char *name = ctl->single_device ? NULL : dev->name;
+	const bool playback = pcm->mode == BA_PCM_MODE_SINK;
+	size_t n = 0;
+
+	elem_list[n].type = CTL_ELEM_TYPE_VOLUME;
+	elem_list[n].dev = dev;
+	elem_list[n].pcm = pcm;
+	elem_list[n].playback = playback;
+	elem_list[n].active = true;
+	bluealsa_elem_set_name(ctl, &elem_list[n], name, false);
+	elem_list[n].index = 0;
+
+	n++;
+
+	elem_list[n].type = CTL_ELEM_TYPE_SWITCH;
+	elem_list[n].dev = dev;
+	elem_list[n].pcm = pcm;
+	elem_list[n].playback = playback;
+	elem_list[n].active = true;
+	bluealsa_elem_set_name(ctl, &elem_list[n], name, false);
+	elem_list[n].index = 0;
+
+	n++;
+
+	/* add special "codec" element */
+	if (codecs->codecs_len > 0) {
+		elem_list[n].type = CTL_ELEM_TYPE_CODEC;
+		elem_list[n].dev = dev;
+		elem_list[n].pcm = pcm;
+		elem_list[n].playback = playback;
+		elem_list[n].active = true;
+		memcpy(&elem_list[n].codecs, codecs, sizeof(elem_list[n].codecs));
+		bluealsa_elem_set_name(ctl, &elem_list[n], name, false);
+		elem_list[n].index = 0;
+
+		n++;
+	}
+
+	/* add special "software volume" element */
+	if (ctl->show_extended) {
+		elem_list[n].type = CTL_ELEM_TYPE_SOFT_VOLUME;
+		elem_list[n].dev = dev;
+		elem_list[n].pcm = pcm;
+		elem_list[n].playback = playback;
+		elem_list[n].active = true;
+		bluealsa_elem_set_name(ctl, &elem_list[n], name, false);
+
+		/* ALSA library permits only one enumeration type control for
+		 * each simple control id. So we use different index numbers
+		 * for capture and playback to get different ids. */
+		elem_list[n].index = playback ? 0 : 1;
+
+		n++;
+	}
+
+	/* add special battery level indicator element */
+	if (add_battery_elem &&
+			dev->battery_level != -1 &&
+			/* There has to be attached some PCM to an element structure. Since
+			 * battery level is set only when SCO profile is connected (battery
+			 * requires RFCOMM), for simplicity and convenience, we will bind
+			 * battery element with SCO sink PCM. */
+			pcm->transport & BA_PCM_TRANSPORT_MASK_SCO &&
+			pcm->mode == BA_PCM_MODE_SINK) {
+		elem_list[n].type = CTL_ELEM_TYPE_BATTERY;
+		elem_list[n].dev = dev;
+		elem_list[n].pcm = pcm;
+		elem_list[n].playback = true;
+		elem_list[n].active = true;
+		bluealsa_elem_set_name(ctl, &elem_list[n], name, false);
+		elem_list[n].index = 0;
+
+		n++;
+	}
+
+	return n;
+}
+
+static bool elem_list_dev_has_battery_elem(const struct ctl_elem *elem_list,
+		size_t elem_list_size, const struct bt_dev *dev) {
+	for (size_t i = 0; i < elem_list_size; i++)
+		if (elem_list[i].type == CTL_ELEM_TYPE_BATTERY &&
+				elem_list[i].dev == dev)
+			return true;
+	return false;
+}
+
 static int bluealsa_create_elem_list(struct bluealsa_ctl *ctl) {
 
 	size_t count = 0;
@@ -381,7 +699,11 @@ static int bluealsa_create_elem_list(struct bluealsa_ctl *ctl) {
 			/* It is possible, that BT device battery level will be exposed via
 			 * RFCOMM interface, so in order to account for a special "battery"
 			 * element we have to increment our element counter by one. */
-			count += 1;
+			if (ctl->show_battery)
+				count += 1;
+			/* If extended controls are enabled, we need additional elements. */
+			if (ctl->show_extended)
+				count += 2;
 		}
 
 		if ((elem_list = realloc(elem_list, count * sizeof(*elem_list))) == NULL)
@@ -392,84 +714,89 @@ static int bluealsa_create_elem_list(struct bluealsa_ctl *ctl) {
 	/* Clear device mask, so we can distinguish currently used and unused (old)
 	 * device entries - we are not invalidating device list after PCM remove. */
 	for (i = 0; i < ctl->dev_list_size; i++)
-		ctl->dev_list[i]->mask = 0;
+		ctl->dev_list[i]->mask = BT_DEV_MASK_NONE;
 
 	count = 0;
 
 	/* Construct control elements based on available PCMs. */
 	for (i = 0; i < ctl->pcm_list_size; i++) {
 
-		struct ba_pcm *pcm = &ctl->pcm_list[i];
+		struct ba_pcm *pcm = ctl->pcm_list[i];
 		struct bt_dev *dev = bluealsa_dev_get(ctl, pcm);
-		const char *name = ctl->single_device ? NULL : dev->name;
-
-		elem_list[count].type = CTL_ELEM_TYPE_VOLUME;
-		elem_list[count].dev = dev;
-		elem_list[count].pcm = pcm;
-		elem_list[count].playback = pcm->mode == BA_PCM_MODE_SINK;
-		bluealsa_elem_set_name(&elem_list[count], name, -1);
-
-		count++;
-
-		elem_list[count].type = CTL_ELEM_TYPE_SWITCH;
-		elem_list[count].dev = dev;
-		elem_list[count].pcm = pcm;
-		elem_list[count].playback = pcm->mode == BA_PCM_MODE_SINK;
-		bluealsa_elem_set_name(&elem_list[count], name, -1);
-
-		count++;
-
-		/* Try to add special "battery" element. */
-		if (ctl->show_battery && dev->battery_level == -1 &&
-				bluealsa_dev_fetch_battery(ctl, dev) != -1) {
-			elem_list[count].type = CTL_ELEM_TYPE_BATTERY;
-			elem_list[count].dev = dev;
-			elem_list[count].pcm = pcm;
-			elem_list[count].playback = true;
-			bluealsa_elem_set_name(&elem_list[count], name, -1);
-
-			count++;
+		struct ba_pcm_codecs codecs = { 0 };
+		bool add_battery_elem = false;
+
+		/* If Bluetooth transport is bi-directional it must have the same codec
+		 * for both sink and source. In case of such profiles we will only add
+		 * the codec control element for the main stream direction. */
+		if (ctl->show_extended && (
+					BA_PCM_A2DP_MAIN_CHANNEL(pcm) ||
+					BA_PCM_SCO_SPEAKER_CHANNEL(pcm)))
+			bluealsa_pcm_fetch_codecs(ctl, pcm, &codecs);
+
+		if (ctl->show_battery &&
+				!elem_list_dev_has_battery_elem(elem_list, count, dev)) {
+			bluealsa_dev_fetch_battery(ctl, dev);
+			add_battery_elem = true;
 		}
 
+		count += bluealsa_elem_list_add_pcm_elems(ctl, &elem_list[count],
+				dev, pcm, &codecs, add_battery_elem);
+
 	}
 
-	/* Sort control elements alphabetically. */
-	qsort(elem_list, count, sizeof(*elem_list), bluealsa_elem_cmp);
+	if (count > 0)
+		/* Sort control elements according to our sorting rules. */
+		qsort(elem_list, count, sizeof(*elem_list), bluealsa_elem_cmp);
 
 	/* Detect element name duplicates and annotate them with the
 	 * consecutive device ID number - make ALSA library happy. */
 	if (!ctl->single_device)
 		for (i = 0; i < count; i++) {
 
-			char tmp[sizeof(elem_list[0].name)];
 			bool duplicated = false;
 			size_t ii;
 
 			for (ii = i + 1; ii < count; ii++)
-				if (strcmp(elem_list[i].name, elem_list[ii].name) == 0) {
-					bluealsa_elem_set_name(&elem_list[ii], strcpy(tmp, elem_list[ii].dev->name),
-							bluealsa_dev_get_id(ctl, elem_list[ii].pcm));
+				if (elem_list[i].dev != elem_list[ii].dev &&
+						strcmp(elem_list[i].name, elem_list[ii].name) == 0) {
+					bluealsa_elem_set_name(ctl, &elem_list[ii], elem_list[ii].dev->name, true);
 					duplicated = true;
 				}
 
 			if (duplicated)
-				bluealsa_elem_set_name(&elem_list[i], strcpy(tmp, elem_list[i].dev->name),
-						bluealsa_dev_get_id(ctl, elem_list[i].pcm));
+				bluealsa_elem_set_name(ctl, &elem_list[i], elem_list[i].dev->name, true);
 
 		}
 
+	/* Annotate elements with ALSA fake ID (see ALSA lib snd_ctl_ext_elem_list()
+	 * function for reference). These IDs will not be used by the ALSA lib when
+	 * the elem_list callback is called. However, we need them to be consistent
+	 * with ALSA internal fake IDs, because we will use them when creating new
+	 * elements by SND_CTL_EVENT_MASK_ADD events. Otherwise, these elements will
+	 * not behave properly. */
+	for (i = 0; i < count; i++)
+		elem_list[i].numid = i + 1;
+
 	ctl->elem_list = elem_list;
 	ctl->elem_list_size = count;
 
 	return count;
 }
 
+static void bluealsa_free_elem_list(struct bluealsa_ctl *ctl) {
+	for (size_t i = 0; i < ctl->elem_list_size; i++)
+		if (ctl->elem_list[i].type == CTL_ELEM_TYPE_CODEC)
+			bluealsa_dbus_pcm_codecs_free(&ctl->elem_list[i].codecs);
+}
+
 static void bluealsa_close(snd_ctl_ext_t *ext) {
 
 	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
 	size_t i;
 
 	bluealsa_dbus_connection_ctx_free(&ctl->dbus_ctx);
+	bluealsa_free_elem_list(ctl);
 
 	if (ctl->pipefd[0] != -1)
 		close(ctl->pipefd[0]);
@@ -478,6 +805,8 @@ static void bluealsa_close(snd_ctl_ext_t *ext) {
 
 	for (i = 0; i < ctl->dev_list_size; i++)
 		free(ctl->dev_list[i]);
+	for (i = 0; i < ctl->pcm_list_size; i++)
+		free(ctl->pcm_list[i]);
 	free(ctl->dev_list);
 	free(ctl->pcm_list);
 	free(ctl->elem_list);
@@ -496,8 +825,10 @@ static int bluealsa_elem_list(snd_ctl_ext_t *ext, unsigned int offset, snd_ctl_e
 	if (offset > ctl->elem_list_size)
 		return -EINVAL;
 
+	snd_ctl_elem_id_set_numid(id, ctl->elem_list[offset].numid);
 	snd_ctl_elem_id_set_interface(id, SND_CTL_ELEM_IFACE_MIXER);
 	snd_ctl_elem_id_set_name(id, ctl->elem_list[offset].name);
+	snd_ctl_elem_id_set_index(id, ctl->elem_list[offset].index);
 
 	return 0;
 }
@@ -511,10 +842,12 @@ static snd_ctl_ext_key_t bluealsa_find_elem(snd_ctl_ext_t *ext, const snd_ctl_el
 		return numid - 1;
 
 	const char *name = snd_ctl_elem_id_get_name(id);
+	unsigned int index = snd_ctl_elem_id_get_index(id);
 	size_t i;
 
 	for (i = 0; i < ctl->elem_list_size; i++)
-		if (strcmp(ctl->elem_list[i].name, name) == 0)
+		if (strcmp(ctl->elem_list[i].name, name) == 0 &&
+				ctl->elem_list[i].index == index)
 			return i;
 
 	return SND_CTL_EXT_KEY_NOT_FOUND;
@@ -536,6 +869,16 @@ static int bluealsa_get_attribute(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
 		*type = SND_CTL_ELEM_TYPE_INTEGER;
 		*count = 1;
 		break;
+	case CTL_ELEM_TYPE_CODEC:
+		*acc = SND_CTL_EXT_ACCESS_READWRITE;
+		*type = SND_CTL_ELEM_TYPE_ENUMERATED;
+		*count = 1;
+		break;
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		*acc = SND_CTL_EXT_ACCESS_READWRITE;
+		*type = SND_CTL_ELEM_TYPE_ENUMERATED;
+		*count = 1;
+		break;
 	case CTL_ELEM_TYPE_SWITCH:
 		*acc = SND_CTL_EXT_ACCESS_READWRITE;
 		*type = SND_CTL_ELEM_TYPE_BOOLEAN;
@@ -568,8 +911,6 @@ static int bluealsa_get_integer_info(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
 		*imax = 100;
 		*istep = 1;
 		break;
-	case CTL_ELEM_TYPE_SWITCH:
-		return -EINVAL;
 	case CTL_ELEM_TYPE_VOLUME:
 		switch (elem->pcm->transport) {
 		case BA_PCM_TRANSPORT_A2DP_SOURCE:
@@ -588,6 +929,10 @@ static int bluealsa_get_integer_info(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
 		*imin = 0;
 		*istep = 1;
 		break;
+	case CTL_ELEM_TYPE_CODEC:
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+	case CTL_ELEM_TYPE_SWITCH:
+		return -EINVAL;
 	}
 
 	return 0;
@@ -601,21 +946,25 @@ static int bluealsa_read_integer(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key, long
 
 	const struct ctl_elem *elem = &ctl->elem_list[key];
 	const struct ba_pcm *pcm = elem->pcm;
+	const bool active = elem->active;
 
 	switch (elem->type) {
 	case CTL_ELEM_TYPE_BATTERY:
-		value[0] = elem->dev->battery_level;
+		value[0] = active ? elem->dev->battery_level : 0;
 		break;
 	case CTL_ELEM_TYPE_SWITCH:
-		value[0] = !pcm->volume.ch1_muted;
+		value[0] = active ? !pcm->volume.ch1_muted : 0;
 		if (pcm->channels == 2)
-			value[1] = !pcm->volume.ch2_muted;
+			value[1] = active ? !pcm->volume.ch2_muted : 0;
 		break;
 	case CTL_ELEM_TYPE_VOLUME:
-		value[0] = pcm->volume.ch1_volume;
+		value[0] = active ? pcm->volume.ch1_volume : 0;
 		if (pcm->channels == 2)
-			value[1] = pcm->volume.ch2_volume;
+			value[1] = active ? pcm->volume.ch2_volume : 0;
 		break;
+	case CTL_ELEM_TYPE_CODEC:
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		return -EINVAL;
 	}
 
 	return 0;
@@ -631,6 +980,15 @@ static int bluealsa_write_integer(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key, lon
 	struct ba_pcm *pcm = elem->pcm;
 	uint16_t old = pcm->volume.raw;
 
+	if (!elem->active) {
+		/* Ignore the write request because the associated PCM profile has been
+		 * disconnected. Create an update event so the application is informed
+		 * that the value has been reset to zero. */
+		bluealsa_event_elem_updated(ctl, elem);
+		bluealsa_pipefd_ping(ctl);
+		return 1;
+	}
+
 	switch (elem->type) {
 	case CTL_ELEM_TYPE_BATTERY:
 		/* this element should be read-only */
@@ -645,6 +1003,9 @@ static int bluealsa_write_integer(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key, lon
 		if (pcm->channels == 2)
 			pcm->volume.ch2_volume = value[1];
 		break;
+	case CTL_ELEM_TYPE_CODEC:
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		return -EINVAL;
 	}
 
 	/* check whether update is required */
@@ -657,13 +1018,158 @@ static int bluealsa_write_integer(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key, lon
 	return 1;
 }
 
+int bluealsa_get_enumerated_info(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key, unsigned int *items) {
+	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
+
+	if (key > ctl->elem_list_size)
+		return -EINVAL;
+
+	const struct ctl_elem *elem = &ctl->elem_list[key];
+
+	switch (elem->type) {
+	case CTL_ELEM_TYPE_CODEC:
+		*items = elem->codecs.codecs_len;
+		break;
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		*items = ARRAYSIZE(soft_volume_names);
+		break;
+	case CTL_ELEM_TYPE_BATTERY:
+	case CTL_ELEM_TYPE_SWITCH:
+	case CTL_ELEM_TYPE_VOLUME:
+		return -EINVAL;
+	}
+
+	return 0;
+}
+
+int bluealsa_get_enumerated_name(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
+		unsigned int item, char *name, size_t name_max_len) {
+	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
+
+	if (key > ctl->elem_list_size)
+		return -EINVAL;
+
+	const struct ctl_elem *elem = &ctl->elem_list[key];
+
+	switch (elem->type) {
+	case CTL_ELEM_TYPE_CODEC:
+		if (item >= elem->codecs.codecs_len)
+			return -EINVAL;
+		strncpy(name, elem->codecs.codecs[item].name, name_max_len - 1);
+		name[name_max_len - 1] = '\0';
+		break;
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		if (item >= ARRAYSIZE(soft_volume_names))
+			return -EINVAL;
+		strncpy(name, soft_volume_names[item], name_max_len - 1);
+		name[name_max_len - 1] = '\0';
+		break;
+	case CTL_ELEM_TYPE_BATTERY:
+	case CTL_ELEM_TYPE_SWITCH:
+	case CTL_ELEM_TYPE_VOLUME:
+		return -EINVAL;
+	}
+
+	return 0;
+}
+
+static int bluealsa_read_enumerated(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
+		unsigned int *items) {
+	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
+
+	if (key > ctl->elem_list_size)
+		return -EINVAL;
+
+	const struct ctl_elem *elem = &ctl->elem_list[key];
+	const struct ba_pcm *pcm = elem->pcm;
+	unsigned int i;
+	int ret = 0;
+
+	switch (elem->type) {
+	case CTL_ELEM_TYPE_CODEC:
+		/* HFP codec is not known until a second or so after the profile
+		 * connection is established. In that case we *guess* that mSBC
+		 * will be used if available, or CVSD if not, since we do not
+		 * want "unknown" as an enumeration item. */
+		if (pcm->transport & BA_PCM_TRANSPORT_MASK_HFP &&
+				pcm->codec.name[0] == '\0') {
+			for (i = 0; i < elem->codecs.codecs_len; i++) {
+				if (strcmp("mSBC", elem->codecs.codecs[i].name) == 0) {
+					items[0] = i;
+					goto finish;
+				}
+			}
+			items[0] = 0;
+			break;
+		}
+		for (i = 0; i < elem->codecs.codecs_len; i++) {
+			if (strcmp(pcm->codec.name, elem->codecs.codecs[i].name) == 0) {
+				items[0] = i;
+				goto finish;
+			}
+		}
+		ret = -EINVAL;
+		break;
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		items[0] = pcm->soft_volume ? 1 : 0;
+		break;
+	case CTL_ELEM_TYPE_BATTERY:
+	case CTL_ELEM_TYPE_SWITCH:
+	case CTL_ELEM_TYPE_VOLUME:
+		ret = -EINVAL;
+		break;
+	}
+
+finish:
+	return ret;
+}
+
+static int bluealsa_write_enumerated(snd_ctl_ext_t *ext, snd_ctl_ext_key_t key,
+		unsigned int *items) {
+	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
+
+	if (key > ctl->elem_list_size)
+		return -EINVAL;
+
+	const struct ctl_elem *elem = &ctl->elem_list[key];
+	struct ba_pcm *pcm = elem->pcm;
+
+	switch (elem->type) {
+	case CTL_ELEM_TYPE_CODEC:
+		if (items[0] >= elem->codecs.codecs_len)
+			return -EINVAL;
+		if (strcmp(pcm->codec.name, elem->codecs.codecs[items[0]].name) == 0)
+			return 0;
+		if (!bluealsa_dbus_pcm_select_codec(&ctl->dbus_ctx, pcm->pcm_path,
+					elem->codecs.codecs[items[0]].name, NULL, 0, NULL))
+			return -EIO;
+		memcpy(&pcm->codec, &elem->codecs.codecs[items[0]], sizeof(pcm->codec));
+		break;
+	case CTL_ELEM_TYPE_SOFT_VOLUME:
+		if (items[0] >= ARRAYSIZE(soft_volume_names))
+			return -EINVAL;
+		const bool soft_volume = items[0] == 1;
+		if (pcm->soft_volume == soft_volume)
+			return 0;
+		pcm->soft_volume = soft_volume;
+		if (!bluealsa_dbus_pcm_update(&ctl->dbus_ctx, pcm, BLUEALSA_PCM_SOFT_VOLUME, NULL))
+			return -ENOMEM;
+		break;
+	case CTL_ELEM_TYPE_BATTERY:
+	case CTL_ELEM_TYPE_SWITCH:
+	case CTL_ELEM_TYPE_VOLUME:
+		return -EINVAL;
+	}
+
+	return 1;
+}
+
 static void bluealsa_subscribe_events(snd_ctl_ext_t *ext, int subscribe) {
 	struct bluealsa_ctl *ctl = (struct bluealsa_ctl *)ext->private_data;
 
 	if (subscribe) {
-		if (!ctl->single_device)
-			bluealsa_dbus_connection_signal_match_add(&ctl->dbus_ctx, ctl->dbus_ctx.ba_service, NULL,
-					DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesAdded", "path_namespace='/org/bluealsa'");
+		bluealsa_dbus_connection_signal_match_add(&ctl->dbus_ctx, ctl->dbus_ctx.ba_service, NULL,
+				DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesAdded", "path_namespace='/org/bluealsa'");
 		bluealsa_dbus_connection_signal_match_add(&ctl->dbus_ctx, ctl->dbus_ctx.ba_service, NULL,
 				DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesRemoved", "path_namespace='/org/bluealsa'");
 		char dbus_args[50];
@@ -683,47 +1189,39 @@ static void bluealsa_subscribe_events(snd_ctl_ext_t *ext, int subscribe) {
 	dbus_connection_flush(ctl->dbus_ctx.conn);
 }
 
-static int bluealsa_elem_update_list_add(struct bluealsa_ctl *ctl,
-		const char *elem_name, unsigned int mask) {
-
-	struct ctl_elem_update *tmp = ctl->elem_update_list;
-	if ((tmp = realloc(tmp, (ctl->elem_update_list_size + 1) * sizeof(*tmp))) == NULL)
-		return -1;
-
-	tmp[ctl->elem_update_list_size].event_mask = mask;
-	*stpncpy(tmp[ctl->elem_update_list_size].name, elem_name,
-			sizeof(tmp[ctl->elem_update_list_size].name) - 1) = '\0';
-
-	ctl->elem_update_list = tmp;
-	ctl->elem_update_list_size++;
-	return 0;
-}
-
-#define bluealsa_event_elem_added(ctl, elem) \
-	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_ADD)
-#define bluealsa_event_elem_removed(ctl, elem) \
-	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_REMOVE)
-#define bluealsa_event_elem_updated(ctl, elem) \
-	bluealsa_elem_update_list_add(ctl, elem, SND_CTL_EVENT_MASK_VALUE)
-
 static dbus_bool_t bluealsa_dbus_msg_update_dev(const char *key,
-		DBusMessageIter *variant, void *userdata, DBusError *error) {
+		DBusMessageIter *value, void *userdata, DBusError *error) {
 	(void)error;
 
 	struct bt_dev *dev = (struct bt_dev *)userdata;
-	dev->mask = 0;
+	dev->mask = BT_DEV_MASK_NONE;
+
+	if (dbus_message_iter_get_arg_type(value) != DBUS_TYPE_VARIANT)
+		return FALSE;
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
 
 	if (strcmp(key, "Alias") == 0) {
 		const char *alias;
-		dbus_message_iter_get_basic(variant, &alias);
+		dbus_message_iter_get_basic(&variant, &alias);
 		*stpncpy(dev->name, alias, sizeof(dev->name) - 1) = '\0';
-		dev->mask = SND_CTL_EVENT_MASK_ADD;
+		dev->mask = BT_DEV_MASK_UPDATE;
+	}
+	else if (strcmp(key, "Battery") == 0) {
+		signed char level;
+		dbus_message_iter_get_basic(&variant, &level);
+		dev->mask = BT_DEV_MASK_UPDATE;
+		if (dev->battery_level == -1)
+			dev->mask = BT_DEV_MASK_ADD | BT_DEV_MASK_UPDATE;
+		dev->battery_level = level;
 	}
-	if (strcmp(key, "Battery") == 0) {
-		char battery_level;
-		dbus_message_iter_get_basic(variant, &battery_level);
-		dev->mask = dev->battery_level == -1 ? SND_CTL_EVENT_MASK_ADD : SND_CTL_EVENT_MASK_VALUE;
-		dev->battery_level = battery_level;
+	else if (strcmp(key, "Connected") == 0) {
+		dbus_bool_t connected;
+		dbus_message_iter_get_basic(&variant, &connected);
+		/* process device disconnected event only */
+		if (!connected)
+			dev->mask = BT_DEV_MASK_REMOVE;
 	}
 
 	return TRUE;
@@ -757,24 +1255,38 @@ static DBusHandlerResult bluealsa_dbus_msg_filter(DBusConnection *conn,
 		if (strcmp(updated_interface, "org.bluez.Device1") == 0)
 			for (i = 0; i < ctl->elem_list_size; i++) {
 				struct bt_dev *dev = ctl->elem_list[i].dev;
-				if (strcmp(dev->device_path, path) == 0)
+				if (strcmp(dev->device_path, path) == 0) {
 					bluealsa_dbus_message_iter_dict(&iter, NULL,
 							bluealsa_dbus_msg_update_dev, dev);
-				if (dev->mask & SND_CTL_EVENT_MASK_ADD)
-					goto remove_add;
+					if (dev->mask & BT_DEV_MASK_UPDATE)
+						goto remove_add;
+					if (ctl->single_device &&
+							dev->mask & BT_DEV_MASK_REMOVE) {
+						/* Single device mode does not process PCM removes, however,
+						 * when the device disconnects we would like to simulate CTL
+						 * unplug event. */
+						ctl->pcm_list_size = 0;
+						goto remove_add;
+					}
+				}
 			}
 
 		/* handle BlueALSA RFCOMM properties update */
 		if (strcmp(updated_interface, BLUEALSA_INTERFACE_RFCOMM) == 0)
 			for (i = 0; i < ctl->elem_list_size; i++) {
-				struct bt_dev *dev = ctl->elem_list[i].dev;
+				struct ctl_elem *elem = &ctl->elem_list[i];
+				struct bt_dev *dev = elem->dev;
 				if (strcmp(dev->rfcomm_path, path) == 0) {
 					bluealsa_dbus_message_iter_dict(&iter, NULL,
 							bluealsa_dbus_msg_update_dev, dev);
-					if (dev->mask & SND_CTL_EVENT_MASK_ADD)
+					/* for non-dynamic mode we need to use update logic */
+					if (ctl->dynamic &&
+							dev->mask & BT_DEV_MASK_ADD)
 						goto remove_add;
-					if (dev->mask & SND_CTL_EVENT_MASK_VALUE)
-						bluealsa_event_elem_updated(ctl, ctl->elem_list[i].name);
+					if (elem->type != CTL_ELEM_TYPE_BATTERY)
+						continue;
+					if (dev->mask & BT_DEV_MASK_UPDATE)
+						bluealsa_event_elem_updated(ctl, elem);
 				}
 			}
 
@@ -782,40 +1294,47 @@ static DBusHandlerResult bluealsa_dbus_msg_filter(DBusConnection *conn,
 		if (strcmp(updated_interface, BLUEALSA_INTERFACE_PCM) == 0)
 			for (i = 0; i < ctl->elem_list_size; i++) {
 				struct ctl_elem *elem = &ctl->elem_list[i];
-				if (strcmp(elem->pcm->pcm_path, path) == 0) {
-					if (elem->type == CTL_ELEM_TYPE_BATTERY) {
-						bluealsa_dbus_message_iter_dict(&iter, NULL,
-								bluealsa_dbus_msg_update_dev, elem->dev);
-						if (elem->dev->mask & SND_CTL_EVENT_MASK_ADD)
-							goto remove_add;
-						if (elem->dev->mask & SND_CTL_EVENT_MASK_VALUE)
-							bluealsa_event_elem_updated(ctl, ctl->elem_list[i].name);
-					}
-					else {
-						bluealsa_dbus_message_iter_get_pcm_props(&iter, NULL, elem->pcm);
-						bluealsa_event_elem_updated(ctl, elem->name);
-					}
+				struct ba_pcm *pcm = elem->pcm;
+				if (elem->type == CTL_ELEM_TYPE_BATTERY)
+					continue;
+				if (strcmp(pcm->pcm_path, path) == 0) {
+					bluealsa_dbus_message_iter_get_pcm_props(&iter, NULL, pcm);
+					bluealsa_event_elem_updated(ctl, elem);
 				}
 			}
 
 	}
 	else if (strcmp(interface, DBUS_INTERFACE_OBJECT_MANAGER) == 0) {
 
-		if (!ctl->single_device &&
-				strcmp(signal, "InterfacesAdded") == 0) {
+		if (strcmp(signal, "InterfacesAdded") == 0) {
 			struct ba_pcm pcm;
 			if (bluealsa_dbus_message_iter_get_pcm(&iter, NULL, &pcm) &&
 					pcm.transport != BA_PCM_TRANSPORT_NONE) {
-				bluealsa_pcm_add(ctl, &pcm);
+
+				if (ctl->dynamic)
+					bluealsa_pcm_add(ctl, &pcm);
+				else
+					bluealsa_pcm_activate(ctl, &pcm);
+
 				goto remove_add;
+
 			}
 		}
 
 		if (strcmp(signal, "InterfacesRemoved") == 0) {
+
 			const char *pcm_path;
 			dbus_message_iter_get_basic(&iter, &pcm_path);
-			bluealsa_pcm_remove(ctl, pcm_path);
+
+			if (ctl->dynamic)
+				bluealsa_pcm_remove(ctl, pcm_path);
+			else
+				/* In the non-dynamic operation mode we never remove any elements,
+				 * we simply mark all elements of the removed PCM as inactive. */
+				bluealsa_pcm_deactivate(ctl, pcm_path);
+
 			goto remove_add;
+
 		}
 
 	}
@@ -842,7 +1361,11 @@ static DBusHandlerResult bluealsa_dbus_msg_filter(DBusConnection *conn,
 
 	return DBUS_HANDLER_RESULT_HANDLED;
 
-remove_add: {
+remove_add:
+
+	if (!ctl->dynamic)
+		/* non-dynamic mode SHALL not add/remove any elements */
+		goto final;
 
 	/* During a PCM name change, new PCM insertion and/or deletion, the name
 	 * of all control elements might have change, because of optional unique
@@ -851,12 +1374,15 @@ remove_add: {
 	 * and add new ones in order to update potential name changes. */
 
 	for (i = 0; i < ctl->elem_list_size; i++)
-		bluealsa_event_elem_removed(ctl, ctl->elem_list[i].name);
+		bluealsa_event_elem_removed(ctl, &ctl->elem_list[i]);
 
+	bluealsa_free_elem_list(ctl);
 	bluealsa_create_elem_list(ctl);
 
 	for (i = 0; i < ctl->elem_list_size; i++)
-		bluealsa_event_elem_added(ctl, ctl->elem_list[i].name);
+		bluealsa_event_elem_added(ctl, &ctl->elem_list[i]);
+
+final:
 
 	if (ctl->single_device && ctl->pcm_list_size == 0) {
 		/* Trigger POLLERR by closing the read end of our pipe. This
@@ -866,7 +1392,7 @@ remove_add: {
 	}
 
 	return DBUS_HANDLER_RESULT_HANDLED;
-}}
+}
 
 static int bluealsa_read_event(snd_ctl_ext_t *ext, snd_ctl_elem_id_t *id, unsigned int *event_mask) {
 	struct bluealsa_ctl *ctl = ext->private_data;
@@ -880,9 +1406,13 @@ static int bluealsa_read_event(snd_ctl_ext_t *ext, snd_ctl_elem_id_t *id, unsign
 
 	if (ctl->elem_update_list_size) {
 
+		const struct ctl_elem_update *update = &ctl->elem_update_list[ctl->elem_update_event_i];
+
+		snd_ctl_elem_id_set_numid(id, update->numid);
 		snd_ctl_elem_id_set_interface(id, SND_CTL_ELEM_IFACE_MIXER);
-		snd_ctl_elem_id_set_name(id, ctl->elem_update_list[ctl->elem_update_event_i].name);
-		*event_mask = ctl->elem_update_list[ctl->elem_update_event_i].event_mask;
+		snd_ctl_elem_id_set_name(id, update->name);
+		snd_ctl_elem_id_set_index(id, update->index);
+		*event_mask = update->event_mask;
 
 		if (++ctl->elem_update_event_i == ctl->elem_update_list_size) {
 			ctl->elem_update_list_size = 0;
@@ -902,6 +1432,9 @@ static int bluealsa_read_event(snd_ctl_ext_t *ext, snd_ctl_elem_id_t *id, unsign
 	 * kind to actually call .poll_revents(), this code should remain as a
 	 * backward compatibility. */
 	bluealsa_dbus_connection_dispatch(&ctl->dbus_ctx);
+	/* For the same reason, we also need to clear any internal ping events. */
+	if (ctl->single_device)
+		bluealsa_pipefd_flush(ctl);
 
 	if (ctl->elem_update_list_size)
 		return bluealsa_read_event(ext, id, event_mask);
@@ -911,36 +1444,68 @@ static int bluealsa_read_event(snd_ctl_ext_t *ext, snd_ctl_elem_id_t *id, unsign
 static int bluealsa_poll_descriptors_count(snd_ctl_ext_t *ext) {
 	struct bluealsa_ctl *ctl = ext->private_data;
 
-	nfds_t dbus_nfds = 0;
-	bluealsa_dbus_connection_poll_fds(&ctl->dbus_ctx, NULL, &dbus_nfds);
+	nfds_t nfds = 0;
+	bluealsa_dbus_connection_poll_fds(&ctl->dbus_ctx, NULL, &nfds);
 
-	return 1 + dbus_nfds;
+	if (ctl->pipefd[0] > -1)
+		++nfds;
+	if (ctl->pipefd[1] > -1)
+		++nfds;
+
+	return nfds;
 }
 
 static int bluealsa_poll_descriptors(snd_ctl_ext_t *ext, struct pollfd *pfd,
 		unsigned int nfds) {
 	struct bluealsa_ctl *ctl = ext->private_data;
 
-	nfds_t dbus_nfds = nfds - 1;
+	nfds_t pipe_nfds = 0;
+
+	/* Just in case some application (MPD ???) cannot handle a pfd with
+	 * .fd == -1, we omit each end of the pipe from the poll() if it is
+	 * already closed. */
 
-	pfd[0].fd = ctl->pipefd[1];
-	/* For our internal PIPE we are not interested
-	 * in any I/O events, only in error condition. */
-	pfd[0].events = 0;
+	if (ctl->pipefd[0] > -1) {
+		pfd[pipe_nfds].fd = ctl->pipefd[0];
+		pfd[pipe_nfds].events = POLLIN;
+		pipe_nfds++;
+	}
+
+	if (ctl->pipefd[1] > -1) {
+		pfd[pipe_nfds].fd = ctl->pipefd[1];
+		/* For the write end of our internal PIPE we are not interested
+		 * in any I/O events, only in error condition. */
+		pfd[pipe_nfds].events = 0;
+		pipe_nfds++;
+	}
 
-	if (!bluealsa_dbus_connection_poll_fds(&ctl->dbus_ctx, &pfd[1], &dbus_nfds))
+	nfds_t dbus_nfds = nfds - pipe_nfds;
+	if (!bluealsa_dbus_connection_poll_fds(&ctl->dbus_ctx, &pfd[pipe_nfds], &dbus_nfds))
 		return -EINVAL;
 
-	return 1 + dbus_nfds;
+	return pipe_nfds + dbus_nfds;
 }
 
 static int bluealsa_poll_revents(snd_ctl_ext_t *ext, struct pollfd *pfd,
 		unsigned int nfds, unsigned short *revents) {
 	struct bluealsa_ctl *ctl = ext->private_data;
 
-	*revents = pfd[0].revents;
+	nfds_t pipe_nfds = 0;
+	*revents = 0;
+
+	if (ctl->pipefd[0] > -1) {
+		if (pfd[0].revents)
+			bluealsa_pipefd_flush(ctl);
+		*revents |= pfd[pipe_nfds].revents;
+		pipe_nfds++;
+	}
+
+	if (ctl->pipefd[1] > -1) {
+		*revents |= pfd[pipe_nfds].revents;
+		pipe_nfds++;
+	}
 
-	if (bluealsa_dbus_connection_poll_dispatch(&ctl->dbus_ctx, &pfd[1], nfds - 1))
+	if (bluealsa_dbus_connection_poll_dispatch(&ctl->dbus_ctx, &pfd[pipe_nfds], nfds - pipe_nfds))
 		*revents |= POLLIN;
 
 	return 0;
@@ -953,8 +1518,12 @@ static const snd_ctl_ext_callback_t bluealsa_snd_ctl_ext_callback = {
 	.find_elem = bluealsa_find_elem,
 	.get_attribute = bluealsa_get_attribute,
 	.get_integer_info = bluealsa_get_integer_info,
+	.get_enumerated_info = bluealsa_get_enumerated_info,
+	.get_enumerated_name = bluealsa_get_enumerated_name,
 	.read_integer = bluealsa_read_integer,
+	.read_enumerated = bluealsa_read_enumerated,
 	.write_integer = bluealsa_write_integer,
+	.write_enumerated = bluealsa_write_enumerated,
 	.subscribe_events = bluealsa_subscribe_events,
 	.read_event = bluealsa_read_event,
 	.poll_descriptors_count = bluealsa_poll_descriptors_count,
@@ -1034,7 +1603,10 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 	DBusError err = DBUS_ERROR_INIT;
 	const char *service = BLUEALSA_SERVICE;
 	const char *device = NULL;
-	const char *battery = "no";
+	bool show_battery = false;
+	bool show_bt_transport = false;
+	bool show_extended = false;
+	bool dynamic = true;
 	struct bluealsa_ctl *ctl;
 	int ret;
 
@@ -1065,11 +1637,36 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 			}
 			continue;
 		}
+		if (strcmp(id, "extended") == 0) {
+			if ((ret = snd_config_get_bool(n)) < 0) {
+				SNDERR("Invalid type for %s", id);
+				return -EINVAL;
+			}
+			show_extended = !!ret;
+			continue;
+		}
 		if (strcmp(id, "battery") == 0) {
-			if (snd_config_get_string(n, &battery) < 0) {
+			if ((ret = snd_config_get_bool(n)) < 0) {
+				SNDERR("Invalid type for %s", id);
+				return -EINVAL;
+			}
+			show_battery = !!ret;
+			continue;
+		}
+		if (strcmp(id, "bttransport") == 0) {
+			if ((ret = snd_config_get_bool(n)) < 0) {
 				SNDERR("Invalid type for %s", id);
 				return -EINVAL;
 			}
+			show_bt_transport = !!ret;
+			continue;
+		}
+		if (strcmp(id, "dynamic") == 0) {
+			if ((ret = snd_config_get_bool(n)) < 0) {
+				SNDERR("Invalid type for %s", id);
+				return -EINVAL;
+			}
+			dynamic = !!ret;
 			continue;
 		}
 
@@ -1083,6 +1680,13 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 		return -EINVAL;
 	}
 
+	/* single Bluetooth device mode */
+	bool single_device_mode = bacmp(&ba_addr, BDADDR_ALL) != 0;
+
+	/* non-dynamic operation requires single device mode */
+	if (!single_device_mode)
+		dynamic = true;
+
 	if ((ctl = calloc(1, sizeof(*ctl))) == NULL)
 		return -ENOMEM;
 
@@ -1105,8 +1709,14 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 	ctl->pipefd[0] = -1;
 	ctl->pipefd[1] = -1;
 
-	ctl->show_battery = snd_config_get_bool_ascii(battery) == 1;
-	ctl->single_device = false;
+	ctl->show_battery = show_battery;
+	ctl->show_bt_transport = show_bt_transport;
+	ctl->show_extended = show_extended;
+	ctl->single_device = single_device_mode;
+	ctl->dynamic = dynamic;
+
+	struct ba_pcm *pcm_list = NULL;
+	size_t pcm_list_size = 0;
 
 	dbus_threads_init_default();
 
@@ -1122,19 +1732,13 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 		goto fail;
 	}
 
-	if (!bluealsa_dbus_get_pcms(&ctl->dbus_ctx, &ctl->pcm_list, &ctl->pcm_list_size, &err)) {
+	if (!bluealsa_dbus_get_pcms(&ctl->dbus_ctx, &pcm_list, &pcm_list_size, &err)) {
 		SNDERR("Couldn't get BlueALSA PCM list: %s", err.message);
 		ret = -ENODEV;
 		goto fail;
 	}
 
-	if (bacmp(&ba_addr, BDADDR_ALL) != 0) {
-
-		/* single Bluetooth device mode */
-		ctl->single_device = true;
-
-		const size_t pcm_list_size = ctl->pcm_list_size;
-		struct ba_pcm *pcm_list = ctl->pcm_list;
+	if (ctl->single_device) {
 
 		if (bacmp(&ba_addr, BDADDR_ANY) == 0) {
 			/* Interpret BT address ANY as a request for the most
@@ -1167,10 +1771,21 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 		for (i = count = 0; i < pcm_list_size; i++)
 			if (bacmp(&ba_addr, &pcm_list[i].addr) == 0)
 				memmove(&pcm_list[count++], &pcm_list[i], sizeof(*pcm_list));
-		ctl->pcm_list_size = count;
+		pcm_list_size = count;
 
 	}
 
+	/* add PCMs to CTL internal PCM list */
+	for (size_t i = 0; i < pcm_list_size; i++)
+		if (bluealsa_pcm_add(ctl, &pcm_list[i]) == -1) {
+			SNDERR("Couldn't add BlueALSA PCM: %s", strerror(errno));
+			ret = -errno;
+			goto fail;
+		}
+
+	free(pcm_list);
+	pcm_list = NULL;
+
 	if (bluealsa_create_elem_list(ctl) == -1) {
 		SNDERR("Couldn't create control elements: %s", strerror(errno));
 		ret = -errno;
@@ -1179,18 +1794,18 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 
 	if (ctl->single_device) {
 
-		if (pipe2(ctl->pipefd, O_CLOEXEC) == -1) {
-			SNDERR("Couldn't create event pipe: %s", strerror(errno));
-			ret = -errno;
-			goto fail;
-		}
-
 		if (ctl->dev_list_size != 1) {
 			SNDERR("No such BlueALSA audio device: %s", device);
 			ret = -ENODEV;
 			goto fail;
 		}
 
+		if (pipe2(ctl->pipefd, O_CLOEXEC | O_NONBLOCK) == -1) {
+			SNDERR("Couldn't create event pipe: %s", strerror(errno));
+			ret = -errno;
+			goto fail;
+		}
+
 		/* use Bluetooth device name as the card name for our plug-in */
 		strncpy(ctl->ext.name, ctl->dev_list[0]->name, sizeof(ctl->ext.name) - 1);
 		ctl->ext.name[sizeof(ctl->ext.name) - 1] = '\0';
@@ -1206,6 +1821,7 @@ SND_CTL_PLUGIN_DEFINE_FUNC(bluealsa) {
 fail:
 	bluealsa_close(&ctl->ext);
 	dbus_error_free(&err);
+	free(pcm_list);
 	return ret;
 }
 
diff --git a/src/asound/bluealsa-pcm.c b/src/asound/bluealsa-pcm.c
index 751ffe4..5556cb3 100644
--- a/src/asound/bluealsa-pcm.c
+++ b/src/asound/bluealsa-pcm.c
@@ -1,6 +1,6 @@
 /*
  * bluealsa-pcm.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -12,11 +12,13 @@
 # include <config.h>
 #endif
 
+#include <alloca.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <poll.h>
 #include <pthread.h>
 #include <signal.h>
+#include <stdatomic.h>
 #include <stdbool.h>
 #include <stdint.h>
 #include <stdio.h>
@@ -25,7 +27,7 @@
 #include <strings.h>
 #include <sys/eventfd.h>
 #include <sys/ioctl.h>
-#include <time.h>
+#include <sys/time.h>
 #include <unistd.h>
 
 #include <alsa/asoundlib.h>
@@ -43,9 +45,9 @@
 #define BA_PAUSE_STATE_PAUSED  (1 << 0)
 #define BA_PAUSE_STATE_PENDING (1 << 1)
 
-#if SND_LIB_VERSION >= 0x010104 && SND_LIB_VERSION <= 0x010205
+#if SND_LIB_VERSION >= 0x010104
 /**
- * alsa-lib releases 1.1.4 to 1.2.5.1 inclusive have a bug in the rate plugin
+ * alsa-lib releases from 1.1.4 have a bug in the rate plugin
  * which, when combined with the hw params refinement algorithm used by the
  * ioplug, can cause snd_pcm_avail() to return bogus values. This, in turn,
  * can trigger deadlock in applications built on the portaudio library
@@ -69,23 +71,28 @@ struct bluealsa_pcm {
 
 	/* requested BlueALSA PCM */
 	struct ba_pcm ba_pcm;
-	size_t ba_pcm_buffer_size;
+
+	/* PCM FIFO */
 	int ba_pcm_fd;
+	/* PCM control socket */
 	int ba_pcm_ctrl_fd;
 
+	/* Indicates that the server is connected. */
+	atomic_bool connected;
+
 	/* event file descriptor */
 	int event_fd;
 
 	/* virtual hardware - ring buffer */
-	char *io_hw_buffer;
+	char * _Atomic io_hw_buffer;
 	/* The IO thread is responsible for maintaining the hardware pointer
 	 * (pcm->io_hw_ptr), the application is responsible for the application
-	 * pointer (io->appl_ptr). These are both volatile as they are both
+	 * pointer (io->appl_ptr). These pointers should be atomic as they are
 	 * written in one thread and read in the other. */
-	volatile snd_pcm_sframes_t io_hw_ptr;
-	snd_pcm_uframes_t io_hw_boundary;
+	_Atomic snd_pcm_sframes_t io_hw_ptr;
+	_Atomic snd_pcm_uframes_t io_hw_boundary;
 	/* Permit the application to modify the frequency of poll() events. */
-	volatile snd_pcm_uframes_t io_avail_min;
+	_Atomic snd_pcm_uframes_t io_avail_min;
 	pthread_t io_thread;
 	bool io_started;
 
@@ -96,7 +103,7 @@ struct bluealsa_pcm {
 	snd_pcm_uframes_t delay_hw_ptr;
 	unsigned int delay_pcm_nread;
 	/* In the capture mode, delay_running indicates that frames are being
-	 * transfered to the FIFO by the server. In playback mode it indicates
+	 * transferred to the FIFO by the server. In playback mode it indicates
 	 * that the IO thread is transferring frames to the FIFO. */
 	bool delay_running;
 
@@ -134,32 +141,29 @@ static snd_pcm_uframes_t snd_pcm_ioplug_hw_avail(const snd_pcm_ioplug_t * const
 		diff = io->buffer_size - hw_ptr + appl_ptr;
 	if (diff < 0)
 		diff += pcm->io_hw_boundary;
-	return diff <= io->buffer_size ? (snd_pcm_uframes_t) diff : 0;
+	snd_pcm_uframes_t diff_ = diff;
+	return diff_ <= io->buffer_size ? diff_ : 0;
 }
 #endif
 
 /**
- * Helper function for closing PCM transport. */
-static int close_transport(struct bluealsa_pcm *pcm) {
-	int rv = 0;
-	pthread_mutex_lock(&pcm->mutex);
-	if (pcm->ba_pcm_fd != -1) {
-		rv |= close(pcm->ba_pcm_fd);
-		pcm->ba_pcm_fd = -1;
-	}
-	if (pcm->ba_pcm_ctrl_fd != -1) {
-		rv |= close(pcm->ba_pcm_ctrl_fd);
-		pcm->ba_pcm_ctrl_fd = -1;
-	}
-	pthread_mutex_unlock(&pcm->mutex);
-	return rv;
+ * Helper function for terminating IO thread. */
+static void io_thread_cancel(struct bluealsa_pcm *pcm) {
+
+	if (!pcm->io_started)
+		return;
+
+	pthread_cancel(pcm->io_thread);
+	pthread_join(pcm->io_thread, NULL);
+	pcm->io_started = false;
+
 }
 
 /**
- * Helper function for IO thread termination. */
+ * Helper function for logging IO thread termination. */
 static void io_thread_cleanup(struct bluealsa_pcm *pcm) {
 	debug2("IO thread cleanup");
-	pcm->io_started = false;
+	(void)pcm;
 }
 
 /**
@@ -201,16 +205,15 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(io_thread_cleanup), pcm);
 
 	sigset_t sigset;
-	sigemptyset(&sigset);
-
-	/* Block signal, which will be used for pause/resume actions. */
-	sigaddset(&sigset, SIGIO);
-	/* Block SIGPIPE, so we could receive EPIPE while writing to the pipe
-	 * whose reading end has been closed. This will allow clean playback
-	 * termination. */
-	sigaddset(&sigset, SIGPIPE);
-
-	if ((errno = pthread_sigmask(SIG_BLOCK, &sigset, NULL)) != 0) {
+	/* Block all signals in the IO thread.
+	 * Especially, we need to block SIGPIPE, so we could receive EPIPE while
+	 * writing to the pipe which reading end was closed by the server. This
+	 * will allow clean playback termination. Also, we need to block SIGIO,
+	 * which is used for pause/resume actions. The rest of the signals are
+	 * blocked because we are using thread cancellation and we do not want
+	 * any interference from signal handlers. */
+	sigfillset(&sigset);
+	if ((errno = pthread_sigmask(SIG_SETMASK, &sigset, NULL)) != 0) {
 		SNDERR("Thread signal mask error: %s", strerror(errno));
 		goto fail;
 	}
@@ -226,14 +229,18 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 	debug2("Starting IO loop: %d", pcm->ba_pcm_fd);
 	for (;;) {
 
-		if (pcm->pause_state & BA_PAUSE_STATE_PENDING ||
+		pthread_mutex_lock(&pcm->mutex);
+		unsigned int is_pause_pending = pcm->pause_state & BA_PAUSE_STATE_PENDING;
+		pthread_mutex_unlock(&pcm->mutex);
+
+		if (is_pause_pending ||
 				pcm->io_hw_ptr == -1) {
 			debug2("Pausing IO thread");
 
 			pthread_mutex_lock(&pcm->mutex);
 			pcm->pause_state = BA_PAUSE_STATE_PAUSED;
-			pthread_cond_signal(&pcm->pause_cond);
 			pthread_mutex_unlock(&pcm->mutex);
+			pthread_cond_signal(&pcm->pause_cond);
 
 			int tmp;
 			sigwait(&sigset, &tmp);
@@ -246,8 +253,6 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 
 			if (pcm->io_hw_ptr == -1)
 				continue;
-			if (pcm->ba_pcm_fd == -1)
-				goto fail;
 
 			asrsync_init(&asrs, io->rate);
 			io_hw_ptr = pcm->io_hw_ptr;
@@ -277,7 +282,7 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 		/* When used with the rate plugin the buffer might contain a fractional
 		 * number of periods. So if the leftover in the buffer is less than a
 		 * whole period size, adjust the number of frames which should be
-		 * transfered.  */
+		 * transferred.  */
 		if (io->buffer_size - offset < frames)
 			frames = io->buffer_size - offset;
 
@@ -300,14 +305,17 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 					if (errno == EINTR)
 						continue;
 					SNDERR("PCM FIFO read error: %s", strerror(errno));
+					pcm->connected = false;
 					goto fail;
 				}
 				head += ret;
 				len -= ret;
 			}
 
-			if (ret == 0)
+			if (ret == 0) {
+				pcm->connected = false;
 				goto fail;
+			}
 
 			io_thread_update_delay(pcm, io_hw_ptr);
 
@@ -321,6 +329,7 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 						continue;
 					if (errno != EPIPE)
 						SNDERR("PCM FIFO write error: %s", strerror(errno));
+					pcm->connected = false;
 					goto fail;
 				}
 				head += ret;
@@ -343,11 +352,20 @@ static void *io_thread(snd_pcm_ioplug_t *io) {
 	}
 
 fail:
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-	pthread_cleanup_pop(1);
-	close_transport(pcm);
-	eventfd_write(pcm->event_fd, 0xDEAD0000);
+
+	/* make sure we will not get stuck in the pause sync loop */
+	pthread_mutex_lock(&pcm->mutex);
+	pcm->pause_state = BA_PAUSE_STATE_PAUSED;
+	pthread_mutex_unlock(&pcm->mutex);
 	pthread_cond_signal(&pcm->pause_cond);
+
+	eventfd_write(pcm->event_fd, 0xDEAD0000);
+
+	/* wait for cancellation from main thread */
+	while (true)
+		sleep(3600);
+
+	pthread_cleanup_pop(1);
 	return NULL;
 }
 
@@ -377,7 +395,7 @@ static int bluealsa_start(snd_pcm_ioplug_t *io) {
 	/* start the IO thread */
 	pcm->io_started = true;
 	if ((errno = pthread_create(&pcm->io_thread, NULL,
-					PTHREAD_ROUTINE(io_thread), io)) != 0) {
+					PTHREAD_FUNC(io_thread), io)) != 0) {
 		debug2("Couldn't create IO thread: %s", strerror(errno));
 		pcm->io_started = false;
 		return -errno;
@@ -391,11 +409,7 @@ static int bluealsa_stop(snd_pcm_ioplug_t *io) {
 	struct bluealsa_pcm *pcm = io->private_data;
 	debug2("Stopping");
 
-	if (pcm->io_started) {
-		pcm->io_started = false;
-		pthread_cancel(pcm->io_thread);
-		pthread_join(pcm->io_thread, NULL);
-	}
+	io_thread_cancel(pcm);
 
 	pcm->delay_running = false;
 	pcm->delay_pcm_nread = 0;
@@ -421,14 +435,14 @@ static snd_pcm_sframes_t bluealsa_pointer(snd_pcm_ioplug_t *io) {
 	/* Any error returned here is translated to -EPIPE, SND_PCM_STATE_XRUN,
 	 * by ioplug; and that prevents snd_pcm_readi() and snd_pcm_writei()
 	 * from returning -ENODEV to the application on device disconnection.
-`	 * Instead, when the device is disconnected, we update the PCM state
+	 * Instead, when the device is disconnected, we update the PCM state
 	 * directly here but we do not return an error code. This ensures that
 	 * ioplug does not undo that state change. Both snd_pcm_readi() and
 	 * snd_pcm_writei() return -ENODEV when the PCM state is
 	 * SND_PCM_STATE_DISCONNECTED after their internal call to
 	 * snd_pcm_avail_update(), which will be the case when we set it here.
 	 */
-	if (pcm->ba_pcm_fd == -1)
+	if (!pcm->connected)
 		snd_pcm_ioplug_set_state(io, SND_PCM_STATE_DISCONNECTED);
 
 #ifndef SND_PCM_IOPLUG_FLAG_BOUNDARY_WA
@@ -520,7 +534,7 @@ static int bluealsa_fix_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *par
 	if ((ret = snd_pcm_hw_params_set_buffer_size(io->pcm, refined_params, buffer_size)) < 0)
 		return ret;
 
-	memcpy(params, refined_params, snd_pcm_hw_params_sizeof());
+	snd_pcm_hw_params_copy(params, refined_params);
 
 	return ret;
 }
@@ -528,12 +542,14 @@ static int bluealsa_fix_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *par
 
 static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params) {
 	struct bluealsa_pcm *pcm = io->private_data;
-	(void)params;
+
 	debug2("Initializing HW");
 
 #if	BLUEALSA_HW_PARAMS_FIX
 	if (bluealsa_fix_hw_params(io, params) < 0)
 		debug2("Warning - unable to fix incorrect buffer size in hw parameters");
+#endif
+
 	snd_pcm_uframes_t period_size;
 	int ret;
 	if ((ret = snd_pcm_hw_params_get_period_size(params, &period_size, 0)) < 0)
@@ -541,7 +557,6 @@ static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params)
 	snd_pcm_uframes_t buffer_size;
 	if ((ret = snd_pcm_hw_params_get_buffer_size(params, &buffer_size)) < 0)
 		return ret;
-#endif
 
 	pcm->frame_size = (snd_pcm_format_physical_width(io->format) * io->channels) / 8;
 
@@ -553,6 +568,8 @@ static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params)
 		return -EBUSY;
 	}
 
+	pcm->connected = true;
+
 	if (pcm->io.stream == SND_PCM_STREAM_PLAYBACK)
 		/* By default, the size of the pipe buffer is set to a too large value for
 		 * our purpose. On modern Linux system it is 65536 bytes. Large buffer in
@@ -566,7 +583,6 @@ static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params)
 
 	debug2("FIFO buffer size: %zd frames", pcm->delay_fifo_size);
 
-#if BLUEALSA_HW_PARAMS_FIX
 	/* ALSA default for avail min is one period. */
 	pcm->io_avail_min = period_size;
 
@@ -574,15 +590,6 @@ static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params)
 			buffer_size / period_size, pcm->frame_size * period_size,
 			period_size * (buffer_size / period_size) == buffer_size ? '=' : '<',
 			buffer_size * pcm->frame_size);
-#else
-	/* ALSA default for avail min is one period. */
-	pcm->io_avail_min = io->period_size;
-
-	debug2("Selected HW buffer: %zd periods x %zd bytes %c= %zd bytes",
-			io->buffer_size / io->period_size, pcm->frame_size * io->period_size,
-			io->period_size * (io->buffer_size / io->period_size) == io->buffer_size ? '=' : '<',
-			io->buffer_size * pcm->frame_size);
-#endif
 
 	return 0;
 }
@@ -590,16 +597,31 @@ static int bluealsa_hw_params(snd_pcm_ioplug_t *io, snd_pcm_hw_params_t *params)
 static int bluealsa_hw_free(snd_pcm_ioplug_t *io) {
 	struct bluealsa_pcm *pcm = io->private_data;
 	debug2("Freeing HW");
-	if (close_transport(pcm) == -1)
-		return -errno;
-	return 0;
+
+	/* Before closing PCM transport make sure that
+	 * the IO thread is terminated. */
+	io_thread_cancel(pcm);
+
+	int rv = 0;
+	if (pcm->ba_pcm_fd != -1)
+		rv |= close(pcm->ba_pcm_fd);
+	if (pcm->ba_pcm_ctrl_fd != -1)
+		rv |= close(pcm->ba_pcm_ctrl_fd);
+
+	pcm->ba_pcm_fd = -1;
+	pcm->ba_pcm_ctrl_fd = -1;
+	pcm->connected = false;
+
+	return rv == 0 ? 0 : -errno;
 }
 
 static int bluealsa_sw_params(snd_pcm_ioplug_t *io, snd_pcm_sw_params_t *params) {
 	struct bluealsa_pcm *pcm = io->private_data;
 	debug2("Initializing SW");
 
-	snd_pcm_sw_params_get_boundary(params, &pcm->io_hw_boundary);
+	snd_pcm_uframes_t boundary;
+	snd_pcm_sw_params_get_boundary(params, &boundary);
+	pcm->io_hw_boundary = boundary;
 
 	snd_pcm_uframes_t avail_min;
 	snd_pcm_sw_params_get_avail_min(params, &avail_min);
@@ -615,7 +637,7 @@ static int bluealsa_prepare(snd_pcm_ioplug_t *io) {
 	struct bluealsa_pcm *pcm = io->private_data;
 
 	/* if PCM FIFO is not opened, report it right away */
-	if (pcm->ba_pcm_fd == -1) {
+	if (!pcm->connected) {
 		snd_pcm_ioplug_set_state(io, SND_PCM_STATE_DISCONNECTED);
 		return -ENODEV;
 	}
@@ -642,7 +664,49 @@ static int bluealsa_prepare(snd_pcm_ioplug_t *io) {
 
 static int bluealsa_drain(snd_pcm_ioplug_t *io) {
 	struct bluealsa_pcm *pcm = io->private_data;
-	bluealsa_dbus_pcm_ctrl_send_drain(pcm->ba_pcm_ctrl_fd, NULL);
+	debug2("Draining");
+
+	if (!pcm->connected) {
+		snd_pcm_ioplug_set_state(io, SND_PCM_STATE_DISCONNECTED);
+		return -ENODEV;
+	}
+
+	/* A bug in the ioplug drain implementation means that snd_pcm_drain()
+	 * always either finishes in state SND_PCM_STATE_SETUP or returns an error.
+	 * It is not possible to finish in state SND_PCM_STATE_DRAINING and return
+	 * success; therefore is is impossible to correctly implement capture
+	 * drain logic. So for capture PCMs we do nothing and return success. */
+
+	if (io->stream == SND_PCM_STREAM_PLAYBACK) {
+
+		/* We must ensure that all remaining frames in the ring buffer are
+		 * flushed to the FIFO by the I/O thread. It is possible that the
+		 * client has called snd_pcm_drain() without the start_threshold
+		 * having been reached, or while paused, so we must first ensure that
+		 * the IO thread is running. */
+		if (bluealsa_start(io) < 0)
+			return 0;
+
+		/* Block until the drain is complete. */
+		struct pollfd pfd = { pcm->event_fd, POLLIN, 0 };
+		while (bluealsa_pointer(io) >= 0 && io->state == SND_PCM_STATE_DRAINING) {
+			if (poll(&pfd, 1, -1) == -1) {
+				if (errno == EINTR)
+					continue;
+				break;
+			}
+			if (pfd.revents & POLLIN) {
+				eventfd_t event;
+				eventfd_read(pcm->event_fd, &event);
+				if (event & 0xDEAD0000)
+					break;
+			}
+		}
+
+		bluealsa_dbus_pcm_ctrl_send_drain(pcm->ba_pcm_ctrl_fd, NULL);
+
+	}
+
 	/* We cannot recover from an error here. By returning zero we ensure that
 	 * ioplug stops the pcm. Returning an error code would be interpreted by
 	 * ioplug as an incomplete drain and would it leave the pcm running. */
@@ -662,10 +726,6 @@ static snd_pcm_sframes_t bluealsa_calculate_delay(snd_pcm_ioplug_t *io) {
 
 	snd_pcm_sframes_t delay = 0;
 
-	/* if PCM is not started there should be no capture delay */
-	if (!pcm->delay_running && io->stream == SND_PCM_STREAM_CAPTURE)
-		return 0;
-
 	struct timespec now;
 	gettimestamp(&now);
 
@@ -686,6 +746,12 @@ static snd_pcm_sframes_t bluealsa_calculate_delay(snd_pcm_ioplug_t *io) {
 
 	pthread_mutex_lock(&pcm->mutex);
 
+	/* if PCM is not started there should be no capture delay */
+	if (!pcm->delay_running && io->stream == SND_PCM_STREAM_CAPTURE) {
+		pthread_mutex_unlock(&pcm->mutex);
+		return 0;
+	}
+
 	struct timespec diff;
 	timespecsub(&now, &pcm->delay_ts, &diff);
 
@@ -757,11 +823,16 @@ static int bluealsa_pause(snd_pcm_ioplug_t *io, int enable) {
 		 * the server will not be paused while we are processing a transfer. */
 		pthread_mutex_lock(&pcm->mutex);
 		pcm->pause_state |= BA_PAUSE_STATE_PENDING;
-		while (!(pcm->pause_state & BA_PAUSE_STATE_PAUSED) && pcm->ba_pcm_fd != -1)
+		while (!(pcm->pause_state & BA_PAUSE_STATE_PAUSED) && pcm->connected)
 			pthread_cond_wait(&pcm->pause_cond, &pcm->mutex);
 		pthread_mutex_unlock(&pcm->mutex);
 	}
 
+	if (!pcm->connected) {
+		snd_pcm_ioplug_set_state(io, SND_PCM_STATE_DISCONNECTED);
+		return -ENODEV;
+	}
+
 	if (!bluealsa_dbus_pcm_ctrl_send(pcm->ba_pcm_ctrl_fd,
 				enable ? "Pause" : "Resume", NULL))
 		return -errno;
@@ -785,11 +856,11 @@ static void bluealsa_dump(snd_pcm_ioplug_t *io, snd_output_t *out) {
 	struct bluealsa_pcm *pcm = io->private_data;
 	snd_output_printf(out, "BlueALSA PCM: %s\n", pcm->ba_pcm.pcm_path);
 	snd_output_printf(out, "BlueALSA BlueZ device: %s\n", pcm->ba_pcm.device_path);
-	snd_output_printf(out, "BlueALSA Bluetooth codec: %s\n", pcm->ba_pcm.codec);
+	snd_output_printf(out, "BlueALSA Bluetooth codec: %s\n", pcm->ba_pcm.codec.name);
 	/* alsa-lib commits the PCM setup only if bluealsa_hw_params() returned
 	 * success, so we only dump the ALSA PCM parameters if the BlueALSA PCM
 	 * connection is established. */
-	if (pcm->ba_pcm_fd >= 0) {
+	if (pcm->connected) {
 		snd_output_printf(out, "Its setup is:\n");
 		snd_pcm_dump_setup(io->pcm, out);
 	}
@@ -798,7 +869,7 @@ static void bluealsa_dump(snd_pcm_ioplug_t *io, snd_output_t *out) {
 static int bluealsa_delay(snd_pcm_ioplug_t *io, snd_pcm_sframes_t *delayp) {
 	struct bluealsa_pcm *pcm = io->private_data;
 
-	if (pcm->ba_pcm_fd == -1) {
+	if (!pcm->connected) {
 		snd_pcm_ioplug_set_state(io, SND_PCM_STATE_DISCONNECTED);
 		return -ENODEV;
 	}
@@ -841,6 +912,9 @@ static int bluealsa_poll_descriptors(snd_pcm_ioplug_t *io, struct pollfd *pfd,
 		unsigned int nfds) {
 	struct bluealsa_pcm *pcm = io->private_data;
 
+	if (nfds < 1)
+		return -EINVAL;
+
 	nfds_t dbus_nfds = nfds - 1;
 	if (!bluealsa_dbus_connection_poll_fds(&pcm->dbus_ctx, &pfd[1], &dbus_nfds))
 		return -EINVAL;
@@ -859,12 +933,15 @@ static int bluealsa_poll_revents(snd_pcm_ioplug_t *io, struct pollfd *pfd,
 	*revents = 0;
 	int ret = 0;
 
+	if (nfds < 1)
+		return -EINVAL;
+
 	bluealsa_dbus_connection_poll_dispatch(&pcm->dbus_ctx, &pfd[1], nfds - 1);
 	while (dbus_connection_dispatch(pcm->dbus_ctx.conn) == DBUS_DISPATCH_DATA_REMAINS)
 		continue;
 	gettimestamp(&pcm->dbus_dispatch_ts);
 
-	if (pcm->ba_pcm_fd == -1)
+	if (!pcm->connected)
 		goto fail;
 
 	if (pfd[0].revents & POLLIN) {
@@ -1335,17 +1412,6 @@ SND_PCM_PLUGIN_DEFINE_FUNC(bluealsa) {
 	pcm->io.callback = &bluealsa_callback;
 	pcm->io.private_data = pcm;
 
-#if SND_LIB_VERSION >= 0x010102 && SND_LIB_VERSION <= 0x010103
-	/* ALSA library thread-safe API functionality does not play well with ALSA
-	 * IO-plug plug-ins. It causes deadlocks which often make our PCM plug-in
-	 * unusable. As a workaround we are going to disable this functionality. */
-	if (setenv("LIBASOUND_THREAD_SAFE", "0", 0) == -1)
-		SNDERR("Couldn't disable ALSA thread-safe API: %s", strerror(errno));
-#endif
-
-	if ((ret = snd_pcm_ioplug_create(&pcm->io, name, stream, mode)) < 0)
-		goto fail;
-
 	if (codec != NULL && codec[0] != '\0') {
 		if (bluealsa_select_pcm_codec(pcm, codec, &err)) {
 			/* Changing the codec may change the audio format, sampling rate and/or
@@ -1364,11 +1430,27 @@ SND_PCM_PLUGIN_DEFINE_FUNC(bluealsa) {
 		}
 	}
 
-	if ((ret = bluealsa_set_hw_constraint(pcm)) < 0) {
-		snd_pcm_ioplug_delete(&pcm->io);
-		return ret;
+	/* If the BT transport codec is not known (which means that PCM sampling
+	 * rate is also not know), we cannot construct useful constraints. */
+	if (pcm->ba_pcm.sampling == 0) {
+		ret = -EAGAIN;
+		goto fail;
 	}
 
+#if SND_LIB_VERSION >= 0x010102 && SND_LIB_VERSION <= 0x010103
+	/* ALSA library thread-safe API functionality does not play well with ALSA
+	 * IO-plug plug-ins. It causes deadlocks which often make our PCM plug-in
+	 * unusable. As a workaround we are going to disable this functionality. */
+	if (setenv("LIBASOUND_THREAD_SAFE", "0", 0) == -1)
+		SNDERR("Couldn't disable ALSA thread-safe API: %s", strerror(errno));
+#endif
+
+	if ((ret = snd_pcm_ioplug_create(&pcm->io, name, stream, mode)) < 0)
+		goto fail;
+
+	if ((ret = bluealsa_set_hw_constraint(pcm)) < 0)
+		goto fail;
+
 	if (!bluealsa_update_pcm_softvol(pcm, pcm_softvol, &err)) {
 		SNDERR("Couldn't set BlueALSA PCM soft-volume: %s", err.message);
 		dbus_error_free(&err);
@@ -1387,6 +1469,8 @@ fail:
 	dbus_error_free(&err);
 	if (pcm->event_fd != -1)
 		close(pcm->event_fd);
+	pthread_mutex_destroy(&pcm->mutex);
+	pthread_cond_destroy(&pcm->pause_cond);
 	free(pcm);
 	return ret;
 }
diff --git a/src/at.c b/src/at.c
index a4db4bd..9efcdfd 100644
--- a/src/at.c
+++ b/src/at.c
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - at.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
- *               2017 Juha Kuikka
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2017 Juha Kuikka
  *
  * This file is a part of bluez-alsa.
  *
@@ -21,6 +21,23 @@
 #include "shared/defs.h"
 #include "shared/log.h"
 
+/**
+ * Convert AT type into a human-readable string.
+ *
+ * @param type AT message type.
+ * @return Human-readable string. */
+const char *at_type2str(enum bt_at_type type) {
+	static const char *types[__AT_TYPE_MAX] = {
+		[AT_TYPE_RAW] = "RAW",
+		[AT_TYPE_CMD] = "CMD",
+		[AT_TYPE_CMD_GET] = "GET",
+		[AT_TYPE_CMD_SET] = "SET",
+		[AT_TYPE_CMD_TEST] = "TEST",
+		[AT_TYPE_RESP] = "RESP",
+	};
+	return types[type];
+}
+
 /**
  * Build AT message.
  *
@@ -169,7 +186,7 @@ char *at_parse(const char *str, struct bt_at *at) {
  * @param str Command value string.
  * @param state Array with the state to be updated.
  * @return On success this function returns 0, otherwise -1 is returned. */
-int at_parse_bia(const char *str, bool state[__HFP_IND_MAX]) {
+int at_parse_set_bia(const char *str, bool state[__HFP_IND_MAX]) {
 
 	enum hfp_ind ind = HFP_IND_NULL + 1;
 	while (ind < __HFP_IND_MAX && *str != '\0') {
@@ -203,7 +220,7 @@ int at_parse_bia(const char *str, bool state[__HFP_IND_MAX]) {
  * @param map Address where the mapping between the indicator index and the
  *   HFP indicator type will be stored.
  * @return On success this function returns 0, otherwise -1 is returned. */
-int at_parse_cind(const char *str, enum hfp_ind map[20]) {
+int at_parse_get_cind(const char *str, enum hfp_ind map[20]) {
 
 	static const struct {
 		const char *str;
@@ -244,7 +261,7 @@ int at_parse_cind(const char *str, enum hfp_ind map[20]) {
  * @param str CMER command value string.
  * @param map Address where the CMER values will be stored.
  * @return On success this function returns 0, otherwise -1 is returned. */
-int at_parse_cmer(const char *str, unsigned int map[5]) {
+int at_parse_set_cmer(const char *str, unsigned int map[5]) {
 
 	char *tmp;
 	size_t i;
@@ -263,18 +280,29 @@ int at_parse_cmer(const char *str, unsigned int map[5]) {
 }
 
 /**
- * Convert AT type into a human-readable string.
+ * Parse AT +XAPL SET command value.
  *
- * @param type AT message type.
- * @return Human-readable string. */
-const char *at_type2str(enum bt_at_type type) {
-	static const char *types[__AT_TYPE_MAX] = {
-		[AT_TYPE_RAW] = "RAW",
-		[AT_TYPE_CMD] = "CMD",
-		[AT_TYPE_CMD_GET] = "GET",
-		[AT_TYPE_CMD_SET] = "SET",
-		[AT_TYPE_CMD_TEST] = "TEST",
-		[AT_TYPE_RESP] = "RESP",
-	};
-	return types[type];
+ * @param str XAPL command value string.
+ * @param vendor Address where the vendor ID will be stored.
+ * @param product Address where the product ID will be stored.
+ * @param version Address where the version number will be stored.
+ * @param features Address where the features bitmap will be stored.
+ * @return On success this function returns 0, otherwise -1 is returned. */
+int at_parse_set_xapl(const char *str, uint16_t *vendor, uint16_t *product,
+		uint16_t *version, uint8_t *features) {
+
+	unsigned int _vendor, _product, _version, _features;
+	int n = 0;
+
+	if (sscanf(str, "%x-%x-%x,%u%n", &_vendor, &_product, &_version, &_features, &n) != 4)
+		return -1;
+	if (str[n] != '\0')
+		return -1;
+
+	*vendor = _vendor;
+	*product = _product;
+	*version = _version;
+	*features = _features;
+
+	return 0;
 }
diff --git a/src/at.h b/src/at.h
index d6c3a2a..d40138d 100644
--- a/src/at.h
+++ b/src/at.h
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - at.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
- *               2017 Juha Kuikka
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2017 Juha Kuikka
  *
  * This file is a part of bluez-alsa.
  *
@@ -15,6 +15,7 @@
 #pragma once
 #include <stdbool.h>
 #include <stddef.h>
+#include <stdint.h>
 
 #include "hfp.h"
 
@@ -34,12 +35,16 @@ struct bt_at {
 	char *value;
 };
 
+const char *at_type2str(enum bt_at_type type);
+
 char *at_build(char *buffer, size_t size, enum bt_at_type type,
 		const char *command, const char *value);
+
 char *at_parse(const char *str, struct bt_at *at);
-int at_parse_bia(const char *str, bool state[__HFP_IND_MAX]);
-int at_parse_cind(const char *str, enum hfp_ind map[20]);
-int at_parse_cmer(const char *str, unsigned int map[5]);
-const char *at_type2str(enum bt_at_type type);
+int at_parse_set_bia(const char *str, bool state[__HFP_IND_MAX]);
+int at_parse_get_cind(const char *str, enum hfp_ind map[20]);
+int at_parse_set_cmer(const char *str, unsigned int map[5]);
+int at_parse_set_xapl(const char *str, uint16_t *vendor, uint16_t *product,
+		uint16_t *version, uint8_t *features);
 
 #endif
diff --git a/src/audio.c b/src/audio.c
index f55b1d4..c384029 100644
--- a/src/audio.c
+++ b/src/audio.c
@@ -40,7 +40,7 @@ double audio_loudness_to_decibel(double value) {
 }
 
 /**
- * Join channnels into interleaved S16 PCM signal. */
+ * Join channels into interleaved S16 PCM signal. */
 void audio_interleave_s16_2le(const int16_t *ch1, const int16_t *ch2,
 		size_t frames, unsigned int channels, int16_t *dest) {
 	const int16_t *src[] = { ch1, ch2 };
@@ -51,7 +51,7 @@ void audio_interleave_s16_2le(const int16_t *ch1, const int16_t *ch2,
 }
 
 /**
- * Join channnels into interleaved S32 PCM signal. */
+ * Join channels into interleaved S32 PCM signal. */
 void audio_interleave_s32_4le(const int32_t *ch1, const int32_t *ch2,
 		size_t frames, unsigned int channels, int32_t *dest) {
 	const int32_t *src[] = { ch1, ch2 };
diff --git a/src/ba-adapter.c b/src/ba-adapter.c
index a4c680a..be27366 100644
--- a/src/ba-adapter.c
+++ b/src/ba-adapter.c
@@ -9,13 +9,13 @@
  */
 
 #include "ba-adapter.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 
-#include <bluetooth/bluetooth.h>
 #include <bluetooth/hci.h>
 #include <bluetooth/hci_lib.h>
 
@@ -161,7 +161,8 @@ int ba_adapter_get_hfp_features_hf(struct ba_adapter *a) {
 	int features = config.hfp.features_rfcomm_hf;
 	if (BA_TEST_ESCO_SUPPORT(a)) {
 #if ENABLE_MSBC
-		features |= HFP_HF_FEAT_CODEC;
+		if (config.hfp.codecs.msbc)
+			features |= HFP_HF_FEAT_CODEC;
 #endif
 		features |= HFP_HF_FEAT_ESCO;
 	}
@@ -172,7 +173,8 @@ int ba_adapter_get_hfp_features_ag(struct ba_adapter *a) {
 	int features = config.hfp.features_rfcomm_ag;
 	if (BA_TEST_ESCO_SUPPORT(a)) {
 #if ENABLE_MSBC
-		features |= HFP_AG_FEAT_CODEC;
+		if (config.hfp.codecs.msbc)
+			features |= HFP_AG_FEAT_CODEC;
 #endif
 		features |= HFP_AG_FEAT_ESCO;
 	}
diff --git a/src/ba-adapter.h b/src/ba-adapter.h
index 71f1121..b35c5a1 100644
--- a/src/ba-adapter.h
+++ b/src/ba-adapter.h
@@ -15,11 +15,12 @@
 # include <config.h>
 #endif
 
+#include <stdint.h>
 #include <pthread.h>
 
 #include <glib.h>
 
-#include <bluetooth/bluetooth.h>
+#include <bluetooth/bluetooth.h> /* IWYU pragma: keep */
 #include <bluetooth/hci.h>
 #include <bluetooth/hci_lib.h>
 
diff --git a/src/ba-device.c b/src/ba-device.c
index 705a72b..ac7d134 100644
--- a/src/ba-device.c
+++ b/src/ba-device.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - ba-device.c
- * Copyright (c) 2016-2019 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -17,6 +17,7 @@
 #include "ba-transport.h"
 #include "bluealsa-config.h"
 #include "hci.h"
+#include "storage.h"
 #include "shared/log.h"
 
 struct ba_device *ba_device_new(
@@ -49,6 +50,9 @@ struct ba_device *ba_device_new(
 	g_hash_table_insert(adapter->devices, &d->addr, d);
 	pthread_mutex_unlock(&adapter->devices_mutex);
 
+	/* load data from persistent storage */
+	storage_device_load(d);
+
 	return d;
 }
 
@@ -132,6 +136,9 @@ void ba_device_unref(struct ba_device *d) {
 	if (ref_count > 0)
 		return;
 
+	/* save persistent storage */
+	storage_device_save(d);
+
 	debug("Freeing device: %s", batostr_(&d->addr));
 	g_assert_cmpint(ref_count, ==, 0);
 
diff --git a/src/ba-device.h b/src/ba-device.h
index 55321d5..7f93c66 100644
--- a/src/ba-device.h
+++ b/src/ba-device.h
@@ -53,7 +53,7 @@ struct ba_device {
 
 		uint16_t vendor_id;
 		uint16_t product_id;
-		char software_version[8];
+		uint16_t sw_version;
 		uint8_t features;
 
 		/* determine whether headset is docked */
diff --git a/src/ba-rfcomm.c b/src/ba-rfcomm.c
index 52d5341..483156c 100644
--- a/src/ba-rfcomm.c
+++ b/src/ba-rfcomm.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - ba-rfcomm.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,10 +9,12 @@
  */
 
 #include "ba-rfcomm.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <poll.h>
 #include <pthread.h>
+#include <signal.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -28,7 +30,6 @@
 #include "bluealsa-config.h"
 #include "bluealsa-dbus.h"
 #include "bluez.h"
-#include "utils.h"
 #include "shared/defs.h"
 #include "shared/log.h"
 
@@ -125,10 +126,22 @@ retry:
  * HFP set state wrapper for debugging purposes. */
 static void rfcomm_set_hfp_state(struct ba_rfcomm *r, enum hfp_slc_state state) {
 	debug("RFCOMM: %s state transition: %d -> %d",
-			ba_transport_type_to_string(r->sco->type), r->state, state);
+			ba_transport_debug_name(r->sco), r->state, state);
 	r->state = state;
 }
 
+/**
+ * Finalize HFP codec selection - signal other threads. */
+static void rfcomm_finalize_codec_selection(struct ba_rfcomm *r) {
+
+	pthread_mutex_lock(&r->sco->codec_id_mtx);
+	r->codec_selection_done = true;
+	pthread_mutex_unlock(&r->sco->codec_id_mtx);
+
+	pthread_cond_signal(&r->codec_selection_cond);
+
+}
+
 /**
  * Handle AT command response code. */
 static int rfcomm_handler_resp_ok_cb(struct ba_rfcomm *r, const struct bt_at *at) {
@@ -155,12 +168,12 @@ static int rfcomm_handler_cind_test_cb(struct ba_rfcomm *r, const struct bt_at *
 	/* NOTE: The order of indicators in the CIND response message
 	 *       has to be consistent with the hfp_ind enumeration. */
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, "+CIND",
-				"(\"service\",(0-1))"
+				"(\"service\",(0,1))"
 				",(\"call\",(0,1))"
 				",(\"callsetup\",(0-3))"
 				",(\"callheld\",(0-2))"
 				",(\"signal\",(0-5))"
-				",(\"roam\",(0-1))"
+				",(\"roam\",(0,1))"
 				",(\"battchg\",(0-5))"
 			) == -1)
 		return -1;
@@ -198,7 +211,7 @@ static int rfcomm_handler_cind_get_cb(struct ba_rfcomm *r, const struct bt_at *a
  * RESP: Standard indicator update AT command */
 static int rfcomm_handler_cind_resp_test_cb(struct ba_rfcomm *r, const struct bt_at *at) {
 	/* parse response for the +CIND TEST command */
-	if (at_parse_cind(at->value, r->hfp_ind_map) == -1)
+	if (at_parse_get_cind(at->value, r->hfp_ind_map) == -1)
 		warn("Couldn't parse AG indicators: %s", at->value);
 	if (r->state < HFP_SLC_CIND_TEST)
 		rfcomm_set_hfp_state(r, HFP_SLC_CIND_TEST);
@@ -240,7 +253,7 @@ static int rfcomm_handler_cmer_set_cb(struct ba_rfcomm *r, const struct bt_at *a
 	const int fd = r->fd;
 	const char *resp = "OK";
 
-	if (at_parse_cmer(at->value, r->hfp_cmer) == -1) {
+	if (at_parse_set_cmer(at->value, r->hfp_cmer) == -1) {
 		warn("Couldn't parse CMER setup: %s", at->value);
 		resp = "ERROR";
 	}
@@ -286,7 +299,7 @@ static int rfcomm_handler_bia_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 	const int fd = r->fd;
 	const char *resp = "OK";
 
-	if (at_parse_bia(at->value, r->hfp_ind_state) == -1) {
+	if (at_parse_set_bia(at->value, r->hfp_ind_state) == -1) {
 		warn("Couldn't parse BIA indicators activation: %s", at->value);
 		resp = "ERROR";
 	}
@@ -296,6 +309,30 @@ static int rfcomm_handler_bia_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 	return 0;
 }
 
+#if !DEBUG
+# define debug_ag_features(features)
+#else
+static void debug_ag_features(uint32_t features) {
+	const char *names[32] = { NULL };
+	hfp_ag_features_to_strings(features, names, ARRAYSIZE(names));
+	char *tmp = g_strjoinv(", ", (char **)names);
+	debug("AG features [%u]: %s", features, tmp);
+	g_free(tmp);
+}
+#endif
+
+#if !DEBUG
+# define debug_hf_features(features)
+#else
+static void debug_hf_features(uint32_t features) {
+	const char *names[32] = { NULL };
+	hfp_hf_features_to_strings(features, names, ARRAYSIZE(names));
+	char *tmp = g_strjoinv(", ", (char **)names);
+	debug("HF features [%u]: %s", features, tmp);
+	g_free(tmp);
+}
+#endif
+
 /**
  * SET: Bluetooth Retrieve Supported Features */
 static int rfcomm_handler_brsf_set_cb(struct ba_rfcomm *r, const struct bt_at *at) {
@@ -304,14 +341,33 @@ static int rfcomm_handler_brsf_set_cb(struct ba_rfcomm *r, const struct bt_at *a
 	const int fd = r->fd;
 	char tmp[16];
 
-	r->hfp_features = atoi(at->value);
+	r->hf_features = atoi(at->value);
+
+	debug_ag_features(r->ag_features);
+	debug_hf_features(r->hf_features);
 
 	/* If codec negotiation is not supported in the HF, the AT+BAC
 	 * command will not be sent. So, we can assume default codec. */
-	if (!(r->hfp_features & HFP_HF_FEAT_CODEC))
+	if (!(r->hf_features & HFP_HF_FEAT_CODEC)) {
 		ba_transport_set_codec(t_sco, HFP_CODEC_CVSD);
+		r->hf_codecs.cvsd = true;
+	}
+
+	/* If codec negotiation is not supported on our side, the AT+BAC
+	 * command will not be sent as well. In that case we will have to
+	 * use some heuristic for determining which codecs are supported. */
+	if (!(r->ag_features & HFP_AG_FEAT_CODEC)) {
+		/* Assume that mandatory codec is supported. */
+		r->hf_codecs.cvsd = true;
+#if ENABLE_MSBC
+		/* If codec selection is supported assume that
+		 * mSBC is supported as well. */
+		if (r->hf_features & HFP_HF_FEAT_CODEC)
+			r->hf_codecs.msbc = true;
+#endif
+	}
 
-	sprintf(tmp, "%u", ba_adapter_get_hfp_features_ag(t_sco->d->a));
+	sprintf(tmp, "%u", r->ag_features);
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, "+BRSF", tmp) == -1)
 		return -1;
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
@@ -328,12 +384,27 @@ static int rfcomm_handler_brsf_set_cb(struct ba_rfcomm *r, const struct bt_at *a
 static int rfcomm_handler_brsf_resp_cb(struct ba_rfcomm *r, const struct bt_at *at) {
 
 	struct ba_transport * const t_sco = r->sco;
-	r->hfp_features = atoi(at->value);
+
+	r->ag_features = atoi(at->value);
+
+	debug_ag_features(r->ag_features);
+	debug_hf_features(r->hf_features);
 
 	/* codec negotiation is not supported in the AG */
-	if (!(r->hfp_features & HFP_AG_FEAT_CODEC))
+	if (!(r->ag_features & HFP_AG_FEAT_CODEC))
 		ba_transport_set_codec(t_sco, HFP_CODEC_CVSD);
 
+	/* Since CVSD is a mandatory codec,
+	 * we can assume that AG supports it. */
+	r->ag_codecs.cvsd = true;
+
+#if ENABLE_MSBC
+	/* If codec selection is supported in the AG, we
+	 * can assume that mSBC is supported as well. */
+	if (r->ag_features & HFP_AG_FEAT_CODEC)
+		r->ag_codecs.msbc = true;
+#endif
+
 	if (r->state < HFP_SLC_BRSF_SET)
 		rfcomm_set_hfp_state(r, HFP_SLC_BRSF_SET);
 
@@ -365,8 +436,12 @@ static int rfcomm_handler_vgm_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 		return rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK");
 
 	r->gain_mic = atoi(at->value);
-	int level = ba_transport_pcm_volume_bt_to_level(pcm, r->gain_mic);
+	int level = ba_transport_pcm_volume_range_to_level(r->gain_mic, HFP_VOLUME_GAIN_MAX);
+
+	pthread_mutex_lock(&pcm->mutex);
 	ba_transport_pcm_volume_set(&pcm->volume[0], &level, NULL, NULL);
+	pthread_mutex_unlock(&pcm->mutex);
+
 	bluealsa_dbus_pcm_update(pcm, BA_DBUS_PCM_UPDATE_VOLUME);
 
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
@@ -382,8 +457,12 @@ static int rfcomm_handler_vgm_resp_cb(struct ba_rfcomm *r, const struct bt_at *a
 	struct ba_transport_pcm *pcm = &t_sco->sco.mic_pcm;
 
 	r->gain_mic = atoi(at->value);
-	int level = ba_transport_pcm_volume_bt_to_level(pcm, r->gain_mic);
+	int level = ba_transport_pcm_volume_range_to_level(r->gain_mic, HFP_VOLUME_GAIN_MAX);
+
+	pthread_mutex_lock(&pcm->mutex);
 	ba_transport_pcm_volume_set(&pcm->volume[0], &level, NULL, NULL);
+	pthread_mutex_unlock(&pcm->mutex);
+
 	bluealsa_dbus_pcm_update(pcm, BA_DBUS_PCM_UPDATE_VOLUME);
 
 	return 0;
@@ -402,8 +481,12 @@ static int rfcomm_handler_vgs_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 		return rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK");
 
 	r->gain_spk = atoi(at->value);
-	int level = ba_transport_pcm_volume_bt_to_level(pcm, r->gain_spk);
+	int level = ba_transport_pcm_volume_range_to_level(r->gain_spk, HFP_VOLUME_GAIN_MAX);
+
+	pthread_mutex_lock(&pcm->mutex);
 	ba_transport_pcm_volume_set(&pcm->volume[0], &level, NULL, NULL);
+	pthread_mutex_unlock(&pcm->mutex);
+
 	bluealsa_dbus_pcm_update(pcm, BA_DBUS_PCM_UPDATE_VOLUME);
 
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
@@ -419,8 +502,12 @@ static int rfcomm_handler_vgs_resp_cb(struct ba_rfcomm *r, const struct bt_at *a
 	struct ba_transport_pcm *pcm = &t_sco->sco.spk_pcm;
 
 	r->gain_spk = atoi(at->value);
-	int level = ba_transport_pcm_volume_bt_to_level(pcm, r->gain_spk);
+	int level = ba_transport_pcm_volume_range_to_level(r->gain_spk, HFP_VOLUME_GAIN_MAX);
+
+	pthread_mutex_lock(&pcm->mutex);
 	ba_transport_pcm_volume_set(&pcm->volume[0], &level, NULL, NULL);
+	pthread_mutex_unlock(&pcm->mutex);
+
 	bluealsa_dbus_pcm_update(pcm, BA_DBUS_PCM_UPDATE_VOLUME);
 
 	return 0;
@@ -438,14 +525,24 @@ static int rfcomm_handler_btrh_get_cb(struct ba_rfcomm *r, const struct bt_at *a
 	return 0;
 }
 
+#if ENABLE_MSBC
+static int rfcomm_hfp_setup_codec_connection(struct ba_rfcomm *r);
+#endif
+
 /**
  * SET: Bluetooth Codec Connection */
 static int rfcomm_handler_bcc_cmd_cb(struct ba_rfcomm *r, const struct bt_at *at) {
 	(void)at;
 	const int fd = r->fd;
-	/* TODO: Start Codec Connection procedure because HF wants to send audio. */
+#if ENABLE_MSBC
+	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
+		return -1;
+	if (rfcomm_hfp_setup_codec_connection(r) == -1)
+		return -1;
+#else
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "ERROR") == -1)
 		return -1;
+#endif
 	return 0;
 }
 
@@ -455,46 +552,40 @@ static int rfcomm_handler_bcs_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 
 	struct ba_transport * const t_sco = r->sco;
 	const int fd = r->fd;
-	int codec;
+	int rv;
 
+	int codec;
 	if ((codec = atoi(at->value)) != r->codec) {
 		warn("Codec not acknowledged: %s != %d", at->value, r->codec);
-		if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "ERROR") == -1)
-			return -1;
+		rv = rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "ERROR");
 		goto final;
 	}
 
-	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
-		return -1;
+	if ((rv = rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK")) == -1)
+		goto final;
 
 	/* Codec negotiation process is complete. Update transport and
 	 * notify connected clients, that transport has been changed. */
 	ba_transport_set_codec(t_sco, codec);
 
 final:
-	pthread_cond_signal(&r->codec_selection_completed);
-	return 0;
+	rfcomm_finalize_codec_selection(r);
+	return rv;
 }
 
 static int rfcomm_handler_resp_bcs_ok_cb(struct ba_rfcomm *r, const struct bt_at *at) {
 
 	struct ba_transport * const t_sco = r->sco;
 
-	if (rfcomm_handler_resp_ok_cb(r, at) == -1)
+	if ((rfcomm_handler_resp_ok_cb(r, at)) == -1)
 		return -1;
 
 	if (!r->handler_resp_ok_success) {
 		warn("Codec selection not finalized: %d", r->codec);
-		goto final;
+		ba_transport_set_codec(t_sco, HFP_CODEC_UNDEFINED);
+		rfcomm_finalize_codec_selection(r);
 	}
 
-	/* Finalize codec selection process and notify connected clients, that
-	 * transport has been changed. Note, that this event might be emitted
-	 * for an active transport - switching initiated by Audio Gateway. */
-	ba_transport_set_codec(t_sco, r->codec);
-
-final:
-	pthread_cond_signal(&r->codec_selection_completed);
 	return 0;
 }
 
@@ -502,14 +593,44 @@ final:
  * RESP: Bluetooth Codec Selection */
 static int rfcomm_handler_bcs_resp_cb(struct ba_rfcomm *r, const struct bt_at *at) {
 
-	static const struct ba_rfcomm_handler handler = {
+	static const struct ba_rfcomm_handler handler_supported = {
 		AT_TYPE_RESP, "", rfcomm_handler_resp_bcs_ok_cb };
+	static const struct ba_rfcomm_handler handler_unsupported = {
+		AT_TYPE_RESP, "", rfcomm_handler_resp_ok_cb };
+
 	const int fd = r->fd;
+	const int codec = atoi(at->value);
+	bool codec_supported = false;
+
+	if (r->hf_codecs.cvsd && codec == HFP_CODEC_CVSD)
+		codec_supported = true;
+#ifdef ENABLE_MSBC
+	if (r->hf_codecs.msbc && codec == HFP_CODEC_MSBC)
+		codec_supported = true;
+#endif
+
+	if (!codec_supported) {
+		/* If the requested codec is not supported, we must reply with the
+		 * list of codecs that we do support. */
+		if (rfcomm_write_at(fd, AT_TYPE_CMD_SET, "+BAC", r->hf_bac_bcs_string) == -1)
+			return -1;
+		r->handler = &handler_unsupported;
+		return 0;
+	}
 
-	r->codec = atoi(at->value);
+	r->codec = codec;
 	if (rfcomm_write_at(fd, AT_TYPE_CMD_SET, "+BCS", at->value) == -1)
 		return -1;
-	r->handler = &handler;
+	r->handler = &handler_supported;
+
+	/* The oFono AG, and possibly other AG implementations too, does not
+	 * send the "OK" confirmation until it has successfully connected a
+	 * SCO socket. So to support such an AG we must set the selected codec
+	 * here and notify connected clients, that the transport has been
+	 * changed. Note, that this event might be emitted for an active
+	 * transport - codec switching initiated by Audio Gateway. */
+	ba_transport_set_codec(r->sco, r->codec);
+	rfcomm_finalize_codec_selection(r);
 
 	return 0;
 }
@@ -520,32 +641,39 @@ static int rfcomm_handler_bac_set_cb(struct ba_rfcomm *r, const struct bt_at *at
 
 	const int fd = r->fd;
 	char *tmp = at->value - 1;
+	int rv;
 
 	/* We shall use the information on codecs available in HF
 	 * from the most recently received AT+BAC command. */
-	memset(&r->codecs, 0, sizeof(r->codecs));
+	memset(&r->hf_codecs, 0, sizeof(r->hf_codecs));
 
 	do {
 		tmp += 1;
 		switch (atoi(tmp)) {
 		case HFP_CODEC_CVSD:
-				r->codecs.cvsd = true;
+			r->hf_codecs.cvsd = true;
 			break;
 #if ENABLE_MSBC
 		case HFP_CODEC_MSBC:
-				r->codecs.msbc = true;
+			r->hf_codecs.msbc = true;
 			break;
 #endif
 		}
 	} while ((tmp = strchr(tmp, ',')) != NULL);
 
-	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
-		return -1;
+	if ((rv = rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK")) == -1)
+		goto final;
 
 	if (r->state < HFP_SLC_BAC_SET_OK)
 		rfcomm_set_hfp_state(r, HFP_SLC_BAC_SET_OK);
 
-	return 0;
+final:
+	if (r->state == HFP_SLC_CONNECTED)
+		/* We can receive the AT+BAC command as a response to AT+BSC in case of
+		 * invalid codec selection. In such case, we shall finalize current codec
+		 * selection procedure. */
+		rfcomm_finalize_codec_selection(r);
+	return rv;
 }
 
 /**
@@ -557,10 +685,13 @@ static int rfcomm_handler_android_set_xhsmicmute(struct ba_rfcomm *r, char *valu
 
 	struct ba_transport * const t_sco = r->sco;
 	struct ba_transport_pcm *pcm = &t_sco->sco.mic_pcm;
+	const bool muted = value[0] == '0' ? false : true;
 	const int fd = r->fd;
 
-	bool muted = value[0] == '0' ? false : true;
+	pthread_mutex_lock(&pcm->mutex);
 	ba_transport_pcm_volume_set(&pcm->volume[0], NULL, NULL, &muted);
+	pthread_mutex_unlock(&pcm->mutex);
+
 	bluealsa_dbus_pcm_update(pcm, BA_DBUS_PCM_UPDATE_VOLUME);
 
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
@@ -683,30 +814,18 @@ static int rfcomm_handler_xapl_set_cb(struct ba_rfcomm *r, const struct bt_at *a
 	struct ba_device * const d = r->sco->d;
 	const int fd = r->fd;
 
-	unsigned int vendor, product;
-	char version[sizeof(d->xapl.software_version)];
-	char resp[32];
-	char *tmp;
-
-	if ((tmp = strrchr(at->value, ',')) == NULL) {
+	if (at_parse_set_xapl(at->value, &d->xapl.vendor_id, &d->xapl.product_id,
+				&d->xapl.sw_version, &d->xapl.features) == -1) {
 		warn("Invalid +XAPL value: %s", at->value);
 		if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "ERROR") == -1)
 			return -1;
 		return 0;
 	}
 
-	d->xapl.features = atoi(tmp + 1);
-	*tmp = '\0';
-
-	if (sscanf(at->value, "%x-%x-%7s", &vendor, &product, version) != 3)
-		warn("Couldn't parse +XAPL vendor and product: %s", at->value);
-
-	d->xapl.vendor_id = vendor;
-	d->xapl.product_id = product;
-	strcpy(d->xapl.software_version, version);
-
+	char resp[32];
 	snprintf(resp, sizeof(resp), "+XAPL=%s,%u",
 			config.hfp.xapl_product_name, config.hfp.xapl_features);
+
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, resp) == -1)
 		return -1;
 	if (rfcomm_write_at(fd, AT_TYPE_RESP, NULL, "OK") == -1)
@@ -839,41 +958,85 @@ static enum ba_rfcomm_signal rfcomm_recv_signal(struct ba_rfcomm *r) {
 }
 
 #if ENABLE_MSBC
+
 /**
- * Try to setup HFP codec connection. */
-static int rfcomm_set_hfp_codec(struct ba_rfcomm *r, uint16_t codec) {
+ * Set HFP codec for the given Service Level Connection. */
+static int rfcomm_hfp_set_codec(struct ba_rfcomm *r, uint16_t codec) {
 
 	struct ba_transport * const t_sco = r->sco;
 	const int fd = r->fd;
-	char tmp[16];
+	int rv = 0;
 
 	debug("RFCOMM: %s setting codec: %s",
-			ba_transport_type_to_string(t_sco->type),
+			ba_transport_debug_name(t_sco),
 			hfp_codec_id_to_string(codec));
 
-	/* Codec selection can be requested only after SLC establishment. */
-	if (r->state != HFP_SLC_CONNECTED) {
-		/* If codec selection was requested by some other thread by calling the
-		 * ba_transport_select_codec(), we have to signal it that the selection
-		 * procedure has been completed. */
-		pthread_cond_signal(&r->codec_selection_completed);
+	/* SLC is required for codec connection */
+	if (r->state != HFP_SLC_CONNECTED)
+		goto fail;
+
+	/* only AG can set codec */
+	if (!(t_sco->profile & BA_TRANSPORT_PROFILE_HFP_AG))
+		goto fail;
+
+	char tmp[16];
+	sprintf(tmp, "%d", codec);
+	if ((rv = rfcomm_write_at(fd, AT_TYPE_RESP, "+BCS", tmp)) == -1)
+		goto fail;
+
+	r->codec = codec;
+	r->handler = &rfcomm_handler_bcs_set;
+	return 0;
+
+fail:
+	rfcomm_finalize_codec_selection(r);
+	return rv;
+}
+
+/**
+ * Try to setup HFP codec connection. */
+static int rfcomm_hfp_setup_codec_connection(struct ba_rfcomm *r) {
+
+	struct {
+		uint16_t codec_id;
+		bool is_supported;
+	} codecs[] = {
+		{ HFP_CODEC_MSBC, r->ag_codecs.msbc && r->hf_codecs.msbc },
+		{ HFP_CODEC_CVSD, r->ag_codecs.cvsd && r->hf_codecs.cvsd },
+	};
+
+	struct ba_transport * const t_sco = r->sco;
+	const int fd = r->fd;
+	int rv;
+
+	/* SLC is required for codec connection */
+	if (r->state != HFP_SLC_CONNECTED)
+		return 0;
+
+	/* nothing to do if codec is already selected */
+	if (ba_transport_get_codec(t_sco) != HFP_CODEC_UNDEFINED)
 		return 0;
-	}
 
-	/* for AG request codec selection using unsolicited response code */
-	if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_AG) {
-		sprintf(tmp, "%d", codec);
-		if (rfcomm_write_at(fd, AT_TYPE_RESP, "+BCS", tmp) == -1)
+	/* Only AG can initialize codec connection. So, for HF we need to request
+	 * codec selection from AG by sending AT+BCC command. */
+	if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_HF) {
+		if ((rv = rfcomm_write_at(fd, AT_TYPE_CMD, "+BCC", NULL)) == -1)
 			return -1;
-		r->codec = codec;
-		r->handler = &rfcomm_handler_bcs_set;
+		r->handler = &rfcomm_handler_resp_ok;
 		return 0;
 	}
 
-	/* TODO: Send codec connection initialization request to AG. */
-	pthread_cond_signal(&r->codec_selection_completed);
+	for (size_t i = 0; i < ARRAYSIZE(codecs); i++) {
+		if (!codecs[i].is_supported)
+			continue;
+		if ((rv = rfcomm_hfp_set_codec(r, codecs[i].codec_id)) == -1)
+			return -1;
+		break;
+	}
+
 	return 0;
 }
+
 #endif
 
 /**
@@ -884,16 +1047,21 @@ static int rfcomm_notify_battery_level_change(struct ba_rfcomm *r) {
 	const int fd = r->fd;
 	char tmp[32];
 
+	if (!config.battery.available)
+		return 0;
+
 	/* for HFP-AG return battery level indicator if reporting is enabled */
-	if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_AG &&
+	if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_AG &&
 			r->hfp_cmer[3] > 0 && r->hfp_ind_state[HFP_IND_BATTCHG]) {
-		sprintf(tmp, "%d,%d", HFP_IND_BATTCHG, (config.battery.level + 1) / 17);
-		return rfcomm_write_at(fd, AT_TYPE_RESP, "+CIND", tmp);
+		const unsigned int level = config.battery.level * 6 / 100;
+		sprintf(tmp, "%d,%d", HFP_IND_BATTCHG, MIN(level, 5));
+		return rfcomm_write_at(fd, AT_TYPE_RESP, "+CIEV", tmp);
 	}
 
-	if (t_sco->type.profile & BA_TRANSPORT_PROFILE_MASK_HF &&
+	if (t_sco->profile & BA_TRANSPORT_PROFILE_MASK_HF &&
 			t_sco->d->xapl.features & (XAPL_FEATURE_BATTERY | XAPL_FEATURE_DOCKING)) {
-		sprintf(tmp, "2,1,%d,2,0", (config.battery.level + 1) / 10);
+		const unsigned int level = config.battery.level * 10 / 100;
+		sprintf(tmp, "2,1,%d,2,0", MIN(level, 9));
 		if (rfcomm_write_at(fd, AT_TYPE_CMD_SET, "+IPHONEACCEV", tmp) == -1)
 			return -1;
 		r->handler = &rfcomm_handler_resp_ok;
@@ -911,7 +1079,8 @@ static int rfcomm_notify_volume_change_mic(struct ba_rfcomm *r, bool force) {
 	const int fd = r->fd;
 	char tmp[24];
 
-	int gain = ba_transport_pcm_volume_level_to_bt(pcm, pcm->volume[0].level);
+	int gain = ba_transport_pcm_volume_level_to_range(
+			pcm->volume[0].level, HFP_VOLUME_GAIN_MAX);
 	if (!force && r->gain_mic == gain)
 		return 0;
 
@@ -919,8 +1088,9 @@ static int rfcomm_notify_volume_change_mic(struct ba_rfcomm *r, bool force) {
 	debug("Updating microphone gain: %d", gain);
 
 	/* for AG return unsolicited response code */
-	if (t_sco->type.profile & BA_TRANSPORT_PROFILE_MASK_AG) {
-		sprintf(tmp, "+VGM=%d", gain);
+	if (t_sco->profile & BA_TRANSPORT_PROFILE_MASK_AG) {
+		bool is_hsp = t_sco->profile & BA_TRANSPORT_PROFILE_MASK_HSP;
+		sprintf(tmp, "+VGM%c%d", is_hsp ? '=' : ':', gain);
 		return rfcomm_write_at(fd, AT_TYPE_RESP, NULL, tmp);
 	}
 
@@ -941,7 +1111,8 @@ static int rfcomm_notify_volume_change_spk(struct ba_rfcomm *r, bool force) {
 	const int fd = r->fd;
 	char tmp[24];
 
-	int gain = ba_transport_pcm_volume_level_to_bt(pcm, pcm->volume[0].level);
+	int gain = ba_transport_pcm_volume_level_to_range(
+			pcm->volume[0].level, HFP_VOLUME_GAIN_MAX);
 	if (!force && r->gain_spk == gain)
 		return 0;
 
@@ -949,8 +1120,9 @@ static int rfcomm_notify_volume_change_spk(struct ba_rfcomm *r, bool force) {
 	debug("Updating speaker gain: %d", gain);
 
 	/* for AG return unsolicited response code */
-	if (t_sco->type.profile & BA_TRANSPORT_PROFILE_MASK_AG) {
-		sprintf(tmp, "+VGS=%d", gain);
+	if (t_sco->profile & BA_TRANSPORT_PROFILE_MASK_AG) {
+		bool is_hsp = t_sco->profile & BA_TRANSPORT_PROFILE_MASK_HSP;
+		sprintf(tmp, "+VGS%c%d", is_hsp ? '=' : ':', gain);
 		return rfcomm_write_at(fd, AT_TYPE_RESP, NULL, tmp);
 	}
 
@@ -1003,6 +1175,12 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(rfcomm_thread_cleanup), r);
 
+	sigset_t sigset;
+	/* See the ba_transport_thread_create() function for information
+	 * why we have to mask all signals. */
+	sigfillset(&sigset);
+	pthread_sigmask(SIG_SETMASK, &sigset, NULL);
+
 	struct ba_transport * const t_sco = r->sco;
 	struct at_reader reader = { .next = NULL };
 	struct pollfd pfds[] = {
@@ -1011,7 +1189,7 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 		{ -1, POLLIN, 0 },
 	};
 
-	debug("Starting RFCOMM loop: %s", ba_transport_type_to_string(t_sco->type));
+	debug("Starting RFCOMM loop: %s", ba_transport_debug_name(t_sco));
 	for (;;) {
 
 		/* During normal operation, RFCOMM should block indefinitely. However,
@@ -1045,15 +1223,17 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 				goto ioerror;
 			}
 
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_MASK_HSP)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_MASK_HSP) {
 				/* There is not logic behind the HSP connection,
 				 * simply set status as connected. */
 				rfcomm_set_hfp_state(r, HFP_SLC_CONNECTED);
+				goto setup;
+			}
 
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_HF)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_HF)
 				switch (r->state) {
 				case HFP_DISCONNECTED:
-					sprintf(tmp, "%u", ba_adapter_get_hfp_features_hf(t_sco->d->a));
+					sprintf(tmp, "%u", r->hf_features);
 					if (rfcomm_write_at(pfds[1].fd, AT_TYPE_CMD_SET, "+BRSF", tmp) == -1)
 						goto ioerror;
 					r->handler = &rfcomm_handler_brsf_resp;
@@ -1063,16 +1243,11 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 					r->handler_resp_ok_new_state = HFP_SLC_BRSF_SET_OK;
 					break;
 				case HFP_SLC_BRSF_SET_OK:
-					if (r->hfp_features & HFP_AG_FEAT_CODEC) {
-						char *ptr = tmp;
-						if (config.hfp.codecs.cvsd)
-							ptr += sprintf(ptr, "%u", HFP_CODEC_CVSD);
-#if ENABLE_MSBC
-						if (config.hfp.codecs.msbc && BA_TEST_ESCO_SUPPORT(t_sco->d->a))
-							ptr += sprintf(ptr, "%s%u", ptr != tmp ? "," : "", HFP_CODEC_MSBC);
-#endif
-						/* advertise which HFP codecs we are supporting */
-						if (rfcomm_write_at(pfds[1].fd, AT_TYPE_CMD_SET, "+BAC", tmp) == -1)
+					/* Process with codecs advertisement only if both
+					 * sides support the codec negotiation feature. */
+					if (r->ag_features & HFP_AG_FEAT_CODEC &&
+							r->hf_features & HFP_HF_FEAT_CODEC) {
+						if (rfcomm_write_at(pfds[1].fd, AT_TYPE_CMD_SET, "+BAC", r->hf_bac_bcs_string) == -1)
 							goto ioerror;
 						r->handler = &rfcomm_handler_resp_ok;
 						r->handler_resp_ok_new_state = HFP_SLC_BAC_SET_OK;
@@ -1109,13 +1284,17 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 					rfcomm_set_hfp_state(r, HFP_SLC_CONNECTED);
 					/* fall-through */
 				case HFP_SLC_CONNECTED:
-					bluealsa_dbus_pcm_update(&t_sco->sco.spk_pcm,
-							BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
-					bluealsa_dbus_pcm_update(&t_sco->sco.mic_pcm,
-							BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+					/* If codec was selected during the SLC establishment,
+					 * notify BlueALSA D-Bus clients about the change. */
+					if (ba_transport_get_codec(t_sco) != HFP_CODEC_UNDEFINED) {
+						bluealsa_dbus_pcm_update(&t_sco->sco.spk_pcm,
+								BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+						bluealsa_dbus_pcm_update(&t_sco->sco.mic_pcm,
+								BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+					}
 				}
 
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_AG)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_AG)
 				switch (r->state) {
 				case HFP_DISCONNECTED:
 				case HFP_SLC_BRSF_SET:
@@ -1130,16 +1309,21 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 					rfcomm_set_hfp_state(r, HFP_SLC_CONNECTED);
 					/* fall-through */
 				case HFP_SLC_CONNECTED:
-					bluealsa_dbus_pcm_update(&t_sco->sco.spk_pcm,
-							BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
-					bluealsa_dbus_pcm_update(&t_sco->sco.mic_pcm,
-							BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+					/* If codec was selected during the SLC establishment,
+					 * notify BlueALSA D-Bus clients about the change. */
+					if (ba_transport_get_codec(t_sco) != HFP_CODEC_UNDEFINED) {
+						bluealsa_dbus_pcm_update(&t_sco->sco.spk_pcm,
+								BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+						bluealsa_dbus_pcm_update(&t_sco->sco.mic_pcm,
+								BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
+					}
 				}
 
 		}
 		else if (r->setup != HFP_SETUP_COMPLETE) {
+setup:
 
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HSP_AG)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_HSP_AG)
 				/* We are not making any initialization setup with
 				 * HSP AG. Simply mark setup as completed. */
 				r->setup = HFP_SETUP_COMPLETE;
@@ -1147,7 +1331,7 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 			/* Notify audio gateway about our initial setup. This setup
 			 * is dedicated for HSP and HFP, because both profiles have
 			 * volume gain control and Apple accessory extension. */
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_MASK_HF)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_MASK_HF)
 				switch (r->setup) {
 				case HFP_SETUP_GAIN_MIC:
 					if (rfcomm_notify_volume_change_mic(r, true) == -1)
@@ -1160,50 +1344,47 @@ static void *rfcomm_thread(struct ba_rfcomm *r) {
 					r->setup++;
 					break;
 				case HFP_SETUP_ACCESSORY_XAPL:
-					sprintf(tmp, "%04X-%04X-%s,%u",
+					sprintf(tmp, "%04X-%04X-%04X,%u",
 							config.hfp.xapl_vendor_id, config.hfp.xapl_product_id,
-							config.hfp.xapl_software_version, config.hfp.xapl_features);
+							config.hfp.xapl_sw_version, config.hfp.xapl_features);
 					if (rfcomm_write_at(r->fd, AT_TYPE_CMD_SET, "+XAPL", tmp) == -1)
 						goto ioerror;
 					r->handler = &rfcomm_handler_xapl_resp;
 					r->setup++;
 					break;
 				case HFP_SETUP_ACCESSORY_BATT:
-					if (config.battery.available &&
-							rfcomm_notify_battery_level_change(r) == -1)
+					if (rfcomm_notify_battery_level_change(r) == -1)
 						goto ioerror;
 					r->setup++;
 					break;
+				case HFP_SETUP_SELECT_CODEC:
+#if ENABLE_MSBC
+					if (r->idle) {
+						if (rfcomm_hfp_setup_codec_connection(r) == -1)
+							goto ioerror;
+						r->setup++;
+					}
+#else
+					r->setup++;
+#endif
+					/* fall-through */
 				case HFP_SETUP_COMPLETE:
 					debug("Initial connection setup completed");
 				}
 
 			/* If HFP transport codec is already selected (e.g. device
 			 * does not support mSBC) mark setup as completed. */
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_AG &&
-					t_sco->type.codec != HFP_CODEC_UNDEFINED)
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_AG &&
+					ba_transport_get_codec(t_sco) != HFP_CODEC_UNDEFINED)
 				r->setup = HFP_SETUP_COMPLETE;
 
 #if ENABLE_MSBC
 			/* Select HFP transport codec. Please note, that this setup
 			 * stage will be performed when the connection becomes idle. */
-			if (t_sco->type.profile & BA_TRANSPORT_PROFILE_HFP_AG &&
-					t_sco->type.codec == HFP_CODEC_UNDEFINED &&
+			if (t_sco->profile & BA_TRANSPORT_PROFILE_HFP_AG &&
 					r->idle) {
-				struct {
-					uint16_t codec_id;
-					bool is_supported;
-				} codecs[] = {
-					{ HFP_CODEC_MSBC, config.hfp.codecs.msbc && r->codecs.msbc },
-					{ HFP_CODEC_CVSD, config.hfp.codecs.cvsd && r->codecs.cvsd },
-				};
-				for (size_t i = 0; i < ARRAYSIZE(codecs); i++) {
-					if (!codecs[i].is_supported)
-						continue;
-					if (rfcomm_set_hfp_codec(r, codecs[i].codec_id) == -1)
-						goto ioerror;
-					break;
-				}
+				if (rfcomm_hfp_setup_codec_connection(r) == -1)
+					goto ioerror;
 				r->setup = HFP_SETUP_COMPLETE;
 			}
 #endif
@@ -1224,11 +1405,14 @@ process:
 		if (reader.next != NULL)
 			goto read;
 
-		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
-
 		r->idle = false;
 		pfds[2].fd = r->handler_fd;
-		switch (poll(pfds, ARRAYSIZE(pfds), timeout)) {
+
+		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+		int poll_rv = poll(pfds, ARRAYSIZE(pfds), timeout);
+		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+
+		switch (poll_rv) {
 		case 0:
 			debug("RFCOMM poll timeout");
 			r->idle = true;
@@ -1240,20 +1424,26 @@ process:
 			goto fail;
 		}
 
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-
 		if (pfds[0].revents & POLLIN) {
 			/* dispatch incoming event */
 			switch (rfcomm_recv_signal(r)) {
 #if ENABLE_MSBC
 			case BA_RFCOMM_SIGNAL_HFP_SET_CODEC_CVSD:
-				if (config.hfp.codecs.cvsd &&
-						rfcomm_set_hfp_codec(r, HFP_CODEC_CVSD) == -1)
+				if (!config.hfp.codecs.cvsd || !(
+							r->ag_features & HFP_AG_FEAT_CODEC &&
+							r->hf_features & HFP_HF_FEAT_CODEC))
+					rfcomm_finalize_codec_selection(r);
+				else if (rfcomm_hfp_set_codec(r, HFP_CODEC_CVSD) == -1)
 					goto ioerror;
 				break;
 			case BA_RFCOMM_SIGNAL_HFP_SET_CODEC_MSBC:
-				if (config.hfp.codecs.msbc &&
-						rfcomm_set_hfp_codec(r, HFP_CODEC_MSBC) == -1)
+				if (!config.hfp.codecs.msbc || !(
+							r->ag_features & HFP_AG_FEAT_CODEC &&
+							r->ag_features & HFP_AG_FEAT_ESCO &&
+							r->hf_features & HFP_HF_FEAT_CODEC &&
+							r->hf_features & HFP_HF_FEAT_ESCO))
+					rfcomm_finalize_codec_selection(r);
+				else if (rfcomm_hfp_set_codec(r, HFP_CODEC_MSBC) == -1)
 					goto ioerror;
 				break;
 #endif
@@ -1368,7 +1558,6 @@ ioerror:
 	}
 
 fail:
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_pop(1);
 	return NULL;
 }
@@ -1392,19 +1581,64 @@ struct ba_rfcomm *ba_rfcomm_new(struct ba_transport *sco, int fd) {
 	r->sco = ba_transport_ref(sco);
 	r->link_lost_quirk = true;
 
-	/* initialize data used for synchronization */
-	r->gain_mic = ba_transport_pcm_volume_level_to_bt(
-			&r->sco->sco.mic_pcm, r->sco->sco.mic_pcm.volume[0].level);
-	r->gain_spk = ba_transport_pcm_volume_level_to_bt(
-			&r->sco->sco.spk_pcm, r->sco->sco.spk_pcm.volume[0].level);
+	/* Initialize HFP feature masks and codec flags. Values for the remote
+	 * device will be set during the SLC establishment. */
+
+	if (sco->profile & BA_TRANSPORT_PROFILE_HFP_AG)
+		r->ag_features = ba_adapter_get_hfp_features_ag(sco->d->a);
+	if (sco->profile & BA_TRANSPORT_PROFILE_HFP_HF)
+		r->hf_features = ba_adapter_get_hfp_features_hf(sco->d->a);
+
+	/* HSP does not support codec negotiation, so we can set the codec
+	 * flag right away. */
+	if (sco->profile & BA_TRANSPORT_PROFILE_MASK_HSP) {
+		r->ag_codecs.cvsd = true;
+		r->hf_codecs.cvsd = true;
+	}
+
+	if (sco->profile & BA_TRANSPORT_PROFILE_HFP_AG) {
+		if (config.hfp.codecs.cvsd)
+			r->ag_codecs.cvsd = true;
+#if ENABLE_MSBC
+		if (config.hfp.codecs.msbc && r->ag_features & HFP_AG_FEAT_ESCO)
+			r->ag_codecs.msbc = true;
+#endif
+	}
+
+	if (sco->profile & BA_TRANSPORT_PROFILE_HFP_HF) {
+
+		char *ptr = r->hf_bac_bcs_string;
+
+		if (config.hfp.codecs.cvsd) {
+			ptr += sprintf(ptr, "%u", HFP_CODEC_CVSD);
+			r->hf_codecs.cvsd = true;
+		}
+
+#if ENABLE_MSBC
+		if (config.hfp.codecs.msbc && r->hf_features & HFP_HF_FEAT_ESCO) {
+			const bool first = ptr == r->hf_bac_bcs_string;
+			ptr += sprintf(ptr, "%s%u", first ? "" : ",", HFP_CODEC_MSBC);
+			r->hf_codecs.msbc = true;
+		}
+#endif
+
+	}
+
+	/* By default, all indicators are enabled. */
+	memset(&r->hfp_ind_state, 1, sizeof(r->hfp_ind_state));
+
+	/* Initialize data used for volume gain synchronization. */
+	r->gain_mic = ba_transport_pcm_volume_level_to_range(
+			sco->sco.mic_pcm.volume[0].level, HFP_VOLUME_GAIN_MAX);
+	r->gain_spk = ba_transport_pcm_volume_level_to_range(
+			sco->sco.spk_pcm.volume[0].level, HFP_VOLUME_GAIN_MAX);
 
 	if (pipe(r->sig_fd) == -1)
 		goto fail;
 
-	pthread_mutex_init(&r->codec_selection_completed_mtx, NULL);
-	pthread_cond_init(&r->codec_selection_completed, NULL);
+	pthread_cond_init(&r->codec_selection_cond, NULL);
 
-	if ((err = pthread_create(&r->thread, NULL, PTHREAD_ROUTINE(rfcomm_thread), r)) != 0) {
+	if ((err = pthread_create(&r->thread, NULL, PTHREAD_FUNC(rfcomm_thread), r)) != 0) {
 		error("Couldn't create RFCOMM thread: %s", strerror(err));
 		r->thread = config.main_thread;
 		goto fail;
@@ -1413,7 +1647,7 @@ struct ba_rfcomm *ba_rfcomm_new(struct ba_transport *sco, int fd) {
 	const char *name = "ba-rfcomm";
 	pthread_setname_np(r->thread, name);
 	debug("Created new RFCOMM thread [%s]: %s",
-			name, ba_transport_type_to_string(sco->type));
+			name, ba_transport_debug_name(sco));
 
 	r->ba_dbus_path = g_strdup_printf("%s/rfcomm", sco->d->ba_dbus_path);
 	bluealsa_dbus_rfcomm_register(r);
@@ -1439,12 +1673,18 @@ void ba_rfcomm_destroy(struct ba_rfcomm *r) {
 	 * RFCOMM thread during the destroy procedure. */
 	bluealsa_dbus_rfcomm_unregister(r);
 
-	if (!pthread_equal(r->thread, config.main_thread) &&
-			!pthread_equal(r->thread, pthread_self())) {
-		if ((err = pthread_cancel(r->thread)) != 0 && err != ESRCH)
-			warn("Couldn't cancel RFCOMM thread: %s", strerror(err));
-		if ((err = pthread_join(r->thread, NULL)) != 0)
-			warn("Couldn't join RFCOMM thread: %s", strerror(err));
+	if (!pthread_equal(r->thread, config.main_thread)) {
+		if (!pthread_equal(r->thread, pthread_self())) {
+			if ((err = pthread_cancel(r->thread)) != 0 && err != ESRCH)
+				warn("Couldn't cancel RFCOMM thread: %s", strerror(err));
+			if ((err = pthread_join(r->thread, NULL)) != 0)
+				warn("Couldn't join RFCOMM thread: %s", strerror(err));
+		}
+		else {
+			/* It seems that the thread is being destroyed by the link lost
+			 * quirk. Detach itself so we will not leak resources. */
+			pthread_detach(r->thread);
+		}
 	}
 
 	if (r->handler_fd != -1)
@@ -1461,8 +1701,7 @@ void ba_rfcomm_destroy(struct ba_rfcomm *r) {
 	if (r->ba_dbus_path != NULL)
 		g_free(r->ba_dbus_path);
 
-	pthread_mutex_destroy(&r->codec_selection_completed_mtx);
-	pthread_cond_destroy(&r->codec_selection_completed);
+	pthread_cond_destroy(&r->codec_selection_cond);
 
 	free(r);
 }
diff --git a/src/ba-rfcomm.h b/src/ba-rfcomm.h
index e1f3b7d..a95cb56 100644
--- a/src/ba-rfcomm.h
+++ b/src/ba-rfcomm.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - ba-rfcomm.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -17,6 +17,7 @@
 #endif
 
 #include <pthread.h>
+#include <stdatomic.h>
 #include <stdbool.h>
 #include <stdint.h>
 
@@ -39,6 +40,13 @@ enum ba_rfcomm_signal {
 	BA_RFCOMM_SIGNAL_UPDATE_VOLUME,
 };
 
+struct ba_rfcomm_hfp_codecs {
+	bool cvsd;
+#if ENABLE_MSBC
+	bool msbc;
+#endif
+};
+
 /**
  * Data associated with RFCOMM communication. */
 struct ba_rfcomm {
@@ -75,20 +83,23 @@ struct ba_rfcomm {
 	/* number of failed communication attempts */
 	int retries;
 
-	/* AG/HF supported features bitmask */
-	uint32_t hfp_features;
+	/* AG supported feature mask */
+	uint32_t ag_features;
+	/* HF supported feature mask */
+	uint32_t hf_features;
 
-	/* HF supported HFP codecs */
-	struct {
-		bool cvsd;
-#if ENABLE_MSBC
-		bool msbc;
-#endif
-	} codecs;
+	/* AG supported codecs */
+	struct ba_rfcomm_hfp_codecs ag_codecs;
+	/* HF supported codecs */
+	struct ba_rfcomm_hfp_codecs hf_codecs;
+
+	/* HF supported codecs encoded for BAC command and BCS error response. */
+	char hf_bac_bcs_string[4];
 
-	/* codec selection synchronization */
-	pthread_mutex_t codec_selection_completed_mtx;
-	pthread_cond_t codec_selection_completed;
+	/* Synchronization primitives for codec selection. The condition variable
+	 * shall be used with the codec_id mutex from the associated transport. */
+	pthread_cond_t codec_selection_cond;
+	bool codec_selection_done;
 
 	/* requested codec by the AG */
 	int codec;
@@ -116,7 +127,7 @@ struct ba_rfcomm {
 	 * remove all references, otherwise resources will not be freed. If this
 	 * quirk workaround is enabled, RFCOMM link lost will trigger SCO transport
 	 * destroy rather than a simple unreferencing. */
-	bool link_lost_quirk;
+	atomic_bool link_lost_quirk;
 
 };
 
diff --git a/src/ba-transport.c b/src/ba-transport.c
index 6306077..85679e9 100644
--- a/src/ba-transport.c
+++ b/src/ba-transport.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - ba-transport.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,33 +9,49 @@
  */
 
 #include "ba-transport.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <math.h>
 #include <poll.h>
+#include <signal.h>
 #include <stdbool.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/ioctl.h>
+#include <sys/time.h>
 #include <sys/socket.h>
 #include <unistd.h>
 
 #include <bluetooth/bluetooth.h>
 #include <bluetooth/hci.h>
-#include <bluetooth/hci_lib.h>
 
 #include <gio/gio.h>
 #include <gio/gunixfdlist.h>
 #include <glib-object.h>
 #include <glib.h>
 
-#include "a2dp-aac.h"
-#include "a2dp-aptx-hd.h"
-#include "a2dp-aptx.h"
-#include "a2dp-faststream.h"
-#include "a2dp-lc3plus.h"
-#include "a2dp-ldac.h"
-#include "a2dp-mpeg.h"
+#if ENABLE_AAC
+# include "a2dp-aac.h"
+#endif
+#if ENABLE_APTX
+# include "a2dp-aptx.h"
+#endif
+#if ENABLE_APTX_HD
+# include "a2dp-aptx-hd.h"
+#endif
+#if ENABLE_FASTSTREAM
+# include "a2dp-faststream.h"
+#endif
+#if ENABLE_LC3PLUS
+# include "a2dp-lc3plus.h"
+#endif
+#if ENABLE_LDAC
+# include "a2dp-ldac.h"
+#endif
+#if ENABLE_MPEG
+# include "a2dp-mpeg.h"
+#endif
 #include "a2dp-sbc.h"
 #include "audio.h"
 #include "ba-adapter.h"
@@ -47,16 +63,20 @@
 #include "dbus.h"
 #include "hci.h"
 #include "hfp.h"
+#include "io.h"
+#if ENABLE_OFONO
+# include "ofono.h"
+#endif
 #include "sco.h"
-#include "utils.h"
+#include "storage.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/log.h"
 #include "shared/rt.h"
 
 static const char *transport_get_dbus_path_type(
-		struct ba_transport_type type) {
-	switch (type.profile) {
+		enum ba_transport_profile profile) {
+	switch (profile) {
 	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
 		return "a2dpsrc";
 	case BA_TRANSPORT_PROFILE_A2DP_SINK:
@@ -82,22 +102,25 @@ static int transport_pcm_init(
 	struct ba_transport *t = th->t;
 
 	pcm->t = t;
-	pcm->th = th;
 	pcm->mode = mode;
 	pcm->fd = -1;
 	pcm->active = true;
 
+	/* link PCM and transport thread */
+	pcm->th = th;
+	th->pcm = pcm;
+
 	pcm->volume[0].level = config.volume_init_level;
 	pcm->volume[1].level = config.volume_init_level;
 	ba_transport_pcm_volume_set(&pcm->volume[0], NULL, NULL, NULL);
 	ba_transport_pcm_volume_set(&pcm->volume[1], NULL, NULL, NULL);
 
 	pthread_mutex_init(&pcm->mutex, NULL);
-	pthread_mutex_init(&pcm->synced_mtx, NULL);
-	pthread_cond_init(&pcm->synced, NULL);
+	pthread_mutex_init(&pcm->client_mtx, NULL);
+	pthread_cond_init(&pcm->cond, NULL);
 
 	pcm->ba_dbus_path = g_strdup_printf("%s/%s/%s",
-			t->d->ba_dbus_path, transport_get_dbus_path_type(t->type),
+			t->d->ba_dbus_path, transport_get_dbus_path_type(t->profile),
 			mode == BA_TRANSPORT_PCM_MODE_SOURCE ? "source" : "sink");
 
 	return 0;
@@ -111,14 +134,28 @@ static void transport_pcm_free(
 	pthread_mutex_unlock(&pcm->mutex);
 
 	pthread_mutex_destroy(&pcm->mutex);
-	pthread_mutex_destroy(&pcm->synced_mtx);
-	pthread_cond_destroy(&pcm->synced);
+	pthread_mutex_destroy(&pcm->client_mtx);
+	pthread_cond_destroy(&pcm->cond);
 
 	if (pcm->ba_dbus_path != NULL)
 		g_free(pcm->ba_dbus_path);
 
 }
 
+/**
+ * Convert PCM volume level to [0, max] range. */
+int ba_transport_pcm_volume_level_to_range(int value, int max) {
+	int volume = audio_decibel_to_loudness(value / 100.0) * max;
+	return MIN(MAX(volume, 0), max);
+}
+
+/**
+ * Convert [0, max] range to PCM volume level. */
+int ba_transport_pcm_volume_range_to_level(int value, int max) {
+	double level = audio_loudness_to_decibel(1.0 * value / max);
+	return MIN(MAX(level, -96.0), 96.0) * 100;
+}
+
 /**
  * Set PCM volume level/mute.
  *
@@ -145,26 +182,66 @@ void ba_transport_pcm_volume_set(
 	if (hard_mute != NULL)
 		volume->hard_mute = *hard_mute;
 
-	/* pre-calculate PCM scale factor */
+	/* calculate PCM scale factor */
 	const bool muted = volume->soft_mute || volume->hard_mute;
 	volume->scale = muted ? 0 : pow(10, (0.01 * volume->level) / 20);
 
 }
 
+static int ba_transport_pcms_full_lock(struct ba_transport *t) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+		/* lock client mutexes first to avoid deadlock */
+		pthread_mutex_lock(&t->a2dp.pcm.client_mtx);
+		pthread_mutex_lock(&t->a2dp.pcm_bc.client_mtx);
+		/* lock PCM data mutexes */
+		pthread_mutex_lock(&t->a2dp.pcm.mutex);
+		pthread_mutex_lock(&t->a2dp.pcm_bc.mutex);
+		return 0;
+	}
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+		/* lock client mutexes first to avoid deadlock */
+		pthread_mutex_lock(&t->sco.spk_pcm.client_mtx);
+		pthread_mutex_lock(&t->sco.mic_pcm.client_mtx);
+		/* lock PCM data mutexes */
+		pthread_mutex_lock(&t->sco.spk_pcm.mutex);
+		pthread_mutex_lock(&t->sco.mic_pcm.mutex);
+		return 0;
+	}
+	errno = EINVAL;
+	return -1;
+}
+
+static int ba_transport_pcms_full_unlock(struct ba_transport *t) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+		pthread_mutex_unlock(&t->a2dp.pcm.mutex);
+		pthread_mutex_unlock(&t->a2dp.pcm_bc.mutex);
+		pthread_mutex_unlock(&t->a2dp.pcm.client_mtx);
+		pthread_mutex_unlock(&t->a2dp.pcm_bc.client_mtx);
+		return 0;
+	}
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+		pthread_mutex_unlock(&t->sco.spk_pcm.mutex);
+		pthread_mutex_unlock(&t->sco.mic_pcm.mutex);
+		pthread_mutex_unlock(&t->sco.spk_pcm.client_mtx);
+		pthread_mutex_unlock(&t->sco.mic_pcm.client_mtx);
+		return 0;
+	}
+	errno = EINVAL;
+	return -1;
+}
+
 static int transport_thread_init(
 		struct ba_transport_thread *th,
 		struct ba_transport *t) {
 
 	th->t = t;
-	th->state = BA_TRANSPORT_THREAD_STATE_NONE;
-	th->id = config.main_thread;
+	th->state = BA_TRANSPORT_THREAD_STATE_TERMINATED;
 	th->bt_fd = -1;
 	th->pipe[0] = -1;
 	th->pipe[1] = -1;
 
 	pthread_mutex_init(&th->mutex, NULL);
-	pthread_mutex_init(&th->state_mtx, NULL);
-	pthread_cond_init(&th->changed, NULL);
+	pthread_cond_init(&th->cond, NULL);
 
 	if (pipe(th->pipe) == -1)
 		return -1;
@@ -172,15 +249,6 @@ static int transport_thread_init(
 	return 0;
 }
 
-/**
- * Prepare synchronous transport thread cancellation.
- *
- * Please note that it is required to call this function before thread
- * synchronous cancellation (cancel + join). */
-static void transport_thread_cancel_prepare(struct ba_transport_thread *th) {
-	ba_transport_thread_set_state_stopping(th);
-}
-
 /**
  * Synchronous transport thread cancellation.
  *
@@ -190,36 +258,58 @@ static void transport_thread_cancel_prepare(struct ba_transport_thread *th) {
  * terminate, so join will not return either! */
 static void transport_thread_cancel(struct ba_transport_thread *th) {
 
-	/* we are going to modify thread ID */
 	pthread_mutex_lock(&th->mutex);
 
-	pthread_t id = th->id;
-	if (pthread_equal(id, config.main_thread))
-		goto skip;
+	/* If the transport thread is in the idle state (i.e. it is not running),
+	 * we can mark it as terminated right away. */
+	if (th->state == BA_TRANSPORT_THREAD_STATE_IDLE) {
+		th->state = BA_TRANSPORT_THREAD_STATE_TERMINATED;
+		pthread_mutex_unlock(&th->mutex);
+		pthread_cond_broadcast(&th->cond);
+		return;
+	}
+
+	/* If this function was called from more than one thread at the same time
+	 * (e.g. from transport thread manager thread and from main thread due to
+	 * SIGTERM signal), wait until the IO thread terminates - this function is
+	 * supposed to be synchronous. */
+	if (th->state == BA_TRANSPORT_THREAD_STATE_JOINING) {
+		while (th->state != BA_TRANSPORT_THREAD_STATE_TERMINATED)
+			pthread_cond_wait(&th->cond, &th->mutex);
+		pthread_mutex_unlock(&th->mutex);
+		return;
+	}
+
+	if (th->state == BA_TRANSPORT_THREAD_STATE_TERMINATED) {
+		pthread_mutex_unlock(&th->mutex);
+		return;
+	}
+
+	/* The transport thread has to be marked for stopping. If at this point
+	 * the state is not STOPPING, it is a programming error. */
+	g_assert_cmpint(th->state, ==, BA_TRANSPORT_THREAD_STATE_STOPPING);
 
 	int err;
+	pthread_t id = th->id;
 	if ((err = pthread_cancel(id)) != 0 && err != ESRCH)
 		warn("Couldn't cancel transport thread: %s", strerror(err));
-	if ((err = pthread_join(id, NULL)) != 0)
-		warn("Couldn't join transport thread: %s", strerror(err));
 
-	/* Indicate that the thread has been successfully terminated. Also,
-	 * make sure, that after termination, this thread handler will not
-	 * be used anymore. */
-	th->id = config.main_thread;
-	pthread_cond_signal(&th->changed);
+	/* Set the state to JOINING before unlocking the mutex. This will
+	 * prevent calling the pthread_cancel() function once again. */
+	th->state = BA_TRANSPORT_THREAD_STATE_JOINING;
 
-skip:
 	pthread_mutex_unlock(&th->mutex);
-}
 
-/**
- * Wait until transport thread is terminated. */
-static void transport_thread_cancel_wait(struct ba_transport_thread *th) {
+	if ((err = pthread_join(id, NULL)) != 0)
+		warn("Couldn't join transport thread: %s", strerror(err));
+
 	pthread_mutex_lock(&th->mutex);
-	while (!pthread_equal(th->id, config.main_thread))
-		pthread_cond_wait(&th->changed, &th->mutex);
+	th->state = BA_TRANSPORT_THREAD_STATE_TERMINATED;
 	pthread_mutex_unlock(&th->mutex);
+
+	/* Notify others that the thread has been terminated. */
+	pthread_cond_broadcast(&th->cond);
+
 }
 
 /**
@@ -233,36 +323,97 @@ static void transport_thread_free(
 	if (th->pipe[1] != -1)
 		close(th->pipe[1]);
 	pthread_mutex_destroy(&th->mutex);
-	pthread_mutex_destroy(&th->state_mtx);
-	pthread_cond_destroy(&th->changed);
+	pthread_cond_destroy(&th->cond);
 }
 
-int ba_transport_thread_set_state(
+/**
+ * Set transport thread state.
+ *
+ * It is only allowed to set the new state according to the state machine.
+ * For details, see comments in this function body.
+ *
+ * @param th Transport thread.
+ * @param state New transport thread state.
+ * @return If state transition was successful, 0 is returned. Otherwise, -1 is
+ *   returned and errno is set to EINVAL. */
+int ba_transport_thread_state_set(
 		struct ba_transport_thread *th,
-		enum ba_transport_thread_state state,
-		bool force) {
-
-	pthread_mutex_lock(&th->state_mtx);
-
-	/* By default only a valid state transitions are allowed. In order
-	 * to set the state to an arbitrary value, the force parameter has
-	 * to be set to true. */
-	if (!force) {
-		if (state <= th->state)
-			goto skip;
-		if (th->state == BA_TRANSPORT_THREAD_STATE_NONE &&
-			state != BA_TRANSPORT_THREAD_STATE_STARTING)
-			goto skip;
-	}
+		enum ba_transport_thread_state state) {
+
+	pthread_mutex_lock(&th->mutex);
+
+	enum ba_transport_thread_state old_state = th->state;
+
+	/* Moving to the next state is always allowed. */
+	bool valid = state == th->state + 1;
+
+	/* Allow wrapping around the state machine. */
+	if (state == BA_TRANSPORT_THREAD_STATE_IDLE &&
+			old_state == BA_TRANSPORT_THREAD_STATE_TERMINATED)
+		valid = true;
+
+	/* Thread initialization failure: STARTING -> STOPPING */
+	if (state == BA_TRANSPORT_THREAD_STATE_STOPPING &&
+			old_state == BA_TRANSPORT_THREAD_STATE_STARTING)
+		valid = true;
 
-	th->state = state;
-	pthread_cond_signal(&th->changed);
+	/* Additionally, it is allowed to move to the TERMINATED state from
+	 * IDLE and STARTING. This transition indicates that the thread has
+	 * never been started or there was an error during the startup. */
+	if (state == BA_TRANSPORT_THREAD_STATE_TERMINATED && (
+				old_state == BA_TRANSPORT_THREAD_STATE_IDLE ||
+				old_state == BA_TRANSPORT_THREAD_STATE_STARTING))
+		valid = true;
+
+	if (valid)
+		th->state = state;
+
+	pthread_mutex_unlock(&th->mutex);
+
+	if (!valid)
+		return errno = EINVAL, -1;
+
+	if (state != old_state && (
+				state == BA_TRANSPORT_THREAD_STATE_RUNNING ||
+				old_state == BA_TRANSPORT_THREAD_STATE_RUNNING)) {
+			bluealsa_dbus_pcm_update(th->pcm, BA_DBUS_PCM_UPDATE_RUNNING);
+	}
 
-skip:
-	pthread_mutex_unlock(&th->state_mtx);
+	pthread_cond_broadcast(&th->cond);
 	return 0;
 }
 
+/**
+ * Check if transport thread is in given state. */
+bool ba_transport_thread_state_check(
+		struct ba_transport_thread *th,
+		enum ba_transport_thread_state state) {
+	pthread_mutex_lock(&th->mutex);
+	bool ok = th->state == state;
+	pthread_mutex_unlock(&th->mutex);
+	return ok;
+}
+
+/**
+ * Wait until transport thread reaches given state. */
+int ba_transport_thread_state_wait(
+		struct ba_transport_thread *th,
+		enum ba_transport_thread_state state) {
+
+	enum ba_transport_thread_state tmp;
+
+	pthread_mutex_lock(&th->mutex);
+	while ((tmp = th->state) < state)
+		pthread_cond_wait(&th->cond, &th->mutex);
+	pthread_mutex_unlock(&th->mutex);
+
+	if (tmp == state)
+		return 0;
+
+	errno = EIO;
+	return -1;
+}
+
 int ba_transport_thread_bt_acquire(
 		struct ba_transport_thread *th) {
 
@@ -306,7 +457,11 @@ int ba_transport_thread_bt_release(
 		struct ba_transport_thread *th) {
 
 	if (th->bt_fd != -1) {
+#if DEBUG
+		pthread_mutex_lock(&th->t->bt_fd_mtx);
 		debug("Closing BT socket duplicate [%d]: %d", th->t->bt_fd, th->bt_fd);
+		pthread_mutex_unlock(&th->t->bt_fd_mtx);
+#endif
 		close(th->bt_fd);
 		th->bt_fd = -1;
 	}
@@ -317,12 +472,26 @@ int ba_transport_thread_bt_release(
 int ba_transport_thread_signal_send(
 		struct ba_transport_thread *th,
 		enum ba_transport_thread_signal signal) {
-	if (pthread_equal(th->id, config.main_thread))
-		return errno = ESRCH, -1;
-	if (write(th->pipe[1], &signal, sizeof(signal)) == sizeof(signal))
-		return 0;
-	warn("Couldn't write transport thread signal: %s", strerror(errno));
-	return -1;
+
+	int ret = -1;
+
+	pthread_mutex_lock(&th->mutex);
+
+	if (th->state != BA_TRANSPORT_THREAD_STATE_RUNNING) {
+		errno = ESRCH;
+		goto fail;
+	}
+
+	if (write(th->pipe[1], &signal, sizeof(signal)) != sizeof(signal)) {
+		warn("Couldn't write transport thread signal: %s", strerror(errno));
+		goto fail;
+	}
+
+	ret = 0;
+
+fail:
+	pthread_mutex_unlock(&th->mutex);
+	return ret;
 }
 
 int ba_transport_thread_signal_recv(
@@ -344,26 +513,24 @@ int ba_transport_thread_signal_recv(
 
 static void transport_threads_cancel(struct ba_transport *t) {
 
-	transport_thread_cancel_prepare(&t->thread_enc);
-	transport_thread_cancel_prepare(&t->thread_dec);
-
 	transport_thread_cancel(&t->thread_enc);
 	transport_thread_cancel(&t->thread_dec);
 
 	pthread_mutex_lock(&t->bt_fd_mtx);
-
 	t->stopping = false;
-	pthread_cond_signal(&t->stopped);
-
 	pthread_mutex_unlock(&t->bt_fd_mtx);
 
+	pthread_cond_broadcast(&t->stopped);
+
 }
 
 static void transport_threads_cancel_if_no_clients(struct ba_transport *t) {
 
-	/* Hold PCM locks, so no client will open a PCM in
-	 * the middle of our PCM inactivity check. */
-	ba_transport_pcms_lock(t);
+	/* Hold PCM client and data locks. The data lock is required because we
+	 * are going to check the PCM FIFO file descriptor. The client lock is
+	 * required to prevent PCM clients from opening PCM in the middle of our
+	 * inactivity check. */
+	ba_transport_pcms_full_lock(t);
 
 	/* Hold BT lock, because we are going to modify
 	 * the IO transports stopping flag. */
@@ -371,36 +538,32 @@ static void transport_threads_cancel_if_no_clients(struct ba_transport *t) {
 
 	bool stop = false;
 
-	if (t->stopping)
-		goto final;
-
-	switch (t->type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		/* Release bidirectional A2DP transport only in case when there
-		 * is no active PCM connection - neither encoder nor decoder. */
-		if (t->a2dp.pcm.fd == -1 && t->a2dp.pcm_bc.fd == -1)
-			t->stopping = stop = true;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		/* For Audio Gateway profile it is required to release SCO if we
-		 * are not transferring audio (not sending nor receiving), because
-		 * it will free Bluetooth bandwidth - headset will send microphone
-		 * signal even though we are not reading it! */
-		if (t->sco.spk_pcm.fd == -1 && t->sco.mic_pcm.fd == -1)
-			t->stopping = stop = true;
-		break;
+	if (!t->stopping) {
+		if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
+			/* Release bidirectional A2DP transport only in case when there
+			 * is no active PCM connection - neither encoder nor decoder. */
+			if (t->a2dp.pcm.fd == -1 && t->a2dp.pcm_bc.fd == -1)
+				t->stopping = stop = true;
+		}
+		else if (t->profile & BA_TRANSPORT_PROFILE_MASK_AG) {
+			/* For Audio Gateway profile it is required to release SCO if we
+			 * are not transferring audio (not sending nor receiving), because
+			 * it will free Bluetooth bandwidth - headset will send microphone
+			 * signal even though we are not reading it! */
+			if (t->sco.spk_pcm.fd == -1 && t->sco.mic_pcm.fd == -1)
+				t->stopping = stop = true;
+		}
 	}
 
+	pthread_mutex_unlock(&t->bt_fd_mtx);
+
 	if (stop) {
 		debug("Stopping transport: %s", "No PCM clients");
-		transport_thread_cancel_prepare(&t->thread_enc);
-		transport_thread_cancel_prepare(&t->thread_dec);
+		ba_transport_thread_state_set_stopping(&t->thread_enc);
+		ba_transport_thread_state_set_stopping(&t->thread_dec);
 	}
 
-final:
-	pthread_mutex_unlock(&t->bt_fd_mtx);
-	ba_transport_pcms_unlock(t);
+	ba_transport_pcms_full_unlock(t);
 
 	if (stop) {
 		transport_threads_cancel(t);
@@ -486,12 +649,14 @@ static struct ba_transport *transport_new(
 		return NULL;
 
 	t->d = ba_device_ref(device);
-	t->type.profile = BA_TRANSPORT_PROFILE_NONE;
-	t->type.codec = -1;
+	t->profile = BA_TRANSPORT_PROFILE_NONE;
+	t->codec_id = -1;
 	t->ref_count = 1;
 
-	pthread_mutex_init(&t->type_mtx, NULL);
+	pthread_mutex_init(&t->codec_id_mtx, NULL);
+	pthread_mutex_init(&t->codec_select_client_mtx, NULL);
 	pthread_mutex_init(&t->bt_fd_mtx, NULL);
+	pthread_mutex_init(&t->acquisition_mtx, NULL);
 	pthread_cond_init(&t->stopped, NULL);
 
 	t->bt_fd = -1;
@@ -509,7 +674,7 @@ static struct ba_transport *transport_new(
 	if (pipe(t->thread_manager_pipe) == -1)
 		goto fail;
 	if ((errno = pthread_create(&t->thread_manager_thread_id,
-			NULL, PTHREAD_ROUTINE(transport_thread_manager), t)) != 0) {
+			NULL, PTHREAD_FUNC(transport_thread_manager), t)) != 0) {
 		t->thread_manager_thread_id = config.main_thread;
 		goto fail;
 	}
@@ -535,7 +700,6 @@ fail:
 static int transport_acquire_bt_a2dp(struct ba_transport *t) {
 
 	GDBusMessage *msg, *rep;
-	GUnixFDList *fd_list;
 	GError *err = NULL;
 	int fd = -1;
 
@@ -556,12 +720,13 @@ static int transport_acquire_bt_a2dp(struct ba_transport *t) {
 	g_variant_get(g_dbus_message_get_body(rep), "(hqq)",
 			NULL, &mtu_read, &mtu_write);
 
-	t->mtu_read = mtu_read;
-	t->mtu_write = mtu_write;
+	GUnixFDList *fd_list = g_dbus_message_get_unix_fd_list(rep);
+	if ((fd = g_unix_fd_list_get(fd_list, 0, &err)) == -1)
+		goto fail;
 
-	fd_list = g_dbus_message_get_unix_fd_list(rep);
-	fd = g_unix_fd_list_get(fd_list, 0, &err);
 	t->bt_fd = fd;
+	t->mtu_read = mtu_read;
+	t->mtu_write = mtu_write;
 
 	/* Minimize audio delay and increase responsiveness (seeking, stopping) by
 	 * decreasing the BT socket output buffer. We will use a tripled write MTU
@@ -648,19 +813,19 @@ fail:
 
 struct ba_transport *ba_transport_new_a2dp(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_owner,
 		const char *dbus_path,
 		const struct a2dp_codec *codec,
 		const void *configuration) {
 
-	const bool is_sink = type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK;
+	const bool is_sink = profile & BA_TRANSPORT_PROFILE_A2DP_SINK;
 	struct ba_transport *t;
 
 	if ((t = transport_new(device, dbus_owner, dbus_path)) == NULL)
 		return NULL;
 
-	t->type.profile = type.profile;
+	t->profile = profile;
 
 	t->a2dp.codec = codec;
 	memcpy(&t->a2dp.configuration, configuration, codec->capabilities_size);
@@ -670,18 +835,19 @@ struct ba_transport *ba_transport_new_a2dp(
 			is_sink ? &t->thread_dec : &t->thread_enc,
 			is_sink ? BA_TRANSPORT_PCM_MODE_SOURCE : BA_TRANSPORT_PCM_MODE_SINK);
 	t->a2dp.pcm.soft_volume = !config.a2dp.volume;
-	t->a2dp.pcm.max_bt_volume = 127;
 
 	transport_pcm_init(&t->a2dp.pcm_bc,
 			is_sink ? &t->thread_enc : &t->thread_dec,
 			is_sink ?  BA_TRANSPORT_PCM_MODE_SINK : BA_TRANSPORT_PCM_MODE_SOURCE);
 	t->a2dp.pcm_bc.soft_volume = !config.a2dp.volume;
-	t->a2dp.pcm_bc.max_bt_volume = 127;
 
 	t->acquire = transport_acquire_bt_a2dp;
 	t->release = transport_release_bt_a2dp;
 
-	ba_transport_set_codec(t, type.codec);
+	ba_transport_set_codec(t, codec->codec_id);
+
+	storage_pcm_data_sync(&t->a2dp.pcm);
+	storage_pcm_data_sync(&t->a2dp.pcm_bc);
 
 	if (t->a2dp.pcm.channels > 0)
 		bluealsa_dbus_pcm_register(&t->a2dp.pcm);
@@ -714,7 +880,7 @@ static int transport_acquire_bt_sco(struct ba_transport *t) {
 	}
 
 	if (hci_sco_connect(fd, &d->addr,
-				t->type.codec == HFP_CODEC_CVSD ? BT_VOICE_CVSD_16BIT : BT_VOICE_TRANSPARENT) == -1) {
+				t->codec_id == HFP_CODEC_CVSD ? BT_VOICE_CVSD_16BIT : BT_VOICE_TRANSPARENT) == -1) {
 		error("Couldn't establish SCO link: %s", strerror(errno));
 		goto fail;
 	}
@@ -740,7 +906,7 @@ static int transport_release_bt_sco(struct ba_transport *t) {
 	close(t->bt_fd);
 	t->bt_fd = -1;
 
-	/* Keep the time-stamp when the SCO link has been close. It will be used
+	/* Keep the time-stamp when the SCO link has been closed. It will be used
 	 * for calculating close-connect quirk delay in the acquire function. */
 	gettimestamp(&t->sco.closed_at);
 
@@ -749,60 +915,193 @@ static int transport_release_bt_sco(struct ba_transport *t) {
 
 struct ba_transport *ba_transport_new_sco(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_owner,
 		const char *dbus_path,
 		int rfcomm_fd) {
 
+	const bool is_ag = profile & BA_TRANSPORT_PROFILE_MASK_AG;
+	uint16_t codec_id = HFP_CODEC_UNDEFINED;
 	struct ba_transport *t;
 	int err;
 
+	/* BlueALSA can only support one SCO transport per device, so we arbitrarily
+	 * accept only the first profile connection, with no preference for HFP.
+	 * Most (all?) commercial devices prefer HFP over HSP, but we are unable to
+	 * emulate that with our current design (we would need to know all profiles
+	 * supported by the remote device before it connects). Fortunately BlueZ
+	 * appears to always connect HFP before HSP, so at least for connections
+	 * from commercial devices and for BlueALSA to BlueALSA connections we get
+	 * the desired result. */
+	if ((t = ba_transport_lookup(device, dbus_path)) != NULL) {
+		debug("SCO transport already connected: %s", ba_transport_debug_name(t));
+		ba_transport_unref(t);
+		errno = EBUSY;
+		return NULL;
+	}
+
 	if ((t = transport_new(device, dbus_owner, dbus_path)) == NULL)
 		return NULL;
 
+	t->profile = profile;
+
+	transport_pcm_init(&t->sco.spk_pcm,
+			is_ag ? &t->thread_enc : &t->thread_dec,
+			is_ag ? BA_TRANSPORT_PCM_MODE_SINK : BA_TRANSPORT_PCM_MODE_SOURCE);
+
+	transport_pcm_init(&t->sco.mic_pcm,
+			is_ag ? &t->thread_dec : &t->thread_enc,
+			is_ag ? BA_TRANSPORT_PCM_MODE_SOURCE : BA_TRANSPORT_PCM_MODE_SINK);
+
+	t->acquire = transport_acquire_bt_sco;
+	t->release = transport_release_bt_sco;
+
 	/* HSP supports CVSD only */
-	if (type.profile & BA_TRANSPORT_PROFILE_MASK_HSP)
-		type.codec = HFP_CODEC_CVSD;
+	if (profile & BA_TRANSPORT_PROFILE_MASK_HSP)
+		codec_id = HFP_CODEC_CVSD;
 
 #if ENABLE_MSBC
+	if (!config.hfp.codecs.msbc)
+		codec_id = HFP_CODEC_CVSD;
 	/* Check whether support for codec other than
 	 * CVSD is possible with underlying adapter. */
 	if (!BA_TEST_ESCO_SUPPORT(device->a))
-		type.codec = HFP_CODEC_CVSD;
+		codec_id = HFP_CODEC_CVSD;
 #else
-	type.codec = HFP_CODEC_CVSD;
+	codec_id = HFP_CODEC_CVSD;
 #endif
 
-	t->type.profile = type.profile;
+	ba_transport_set_codec(t, codec_id);
 
-	transport_pcm_init(&t->sco.spk_pcm, &t->thread_enc, BA_TRANSPORT_PCM_MODE_SINK);
-	t->sco.spk_pcm.max_bt_volume = 15;
+	storage_pcm_data_sync(&t->sco.spk_pcm);
+	storage_pcm_data_sync(&t->sco.mic_pcm);
 
-	transport_pcm_init(&t->sco.mic_pcm, &t->thread_dec, BA_TRANSPORT_PCM_MODE_SOURCE);
-	t->sco.mic_pcm.max_bt_volume = 15;
-
-	t->acquire = transport_acquire_bt_sco;
-	t->release = transport_release_bt_sco;
+	bluealsa_dbus_pcm_register(&t->sco.spk_pcm);
+	bluealsa_dbus_pcm_register(&t->sco.mic_pcm);
 
 	if (rfcomm_fd != -1) {
 		if ((t->sco.rfcomm = ba_rfcomm_new(t, rfcomm_fd)) == NULL)
 			goto fail;
 	}
 
-	ba_transport_set_codec(t, type.codec);
-
-	bluealsa_dbus_pcm_register(&t->sco.spk_pcm);
-	bluealsa_dbus_pcm_register(&t->sco.mic_pcm);
-
 	return t;
 
 fail:
 	err = errno;
+	bluealsa_dbus_pcm_unregister(&t->sco.spk_pcm);
+	bluealsa_dbus_pcm_unregister(&t->sco.mic_pcm);
 	ba_transport_unref(t);
 	errno = err;
 	return NULL;
 }
 
+#if DEBUG
+/**
+ * Get BlueALSA transport type debug name.
+ *
+ * @param t Transport structure.
+ * @return Human-readable string. */
+const char *ba_transport_debug_name(
+		const struct ba_transport *t) {
+	const enum ba_transport_profile profile = t->profile;
+	const uint16_t codec_id = t->codec_id;
+	switch (profile) {
+	case BA_TRANSPORT_PROFILE_NONE:
+		return "NONE";
+	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
+		switch (codec_id) {
+		case A2DP_CODEC_SBC:
+			return "A2DP Source (SBC)";
+#if ENABLE_MPEG
+		case A2DP_CODEC_MPEG12:
+			return "A2DP Source (MP3)";
+#endif
+#if ENABLE_AAC
+		case A2DP_CODEC_MPEG24:
+			return "A2DP Source (AAC)";
+#endif
+#if ENABLE_APTX
+		case A2DP_CODEC_VENDOR_APTX:
+			return "A2DP Source (aptX)";
+#endif
+#if ENABLE_APTX_HD
+		case A2DP_CODEC_VENDOR_APTX_HD:
+			return "A2DP Source (aptX HD)";
+#endif
+#if ENABLE_FASTSTREAM
+		case A2DP_CODEC_VENDOR_FASTSTREAM:
+			return "A2DP Source (FastStream)";
+#endif
+#if ENABLE_LC3PLUS
+		case A2DP_CODEC_VENDOR_LC3PLUS:
+			return "A2DP Source (LC3plus)";
+#endif
+#if ENABLE_LDAC
+		case A2DP_CODEC_VENDOR_LDAC:
+			return "A2DP Source (LDAC)";
+#endif
+		} break;
+	case BA_TRANSPORT_PROFILE_A2DP_SINK:
+		switch (codec_id) {
+		case A2DP_CODEC_SBC:
+			return "A2DP Sink (SBC)";
+#if ENABLE_MPEG
+		case A2DP_CODEC_MPEG12:
+			return "A2DP Sink (MP3)";
+#endif
+#if ENABLE_AAC
+		case A2DP_CODEC_MPEG24:
+			return "A2DP Sink (AAC)";
+#endif
+#if ENABLE_APTX
+		case A2DP_CODEC_VENDOR_APTX:
+			return "A2DP Sink (aptX)";
+#endif
+#if ENABLE_APTX_HD
+		case A2DP_CODEC_VENDOR_APTX_HD:
+			return "A2DP Sink (aptX HD)";
+#endif
+#if ENABLE_FASTSTREAM
+		case A2DP_CODEC_VENDOR_FASTSTREAM:
+			return "A2DP Sink (FastStream)";
+#endif
+#if ENABLE_LC3PLUS
+		case A2DP_CODEC_VENDOR_LC3PLUS:
+			return "A2DP Sink (LC3plus)";
+#endif
+#if ENABLE_LDAC
+		case A2DP_CODEC_VENDOR_LDAC:
+			return "A2DP Sink (LDAC)";
+#endif
+		} break;
+	case BA_TRANSPORT_PROFILE_HFP_HF:
+		switch (codec_id) {
+		case HFP_CODEC_UNDEFINED:
+			return "HFP Hands-Free (...)";
+		case HFP_CODEC_CVSD:
+			return "HFP Hands-Free (CVSD)";
+		case HFP_CODEC_MSBC:
+			return "HFP Hands-Free (mSBC)";
+		} break;
+	case BA_TRANSPORT_PROFILE_HFP_AG:
+		switch (codec_id) {
+		case HFP_CODEC_UNDEFINED:
+			return "HFP Audio Gateway (...)";
+		case HFP_CODEC_CVSD:
+			return "HFP Audio Gateway (CVSD)";
+		case HFP_CODEC_MSBC:
+			return "HFP Audio Gateway (mSBC)";
+		} break;
+	case BA_TRANSPORT_PROFILE_HSP_HS:
+		return "HSP Headset";
+	case BA_TRANSPORT_PROFILE_HSP_AG:
+		return "HSP Audio Gateway";
+	}
+	debug("Unknown transport: profile:%#x codec:%#x", profile, codec_id);
+	return "N/A";
+}
+#endif
+
 struct ba_transport *ba_transport_lookup(
 		struct ba_device *device,
 		const char *dbus_path) {
@@ -829,15 +1128,17 @@ struct ba_transport *ba_transport_ref(
 	return t;
 }
 
+/**
+ * Unregister D-Bus interfaces, stop IO threads and release transport. */
 void ba_transport_destroy(struct ba_transport *t) {
 
 	/* Remove D-Bus interfaces, so no one will access
 	 * this transport during the destroy procedure. */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 		bluealsa_dbus_pcm_unregister(&t->a2dp.pcm);
 		bluealsa_dbus_pcm_unregister(&t->a2dp.pcm_bc);
 	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
 		bluealsa_dbus_pcm_unregister(&t->sco.spk_pcm);
 		bluealsa_dbus_pcm_unregister(&t->sco.mic_pcm);
 		if (t->sco.rfcomm != NULL)
@@ -848,14 +1149,14 @@ void ba_transport_destroy(struct ba_transport *t) {
 	/* stop transport IO threads */
 	ba_transport_stop(t);
 
-	ba_transport_pcms_lock(t);
+	ba_transport_pcms_full_lock(t);
 
 	/* terminate on-going PCM connections - exit PCM controllers */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 		ba_transport_pcm_release(&t->a2dp.pcm);
 		ba_transport_pcm_release(&t->a2dp.pcm_bc);
 	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
 		ba_transport_pcm_release(&t->sco.spk_pcm);
 		ba_transport_pcm_release(&t->sco.mic_pcm);
 	}
@@ -863,7 +1164,7 @@ void ba_transport_destroy(struct ba_transport *t) {
 	/* make sure that transport is released */
 	ba_transport_release(t);
 
-	ba_transport_pcms_unlock(t);
+	ba_transport_pcms_full_unlock(t);
 
 	ba_transport_unref(t);
 }
@@ -882,7 +1183,16 @@ void ba_transport_unref(struct ba_transport *t) {
 	if (ref_count > 0)
 		return;
 
-	debug("Freeing transport: %s", ba_transport_type_to_string(t->type));
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+		storage_pcm_data_update(&t->a2dp.pcm);
+		storage_pcm_data_update(&t->a2dp.pcm_bc);
+	}
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+		storage_pcm_data_update(&t->sco.spk_pcm);
+		storage_pcm_data_update(&t->sco.mic_pcm);
+	}
+
+	debug("Freeing transport: %s", ba_transport_debug_name(t));
 	g_assert_cmpint(ref_count, ==, 0);
 
 	if (t->bt_fd != -1)
@@ -890,17 +1200,37 @@ void ba_transport_unref(struct ba_transport *t) {
 
 	ba_device_unref(d);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 		transport_pcm_free(&t->a2dp.pcm);
 		transport_pcm_free(&t->a2dp.pcm_bc);
 	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
 		if (t->sco.rfcomm != NULL)
 			ba_rfcomm_destroy(t->sco.rfcomm);
 		transport_pcm_free(&t->sco.spk_pcm);
 		transport_pcm_free(&t->sco.mic_pcm);
+#if ENABLE_OFONO
+		free(t->sco.ofono_dbus_path_card);
+		free(t->sco.ofono_dbus_path_modem);
+#endif
 	}
 
+#if DEBUG
+	/* If IO threads are not terminated yet, we can not go any further.
+	 * Such situation may occur when the transport is about to be freed from one
+	 * of the transport IO threads. The transport thread cleanup function sends
+	 * a command to the manager to terminate all other threads. In such case, we
+	 * will stuck here, because we are about to wait for the transport thread
+	 * manager to terminate. But the manager will not terminate, because it is
+	 * waiting for a transport thread to terminate - which is us... */
+	pthread_mutex_lock(&t->thread_enc.mutex);
+	g_assert_cmpint(t->thread_enc.state, ==, BA_TRANSPORT_THREAD_STATE_TERMINATED);
+	pthread_mutex_unlock(&t->thread_enc.mutex);
+	pthread_mutex_lock(&t->thread_dec.mutex);
+	g_assert_cmpint(t->thread_dec.state, ==, BA_TRANSPORT_THREAD_STATE_TERMINATED);
+	pthread_mutex_unlock(&t->thread_dec.mutex);
+#endif
+
 	if (!pthread_equal(t->thread_manager_thread_id, config.main_thread)) {
 		transport_thread_manager_send_command(t, BA_TRANSPORT_THREAD_MANAGER_TERMINATE);
 		pthread_join(t->thread_manager_thread_id, NULL);
@@ -916,7 +1246,9 @@ void ba_transport_unref(struct ba_transport *t) {
 
 	pthread_cond_destroy(&t->stopped);
 	pthread_mutex_destroy(&t->bt_fd_mtx);
-	pthread_mutex_destroy(&t->type_mtx);
+	pthread_mutex_destroy(&t->acquisition_mtx);
+	pthread_mutex_destroy(&t->codec_select_client_mtx);
+	pthread_mutex_destroy(&t->codec_id_mtx);
 	free(t->bluez_dbus_owner);
 	free(t->bluez_dbus_path);
 	free(t);
@@ -931,61 +1263,38 @@ void ba_transport_pcm_unref(struct ba_transport_pcm *pcm) {
 	ba_transport_unref(pcm->t);
 }
 
-int ba_transport_pcms_lock(struct ba_transport *t) {
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
-		pthread_mutex_lock(&t->a2dp.pcm.mutex);
-		pthread_mutex_lock(&t->a2dp.pcm_bc.mutex);
-		return 0;
-	}
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
-		pthread_mutex_lock(&t->sco.spk_pcm.mutex);
-		pthread_mutex_lock(&t->sco.mic_pcm.mutex);
-		return 0;
-	}
-	errno = EINVAL;
-	return -1;
-}
-
-int ba_transport_pcms_unlock(struct ba_transport *t) {
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
-		pthread_mutex_unlock(&t->a2dp.pcm.mutex);
-		pthread_mutex_unlock(&t->a2dp.pcm_bc.mutex);
-		return 0;
-	}
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
-		pthread_mutex_unlock(&t->sco.spk_pcm.mutex);
-		pthread_mutex_unlock(&t->sco.mic_pcm.mutex);
-		return 0;
-	}
-	errno = EINVAL;
-	return -1;
-}
-
 int ba_transport_select_codec_a2dp(
 		struct ba_transport *t,
 		const struct a2dp_sep *sep) {
 
-	if (!(t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP))
+	if (!(t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP))
 		return errno = ENOTSUP, -1;
 
-	/* selecting new codec will change transport type */
-	pthread_mutex_lock(&t->type_mtx);
+	pthread_mutex_lock(&t->codec_id_mtx);
 
 	/* the same codec with the same configuration already selected */
-	if (t->type.codec == sep->codec_id &&
+	if (t->codec_id == sep->codec_id &&
 			memcmp(&sep->configuration, &t->a2dp.configuration, sep->capabilities_size) == 0)
 		goto final;
 
+	/* A2DP codec selection is in fact a transport recreation - new transport
+	 * with new codec is created and the current one is released. Since normally
+	 * the storage is updated only when the transport is released, we need to
+	 * update it manually here. Otherwise, new transport might be created with
+	 * stale storage data. */
+	storage_pcm_data_update(&t->a2dp.pcm);
+	storage_pcm_data_update(&t->a2dp.pcm_bc);
+
 	GError *err = NULL;
 	if (!bluez_a2dp_set_configuration(t->a2dp.bluez_dbus_sep_path, sep, &err)) {
 		error("Couldn't set A2DP configuration: %s", err->message);
-		pthread_mutex_unlock(&t->type_mtx);
+		pthread_mutex_unlock(&t->codec_id_mtx);
 		g_error_free(err);
 		return errno = EIO, -1;
 	}
 
 final:
-	pthread_mutex_unlock(&t->type_mtx);
+	pthread_mutex_unlock(&t->codec_id_mtx);
 	return 0;
 }
 
@@ -997,7 +1306,7 @@ int ba_transport_select_codec_sco(
 	(void)codec_id;
 #endif
 
-	switch (t->type.profile) {
+	switch (t->profile) {
 	case BA_TRANSPORT_PROFILE_HFP_HF:
 	case BA_TRANSPORT_PROFILE_HFP_AG:
 #if ENABLE_MSBC
@@ -1006,44 +1315,53 @@ int ba_transport_select_codec_sco(
 		if (t->sco.rfcomm == NULL)
 			return errno = ENOTSUP, -1;
 
-		/* selecting new codec will change transport type */
-		pthread_mutex_lock(&t->type_mtx);
+		/* Lock the mutex because we are about to change the codec ID. The codec
+		 * ID itself will be set by the RFCOMM thread. The RFCOMM thread and the
+		 * current one will be synchronized by the RFCOMM codec selection
+		 * condition variable. */
+		pthread_mutex_lock(&t->codec_id_mtx);
+
+		struct ba_rfcomm * const r = t->sco.rfcomm;
+		enum ba_rfcomm_signal rfcomm_signal;
 
 		/* codec already selected, skip switching */
-		if (t->type.codec == codec_id)
+		if (t->codec_id == codec_id)
 			goto final;
 
-		struct ba_rfcomm * const r = t->sco.rfcomm;
-		pthread_mutex_lock(&r->codec_selection_completed_mtx);
+		switch (codec_id) {
+		case HFP_CODEC_CVSD:
+			rfcomm_signal = BA_RFCOMM_SIGNAL_HFP_SET_CODEC_CVSD;
+			break;
+		case HFP_CODEC_MSBC:
+			rfcomm_signal = BA_RFCOMM_SIGNAL_HFP_SET_CODEC_MSBC;
+			break;
+		default:
+			g_assert_not_reached();
+		}
 
 		/* stop transport IO threads */
 		ba_transport_stop(t);
 
-		ba_transport_pcms_lock(t);
+		ba_transport_pcms_full_lock(t);
 		/* release ongoing PCM connections */
 		ba_transport_pcm_release(&t->sco.spk_pcm);
 		ba_transport_pcm_release(&t->sco.mic_pcm);
-		ba_transport_pcms_unlock(t);
+		ba_transport_pcms_full_unlock(t);
 
-		switch (codec_id) {
-		case HFP_CODEC_CVSD:
-			ba_rfcomm_send_signal(r, BA_RFCOMM_SIGNAL_HFP_SET_CODEC_CVSD);
-			pthread_cond_wait(&r->codec_selection_completed, &r->codec_selection_completed_mtx);
-			break;
-		case HFP_CODEC_MSBC:
-			ba_rfcomm_send_signal(r, BA_RFCOMM_SIGNAL_HFP_SET_CODEC_MSBC);
-			pthread_cond_wait(&r->codec_selection_completed, &r->codec_selection_completed_mtx);
-			break;
-		}
+		r->codec_selection_done = false;
+		/* delegate set codec to RFCOMM thread */
+		ba_rfcomm_send_signal(r, rfcomm_signal);
+
+		while (!r->codec_selection_done)
+			pthread_cond_wait(&r->codec_selection_cond, &t->codec_id_mtx);
 
-		pthread_mutex_unlock(&r->codec_selection_completed_mtx);
-		if (t->type.codec != codec_id) {
-			pthread_mutex_unlock(&t->type_mtx);
+		if (t->codec_id != codec_id) {
+			pthread_mutex_unlock(&t->codec_id_mtx);
 			return errno = EIO, -1;
 		}
 
 final:
-		pthread_mutex_unlock(&t->type_mtx);
+		pthread_mutex_unlock(&t->codec_id_mtx);
 		break;
 #endif
 
@@ -1056,10 +1374,9 @@ final:
 	return 0;
 }
 
-static void ba_transport_set_codec_a2dp(struct ba_transport *t) {
-
-	const uint16_t codec_id = t->type.codec;
-
+static void ba_transport_set_codec_a2dp(
+		struct ba_transport *t,
+		uint16_t codec_id) {
 	switch (codec_id) {
 	case A2DP_CODEC_SBC:
 		a2dp_sbc_transport_init(t);
@@ -1103,12 +1420,11 @@ static void ba_transport_set_codec_a2dp(struct ba_transport *t) {
 		error("Unsupported A2DP codec: %#x", codec_id);
 		g_assert_not_reached();
 	}
-
 }
 
-static void ba_transport_set_codec_sco(struct ba_transport *t) {
-
-	const uint16_t codec_id = t->type.codec;
+static void ba_transport_set_codec_sco(
+		struct ba_transport *t,
+		uint16_t codec_id) {
 
 	t->sco.spk_pcm.format = BA_TRANSPORT_PCM_FORMAT_S16_2LE;
 	t->sco.spk_pcm.channels = 1;
@@ -1146,35 +1462,73 @@ static void ba_transport_set_codec_sco(struct ba_transport *t) {
 
 }
 
+uint16_t ba_transport_get_codec(
+		struct ba_transport *t) {
+	pthread_mutex_lock(&t->codec_id_mtx);
+	uint16_t codec_id = t->codec_id;
+	pthread_mutex_unlock(&t->codec_id_mtx);
+	return codec_id;
+}
+
 void ba_transport_set_codec(
 		struct ba_transport *t,
 		uint16_t codec_id) {
 
-	if (t->type.codec == codec_id)
-		return;
+	pthread_mutex_lock(&t->codec_id_mtx);
+
+	bool changed = t->codec_id != codec_id;
+	t->codec_id = codec_id;
 
-	t->type.codec = codec_id;
+	pthread_mutex_unlock(&t->codec_id_mtx);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
-		ba_transport_set_codec_a2dp(t);
+	if (!changed)
+		return;
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO)
-		ba_transport_set_codec_sco(t);
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+		ba_transport_set_codec_a2dp(t, codec_id);
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO)
+		ba_transport_set_codec_sco(t, codec_id);
 
 }
 
+/**
+ * Start transport IO threads.
+ *
+ * This function requires transport threads to be in the IDLE state. If any of
+ * the threads is not in the IDLE state, then this function will fail and the
+ * errno will be set to EINVAL.
+ *
+ * @param t Transport structure.
+ * @return On success this function returns 0. Otherwise -1 is returned and
+ *   errno is set to indicate the error. */
 int ba_transport_start(struct ba_transport *t) {
 
-	if (!pthread_equal(t->thread_enc.id, config.main_thread) ||
-			!pthread_equal(t->thread_dec.id, config.main_thread)) {
-		errno = EEXIST;
-		return -1;
-	}
+	/* For A2DP Source profile only, it is possible that BlueZ will
+	 * activate the transport following a D-Bus "Acquire" request before the
+	 * client thread has completed the acquisition procedure by initializing
+	 * the I/O threads state. So in that case we must ensure that the
+	 * acquisition procedure is not still in progress before we check the
+	 * threads' state. */
+	if (t->profile == BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+		pthread_mutex_lock(&t->acquisition_mtx);
+
+	pthread_mutex_lock(&t->thread_enc.mutex);
+	bool is_enc_idle = t->thread_enc.state == BA_TRANSPORT_THREAD_STATE_IDLE;
+	pthread_mutex_unlock(&t->thread_enc.mutex);
+	pthread_mutex_lock(&t->thread_dec.mutex);
+	bool is_dec_idle = t->thread_dec.state == BA_TRANSPORT_THREAD_STATE_IDLE;
+	pthread_mutex_unlock(&t->thread_dec.mutex);
 
-	debug("Starting transport: %s", ba_transport_type_to_string(t->type));
+	if (t->profile == BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+		pthread_mutex_unlock(&t->acquisition_mtx);
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
-		switch (t->type.codec) {
+	if (!is_enc_idle || !is_dec_idle)
+		return errno = EINVAL, -1;
+
+	debug("Starting transport: %s", ba_transport_debug_name(t));
+
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+		switch (ba_transport_get_codec(t)) {
 		case A2DP_CODEC_SBC:
 			return a2dp_sbc_transport_start(t);
 #if ENABLE_MPEG
@@ -1207,26 +1561,69 @@ int ba_transport_start(struct ba_transport *t) {
 #endif
 		}
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
-		ba_transport_thread_create(&t->thread_enc, sco_enc_thread, "ba-sco-enc", true);
-		ba_transport_thread_create(&t->thread_dec, sco_dec_thread, "ba-sco-dec", false);
-		return 0;
-	}
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO)
+		return sco_transport_start(t);
 
-	errno = ENOTSUP;
+	g_assert_not_reached();
 	return -1;
 }
 
+/**
+ * Schedule transport IO threads cancellation. */
+static int ba_transport_stop_async(struct ba_transport *t) {
+
+#if DEBUG
+	/* Assert that we were called with the lock held, so we
+	 * can safely check and modify the stopping flag. */
+	g_assert_cmpint(pthread_mutex_trylock(&t->bt_fd_mtx), !=, 0);
+#endif
+
+	if (t->stopping)
+		return 0;
+
+	t->stopping = true;
+
+	/* Unlock the mutex before updating thread states. This is necessary to avoid
+	 * lock order inversion with the code in the ba_transport_thread_bt_acquire()
+	 * function. It is safe to do so, because we have already set the stopping
+	 * flag, so the transport_threads_cancel() function will not be called before
+	 * we acquire the lock again. */
+	pthread_mutex_unlock(&t->bt_fd_mtx);
+
+	ba_transport_thread_state_set_stopping(&t->thread_enc);
+	ba_transport_thread_state_set_stopping(&t->thread_dec);
+
+	pthread_mutex_lock(&t->bt_fd_mtx);
+
+	if (transport_thread_manager_send_command(t, BA_TRANSPORT_THREAD_MANAGER_CANCEL_THREADS) != 0)
+		return -1;
+
+	return 0;
+}
+
 /**
  * Stop transport IO threads.
  *
  * This function waits for transport IO threads termination. It is not safe
  * to call it from IO thread itself - it will cause deadlock! */
 int ba_transport_stop(struct ba_transport *t) {
-	transport_thread_manager_send_command(t, BA_TRANSPORT_THREAD_MANAGER_CANCEL_THREADS);
-	transport_thread_cancel_wait(&t->thread_enc);
-	transport_thread_cancel_wait(&t->thread_dec);
-	return 0;
+
+	if (ba_transport_thread_state_check_terminated(&t->thread_enc) &&
+			ba_transport_thread_state_check_terminated(&t->thread_dec))
+		return 0;
+
+	pthread_mutex_lock(&t->bt_fd_mtx);
+
+	int rv;
+	if ((rv = ba_transport_stop_async(t)) == -1)
+		goto fail;
+
+	while (t->stopping)
+		pthread_cond_wait(&t->stopped, &t->bt_fd_mtx);
+
+fail:
+	pthread_mutex_unlock(&t->bt_fd_mtx);
+	return rv;
 }
 
 /**
@@ -1242,8 +1639,11 @@ int ba_transport_stop_if_no_clients(struct ba_transport *t) {
 
 int ba_transport_acquire(struct ba_transport *t) {
 
+	bool acquired = false;
 	int fd = -1;
 
+	pthread_mutex_lock(&t->acquisition_mtx);
+
 	pthread_mutex_lock(&t->bt_fd_mtx);
 
 	/* If we are in the middle of IO threads stopping, wait until all resources
@@ -1259,16 +1659,29 @@ int ba_transport_acquire(struct ba_transport *t) {
 	}
 
 	/* Call transport specific acquire callback. */
-	fd = t->acquire(t);
+	if ((fd = t->acquire(t)) != -1)
+		acquired = true;
 
 final:
 	pthread_mutex_unlock(&t->bt_fd_mtx);
 
-	/* For SCO profiles we can start transport IO threads right away. There
-	 * is no asynchronous signaling from BlueZ like with A2DP profiles. */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO
-			&& fd != -1)
-		ba_transport_start(t);
+	if (acquired) {
+
+		ba_transport_thread_state_set_idle(&t->thread_enc);
+		ba_transport_thread_state_set_idle(&t->thread_dec);
+
+		/* For SCO profiles we can start transport IO threads right away. There
+		 * is no asynchronous signaling from BlueZ like with A2DP profiles. */
+		if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+			if (ba_transport_start(t) == -1) {
+				t->release(t);
+				return -1;
+			}
+		}
+
+	}
+
+	pthread_mutex_unlock(&t->acquisition_mtx);
 
 	return fd;
 }
@@ -1300,7 +1713,7 @@ int ba_transport_set_a2dp_state(
 		/* When transport is marked as pending, try to acquire transport, but only
 		 * if we are handing A2DP sink profile. For source profile, transport has
 		 * to be acquired by our controller (during the PCM open request). */
-		if (t->type.profile == BA_TRANSPORT_PROFILE_A2DP_SINK)
+		if (t->profile == BA_TRANSPORT_PROFILE_A2DP_SINK)
 			return ba_transport_acquire(t);
 		return 0;
 	case BLUEZ_A2DP_TRANSPORT_STATE_ACTIVE:
@@ -1312,32 +1725,21 @@ int ba_transport_set_a2dp_state(
 }
 
 bool ba_transport_pcm_is_active(struct ba_transport_pcm *pcm) {
-	return pcm->fd != -1 && pcm->active;
+	pthread_mutex_lock(&pcm->mutex);
+	bool active = pcm->fd != -1 && pcm->active;
+	pthread_mutex_unlock(&pcm->mutex);
+	return active;
 }
 
 int ba_transport_pcm_get_delay(const struct ba_transport_pcm *pcm) {
 	const struct ba_transport *t = pcm->t;
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
 		return t->a2dp.delay + pcm->delay;
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO)
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO)
 		return pcm->delay + 10;
 	return pcm->delay;
 }
 
-unsigned int ba_transport_pcm_volume_level_to_bt(
-		const struct ba_transport_pcm *pcm,
-		int value) {
-	int volume = audio_decibel_to_loudness(value / 100.0) * pcm->max_bt_volume;
-	return MIN((unsigned int)MAX(volume, 0), pcm->max_bt_volume);
-}
-
-int ba_transport_pcm_volume_bt_to_level(
-		const struct ba_transport_pcm *pcm,
-		unsigned int value) {
-	double level = audio_loudness_to_decibel(1.0 * value / pcm->max_bt_volume);
-	return MIN(MAX(level, -96.0), 96.0) * 100;
-}
-
 int ba_transport_pcm_volume_update(struct ba_transport_pcm *pcm) {
 
 	struct ba_transport *t = pcm->t;
@@ -1345,11 +1747,11 @@ int ba_transport_pcm_volume_update(struct ba_transport_pcm *pcm) {
 	/* In case of A2DP Source or HSP/HFP Audio Gateway skip notifying Bluetooth
 	 * device if we are using software volume control. This will prevent volume
 	 * double scaling - firstly by us and then by Bluetooth headset/speaker. */
-	if (pcm->soft_volume && t->type.profile & (
+	if (pcm->soft_volume && t->profile & (
 				BA_TRANSPORT_PROFILE_A2DP_SOURCE | BA_TRANSPORT_PROFILE_MASK_AG))
 		goto final;
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 
 		/* A2DP specification defines volume property as a single value - volume
 		 * for only one channel. For stereo audio we will use an average of left
@@ -1358,11 +1760,13 @@ int ba_transport_pcm_volume_update(struct ba_transport_pcm *pcm) {
 		unsigned int volume;
 		switch (pcm->channels) {
 		case 1:
-			volume = ba_transport_pcm_volume_level_to_bt(pcm, pcm->volume[0].level);
+			volume = ba_transport_pcm_volume_level_to_range(
+					pcm->volume[0].level, BLUEZ_A2DP_VOLUME_MAX);
 			break;
 		case 2:
-			volume = ba_transport_pcm_volume_level_to_bt(pcm,
-					(pcm->volume[0].level + pcm->volume[1].level) / 2);
+			volume = ba_transport_pcm_volume_level_to_range(
+					(pcm->volume[0].level + pcm->volume[1].level) / 2,
+					BLUEZ_A2DP_VOLUME_MAX);
 			break;
 		default:
 			g_assert_not_reached();
@@ -1384,10 +1788,16 @@ int ba_transport_pcm_volume_update(struct ba_transport_pcm *pcm) {
 		}
 
 	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO &&
-			t->sco.rfcomm != NULL) {
-		/* notify associated RFCOMM transport */
-		ba_rfcomm_send_signal(t->sco.rfcomm, BA_RFCOMM_SIGNAL_UPDATE_VOLUME);
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+
+		if (t->sco.rfcomm != NULL)
+			/* notify associated RFCOMM transport */
+			ba_rfcomm_send_signal(t->sco.rfcomm, BA_RFCOMM_SIGNAL_UPDATE_VOLUME);
+#if ENABLE_OFONO
+		else
+			ofono_call_volume_update(t);
+#endif
+
 	}
 
 final:
@@ -1397,30 +1807,43 @@ final:
 }
 
 int ba_transport_pcm_pause(struct ba_transport_pcm *pcm) {
+
+	pthread_mutex_lock(&pcm->mutex);
+	debug("PCM pause: %d", pcm->fd);
 	pcm->active = false;
-	ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_PAUSE);
-	debug("PCM paused: %d", pcm->fd);
-	return 0;
+	pthread_mutex_unlock(&pcm->mutex);
+
+	return ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_PAUSE);
 }
 
 int ba_transport_pcm_resume(struct ba_transport_pcm *pcm) {
+
+	pthread_mutex_lock(&pcm->mutex);
+	debug("PCM resume: %d", pcm->fd);
 	pcm->active = true;
-	ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_RESUME);
-	debug("PCM resumed: %d", pcm->fd);
-	return 0;
+	pthread_mutex_unlock(&pcm->mutex);
+
+	return ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_RESUME);
 }
 
 int ba_transport_pcm_drain(struct ba_transport_pcm *pcm) {
 
-	if (pthread_equal(pcm->th->id, config.main_thread))
+	pthread_mutex_lock(&pcm->mutex);
+
+	if (!ba_transport_thread_state_check_running(pcm->th)) {
+		pthread_mutex_unlock(&pcm->mutex);
 		return errno = ESRCH, -1;
+	}
 
-	pthread_mutex_lock(&pcm->synced_mtx);
+	debug("PCM drain: %d", pcm->fd);
 
+	pcm->synced = false;
 	ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_SYNC);
-	pthread_cond_wait(&pcm->synced, &pcm->synced_mtx);
 
-	pthread_mutex_unlock(&pcm->synced_mtx);
+	while (!pcm->synced)
+		pthread_cond_wait(&pcm->cond, &pcm->mutex);
+
+	pthread_mutex_unlock(&pcm->mutex);
 
 	/* TODO: Asynchronous transport release.
 	 *
@@ -1432,20 +1855,32 @@ int ba_transport_pcm_drain(struct ba_transport_pcm *pcm) {
 	 * is not implemented - it requires a little bit of refactoring. */
 	usleep(200000);
 
-	debug("PCM drained: %d", pcm->fd);
+	debug("PCM drained");
 	return 0;
 }
 
 int ba_transport_pcm_drop(struct ba_transport_pcm *pcm) {
-	ba_transport_thread_signal_send(&pcm->t->thread_enc, BA_TRANSPORT_THREAD_SIGNAL_PCM_DROP);
-	debug("PCM dropped: %d", pcm->fd);
-	return 0;
+
+#if DEBUG
+	pthread_mutex_lock(&pcm->mutex);
+	debug("PCM drop: %d", pcm->fd);
+	pthread_mutex_unlock(&pcm->mutex);
+#endif
+
+	if (io_pcm_flush(pcm) == -1)
+		return -1;
+
+	int rv = ba_transport_thread_signal_send(pcm->th, BA_TRANSPORT_THREAD_SIGNAL_PCM_DROP);
+	if (rv == -1 && errno == ESRCH)
+		rv = 0;
+
+	return rv;
 }
 
 int ba_transport_pcm_release(struct ba_transport_pcm *pcm) {
 
 #if DEBUG
-	if (pcm->t->type.profile != BA_TRANSPORT_PROFILE_NONE)
+	if (pcm->t->profile != BA_TRANSPORT_PROFILE_NONE)
 		/* assert that we were called with the lock held */
 		g_assert_cmpint(pthread_mutex_trylock(&pcm->mutex), !=, 0);
 #endif
@@ -1465,36 +1900,58 @@ final:
  * Create transport thread. */
 int ba_transport_thread_create(
 		struct ba_transport_thread *th,
-		void *(*routine)(struct ba_transport_thread *),
+		ba_transport_thread_func th_func,
 		const char *name,
 		bool master) {
 
 	struct ba_transport *t = th->t;
-	int ret;
+	sigset_t sigset, oldset;
+	int ret = -1;
+
+	pthread_mutex_lock(&th->mutex);
 
 	th->master = master;
+	th->state = BA_TRANSPORT_THREAD_STATE_STARTING;
 
 	/* Please note, this call here does not guarantee that the BT socket
 	 * will be acquired, because transport might not be opened yet. */
-	if (ba_transport_thread_bt_acquire(th) == -1)
-		return -1;
+	if (ba_transport_thread_bt_acquire(th) == -1) {
+		th->state = BA_TRANSPORT_THREAD_STATE_TERMINATED;
+		goto fail;
+	}
 
 	ba_transport_ref(t);
 
-	ba_transport_thread_set_state_starting(th);
-	if ((ret = pthread_create(&th->id, NULL, PTHREAD_ROUTINE(routine), th)) != 0) {
+	/* Before creating a new thread, we have to block all signals (new thread
+	 * will inherit signal mask). This is required, because we are using thread
+	 * cancellation for stopping transport thread, and it seems that the
+	 * cancellation can deadlock if some signal handler, which uses POSIX API
+	 * which is a cancellation point, is called during the initial phase of the
+	 * thread cancellation. On top of that BlueALSA uses g_unix_signal_add()
+	 * for handling signals, which internally uses signal handler function which
+	 * calls write() for notifying the main loop about the signal. All that can
+	 * lead to deadlock during SIGTERM handling. */
+	sigfillset(&sigset);
+	if ((ret = pthread_sigmask(SIG_SETMASK, &sigset, &oldset)) != 0)
+		warn("Couldn't set signal mask: %s", strerror(ret));
+
+	if ((ret = pthread_create(&th->id, NULL, PTHREAD_FUNC(th_func), th)) != 0) {
 		error("Couldn't create transport thread: %s", strerror(ret));
-		ba_transport_thread_set_state(th, BA_TRANSPORT_THREAD_STATE_NONE, true);
-		th->id = config.main_thread;
+		th->state = BA_TRANSPORT_THREAD_STATE_TERMINATED;
+		pthread_sigmask(SIG_SETMASK, &oldset, NULL);
 		ba_transport_unref(t);
-		return -1;
+		goto fail;
 	}
 
+	pthread_sigmask(SIG_SETMASK, &oldset, NULL);
+
 	pthread_setname_np(th->id, name);
-	debug("Created new IO thread [%s]: %s",
-			name, ba_transport_type_to_string(t->type));
+	debug("Created new IO thread [%s]: %s", name, ba_transport_debug_name(t));
 
-	return 0;
+fail:
+	pthread_mutex_unlock(&th->mutex);
+	pthread_cond_broadcast(&th->cond);
+	return ret == 0 ? 0 : -1;
 }
 
 /**
@@ -1503,6 +1960,13 @@ void ba_transport_thread_cleanup(struct ba_transport_thread *th) {
 
 	struct ba_transport *t = th->t;
 
+	/* For proper functioning of the transport, all threads have to be
+	 * operational. Therefore, if one of the threads is being cancelled,
+	 * we have to cancel all other threads. */
+	pthread_mutex_lock(&t->bt_fd_mtx);
+	ba_transport_stop_async(t);
+	pthread_mutex_unlock(&t->bt_fd_mtx);
+
 	/* Release BT socket file descriptor duplicate created either in the
 	 * ba_transport_thread_create() function or in the IO thread itself. */
 	ba_transport_thread_bt_release(th);
@@ -1516,12 +1980,9 @@ void ba_transport_thread_cleanup(struct ba_transport_thread *th) {
 	 *      indicate the end of the transport IO thread. */
 	char name[32];
 	pthread_getname_np(th->id, name, sizeof(name));
-	debug("Exiting IO thread [%s]: %s", name, ba_transport_type_to_string(t->type));
+	debug("Exiting IO thread [%s]: %s", name, ba_transport_debug_name(t));
 #endif
 
-	/* Reset transport IO thread state back to NONE. */
-	ba_transport_thread_set_state(th, BA_TRANSPORT_THREAD_STATE_NONE, true);
-
 	/* Remove reference which was taken by the ba_transport_thread_create(). */
 	ba_transport_unref(t);
 }
diff --git a/src/ba-transport.h b/src/ba-transport.h
index 64f7373..3a56fab 100644
--- a/src/ba-transport.h
+++ b/src/ba-transport.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - ba-transport.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -20,44 +20,13 @@
 #include <stdbool.h>
 #include <stddef.h>
 #include <stdint.h>
+#include <time.h>
 
 #include "a2dp.h"
 #include "ba-device.h"
-#include "ba-rfcomm.h"
 #include "bluez.h"
 #include "shared/a2dp-codecs.h"
 
-#define BA_TRANSPORT_PROFILE_NONE        (0)
-#define BA_TRANSPORT_PROFILE_A2DP_SOURCE (1 << 0)
-#define BA_TRANSPORT_PROFILE_A2DP_SINK   (2 << 0)
-#define BA_TRANSPORT_PROFILE_HFP_HF      (1 << 2)
-#define BA_TRANSPORT_PROFILE_HFP_AG      (2 << 2)
-#define BA_TRANSPORT_PROFILE_HSP_HS      (1 << 4)
-#define BA_TRANSPORT_PROFILE_HSP_AG      (2 << 4)
-
-#define BA_TRANSPORT_PROFILE_MASK_A2DP \
-	(BA_TRANSPORT_PROFILE_A2DP_SOURCE | BA_TRANSPORT_PROFILE_A2DP_SINK)
-#define BA_TRANSPORT_PROFILE_MASK_HFP \
-	(BA_TRANSPORT_PROFILE_HFP_HF | BA_TRANSPORT_PROFILE_HFP_AG)
-#define BA_TRANSPORT_PROFILE_MASK_HSP \
-	(BA_TRANSPORT_PROFILE_HSP_HS | BA_TRANSPORT_PROFILE_HSP_AG)
-#define BA_TRANSPORT_PROFILE_MASK_SCO \
-	(BA_TRANSPORT_PROFILE_MASK_HFP | BA_TRANSPORT_PROFILE_MASK_HSP)
-#define BA_TRANSPORT_PROFILE_MASK_AG \
-	(BA_TRANSPORT_PROFILE_HSP_AG | BA_TRANSPORT_PROFILE_HFP_AG)
-#define BA_TRANSPORT_PROFILE_MASK_HF \
-	(BA_TRANSPORT_PROFILE_HSP_HS | BA_TRANSPORT_PROFILE_HFP_HF)
-
-/**
- * Selected profile and audio codec.
- *
- * For A2DP vendor codecs the upper byte of the codec field
- * contains the lowest byte of the vendor ID. */
-struct ba_transport_type {
-	uint16_t profile;
-	uint16_t codec;
-};
-
 enum ba_transport_pcm_mode {
 	/* PCM used for capturing audio */
 	BA_TRANSPORT_PCM_MODE_SOURCE,
@@ -91,8 +60,10 @@ struct ba_transport_pcm {
 	/* PCM stream operation mode */
 	enum ba_transport_pcm_mode mode;
 
-	/* PCM access guard */
+	/* guard PCM data updates */
 	pthread_mutex_t mutex;
+	/* updates notification */
+	pthread_cond_t cond;
 
 	/* FIFO file descriptor */
 	int fd;
@@ -111,12 +82,12 @@ struct ba_transport_pcm {
 	 * audio encoding or decoding and data transfer. */
 	unsigned int delay;
 
+	/* indicates whether FIFO buffer was synchronized */
+	bool synced;
+
 	/* internal software volume control */
 	bool soft_volume;
 
-	/* maximal possible Bluetooth volume */
-	unsigned int max_bt_volume;
-
 	/* Volume configuration for channel left [0] and right [1]. In case of
 	 * a monophonic sound, only the left [0] channel shall be used. */
 	struct ba_transport_pcm_volume {
@@ -125,14 +96,13 @@ struct ba_transport_pcm {
 		/* audio signal mute switches */
 		bool soft_mute;
 		bool hard_mute;
-		/* pre-calculated PCM scale factor based on decibel formula
+		/* calculated PCM scale factor based on decibel formula
 		 * pow(10, dB / 20); for muted channel it shall equal 0 */
 		double scale;
 	} volume[2];
 
-	/* data synchronization */
-	pthread_mutex_t synced_mtx;
-	pthread_cond_t synced;
+	/* new PCM client mutex */
+	pthread_mutex_t client_mtx;
 
 	/* exported PCM D-Bus API */
 	char *ba_dbus_path;
@@ -140,6 +110,9 @@ struct ba_transport_pcm {
 
 };
 
+int ba_transport_pcm_volume_level_to_range(int value, int max);
+int ba_transport_pcm_volume_range_to_level(int value, int max);
+
 void ba_transport_pcm_volume_set(
 		struct ba_transport_pcm_volume *volume,
 		const int *level,
@@ -147,10 +120,12 @@ void ba_transport_pcm_volume_set(
 		const bool *hard_mute);
 
 enum ba_transport_thread_state {
-	BA_TRANSPORT_THREAD_STATE_NONE,
+	BA_TRANSPORT_THREAD_STATE_IDLE,
 	BA_TRANSPORT_THREAD_STATE_STARTING,
 	BA_TRANSPORT_THREAD_STATE_RUNNING,
 	BA_TRANSPORT_THREAD_STATE_STOPPING,
+	BA_TRANSPORT_THREAD_STATE_JOINING,
+	BA_TRANSPORT_THREAD_STATE_TERMINATED,
 };
 
 enum ba_transport_thread_signal {
@@ -167,12 +142,14 @@ struct ba_transport_thread {
 
 	/* backward reference to transport */
 	struct ba_transport *t;
+	/* associated PCM */
+	struct ba_transport_pcm *pcm;
 
-	/* guard thread structure */
+	/* guard transport thread data updates */
 	pthread_mutex_t mutex;
+	/* state/id updates notification */
+	pthread_cond_t cond;
 
-	/* guard thread state */
-	pthread_mutex_t state_mtx;
 	/* current state of the thread */
 	enum ba_transport_thread_state state;
 
@@ -185,22 +162,40 @@ struct ba_transport_thread {
 	/* notification PIPE */
 	int pipe[2];
 
-	/* state/id changed notification */
-	pthread_cond_t changed;
-
 };
 
-int ba_transport_thread_set_state(
+/**
+ * Encoder/decoder transport thread IO function. */
+typedef void *(*ba_transport_thread_func)(struct ba_transport_thread *);
+
+int ba_transport_thread_state_set(
+		struct ba_transport_thread *th,
+		enum ba_transport_thread_state state);
+
+#define ba_transport_thread_state_set_idle(th) \
+	ba_transport_thread_state_set(th, BA_TRANSPORT_THREAD_STATE_IDLE)
+#define ba_transport_thread_state_set_running(th) \
+	ba_transport_thread_state_set(th, BA_TRANSPORT_THREAD_STATE_RUNNING)
+#define ba_transport_thread_state_set_stopping(th) \
+	ba_transport_thread_state_set(th, BA_TRANSPORT_THREAD_STATE_STOPPING)
+
+bool ba_transport_thread_state_check(
+		struct ba_transport_thread *th,
+		enum ba_transport_thread_state state);
+
+#define ba_transport_thread_state_check_running(th) \
+	ba_transport_thread_state_check(th, BA_TRANSPORT_THREAD_STATE_RUNNING)
+#define ba_transport_thread_state_check_terminated(th) \
+	ba_transport_thread_state_check(th, BA_TRANSPORT_THREAD_STATE_TERMINATED)
+
+int ba_transport_thread_state_wait(
 		struct ba_transport_thread *th,
-		enum ba_transport_thread_state state,
-		bool force);
+		enum ba_transport_thread_state state);
 
-#define ba_transport_thread_set_state_starting(th) \
-	ba_transport_thread_set_state(th, BA_TRANSPORT_THREAD_STATE_STARTING, false)
-#define ba_transport_thread_set_state_running(th) \
-	ba_transport_thread_set_state(th, BA_TRANSPORT_THREAD_STATE_RUNNING, false)
-#define ba_transport_thread_set_state_stopping(th) \
-	ba_transport_thread_set_state(th, BA_TRANSPORT_THREAD_STATE_STOPPING, false)
+#define ba_transport_thread_state_wait_running(th) \
+	ba_transport_thread_state_wait(th, BA_TRANSPORT_THREAD_STATE_RUNNING)
+#define ba_transport_thread_state_wait_terminated(th) \
+	ba_transport_thread_state_wait(th, BA_TRANSPORT_THREAD_STATE_TERMINATED)
 
 int ba_transport_thread_bt_acquire(
 		struct ba_transport_thread *th);
@@ -220,18 +215,47 @@ enum ba_transport_thread_manager_command {
 	BA_TRANSPORT_THREAD_MANAGER_CANCEL_IF_NO_CLIENTS,
 };
 
+enum ba_transport_profile {
+	BA_TRANSPORT_PROFILE_NONE        = 0,
+	BA_TRANSPORT_PROFILE_A2DP_SOURCE = (1 << 0),
+	BA_TRANSPORT_PROFILE_A2DP_SINK   = (2 << 0),
+	BA_TRANSPORT_PROFILE_HFP_HF      = (1 << 2),
+	BA_TRANSPORT_PROFILE_HFP_AG      = (2 << 2),
+	BA_TRANSPORT_PROFILE_HSP_HS      = (1 << 4),
+	BA_TRANSPORT_PROFILE_HSP_AG      = (2 << 4),
+};
+
+#define BA_TRANSPORT_PROFILE_MASK_A2DP \
+	(BA_TRANSPORT_PROFILE_A2DP_SOURCE | BA_TRANSPORT_PROFILE_A2DP_SINK)
+#define BA_TRANSPORT_PROFILE_MASK_HFP \
+	(BA_TRANSPORT_PROFILE_HFP_HF | BA_TRANSPORT_PROFILE_HFP_AG)
+#define BA_TRANSPORT_PROFILE_MASK_HSP \
+	(BA_TRANSPORT_PROFILE_HSP_HS | BA_TRANSPORT_PROFILE_HSP_AG)
+#define BA_TRANSPORT_PROFILE_MASK_SCO \
+	(BA_TRANSPORT_PROFILE_MASK_HFP | BA_TRANSPORT_PROFILE_MASK_HSP)
+#define BA_TRANSPORT_PROFILE_MASK_AG \
+	(BA_TRANSPORT_PROFILE_HSP_AG | BA_TRANSPORT_PROFILE_HFP_AG)
+#define BA_TRANSPORT_PROFILE_MASK_HF \
+	(BA_TRANSPORT_PROFILE_HSP_HS | BA_TRANSPORT_PROFILE_HFP_HF)
+
 struct ba_transport {
 
 	/* backward reference to device */
 	struct ba_device *d;
 
-	/* guard modifications of transport type, e.g. codec */
-	pthread_mutex_t type_mtx;
-
 	/* Transport structure covers all transports supported by BlueALSA. However,
 	 * every transport requires specific handling - link acquisition, transport
 	 * specific configuration, freeing resources, etc. */
-	struct ba_transport_type type;
+	enum ba_transport_profile profile;
+
+	/* guard modifications of transport codec */
+	pthread_mutex_t codec_id_mtx;
+	/* For A2DP vendor codecs the upper byte of the codec field
+	 * contains the lowest byte of the vendor ID. */
+	uint16_t codec_id;
+
+	/* synchronization for codec selection */
+	pthread_mutex_t codec_select_client_mtx;
 
 	/* data for D-Bus management */
 	char *bluez_dbus_owner;
@@ -241,6 +265,10 @@ struct ba_transport {
 	 * and the IO threads stopping flag */
 	pthread_mutex_t bt_fd_mtx;
 
+	/* Ensure BT file descriptor acquisition procedure
+	 * is completed atomically. */
+	pthread_mutex_t acquisition_mtx;
+
 	/* This field stores a file descriptor (socket) associated with the BlueZ
 	 * side of the transport. The role of this socket depends on the transport
 	 * type - it can be either A2DP or SCO link. */
@@ -297,12 +325,24 @@ struct ba_transport {
 
 		struct {
 
-			/* associated RFCOMM thread */
+			/* Associated RFCOMM thread for SCO transport handled by local
+			 * HSP/HFP implementation. Otherwise, this field is set to NULL. */
 			struct ba_rfcomm *rfcomm;
 
+#if ENABLE_OFONO
+			/* Associated oFono card and modem paths. In case when SCO transport
+			 * is not oFono-based, these fields are set to NULL. */
+			char *ofono_dbus_path_card;
+			char *ofono_dbus_path_modem;
+#endif
+
 			/* Speaker and microphone signals should to be exposed as
 			 * a separate PCM devices. Hence, there is a requirement
-			 * for separate configurations. */
+			 * for separate configurations.
+			 *
+			 * NOTE: The speaker/microphone notation always refers to the whole
+			 *       AG/HS setup. For AG the speaker is an outgoing audio stream,
+			 *       while for HS the speaker is an incoming audio stream. */
 			struct ba_transport_pcm spk_pcm;
 			struct ba_transport_pcm mic_pcm;
 
@@ -324,18 +364,23 @@ struct ba_transport {
 
 struct ba_transport *ba_transport_new_a2dp(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_owner,
 		const char *dbus_path,
 		const struct a2dp_codec *codec,
 		const void *configuration);
 struct ba_transport *ba_transport_new_sco(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_owner,
 		const char *dbus_path,
 		int rfcomm_fd);
 
+#if DEBUG
+const char *ba_transport_debug_name(
+		const struct ba_transport *t);
+#endif
+
 struct ba_transport *ba_transport_lookup(
 		struct ba_device *device,
 		const char *dbus_path);
@@ -348,9 +393,6 @@ void ba_transport_unref(struct ba_transport *t);
 struct ba_transport_pcm *ba_transport_pcm_ref(struct ba_transport_pcm *pcm);
 void ba_transport_pcm_unref(struct ba_transport_pcm *pcm);
 
-int ba_transport_pcms_lock(struct ba_transport *t);
-int ba_transport_pcms_unlock(struct ba_transport *t);
-
 int ba_transport_select_codec_a2dp(
 		struct ba_transport *t,
 		const struct a2dp_sep *sep);
@@ -358,6 +400,8 @@ int ba_transport_select_codec_sco(
 		struct ba_transport *t,
 		uint16_t codec_id);
 
+uint16_t ba_transport_get_codec(
+		struct ba_transport *t);
 void ba_transport_set_codec(
 		struct ba_transport *t,
 		uint16_t codec_id);
@@ -379,13 +423,6 @@ bool ba_transport_pcm_is_active(
 int ba_transport_pcm_get_delay(
 		const struct ba_transport_pcm *pcm);
 
-unsigned int ba_transport_pcm_volume_level_to_bt(
-		const struct ba_transport_pcm *pcm,
-		int value);
-int ba_transport_pcm_volume_bt_to_level(
-		const struct ba_transport_pcm *pcm,
-		unsigned int value);
-
 int ba_transport_pcm_volume_update(
 		struct ba_transport_pcm *pcm);
 
@@ -398,13 +435,13 @@ int ba_transport_pcm_release(struct ba_transport_pcm *pcm);
 
 int ba_transport_thread_create(
 		struct ba_transport_thread *th,
-		void *(*routine)(struct ba_transport_thread *),
+		ba_transport_thread_func th_func,
 		const char *name,
 		bool master);
 
 void ba_transport_thread_cleanup(struct ba_transport_thread *th);
 
 #define debug_transport_thread_loop(th, tag) \
-	debug("IO loop: %s: %s: %s", tag, __func__, ba_transport_type_to_string((th)->t->type))
+	debug("IO loop: %s: %s: %s", tag, __func__, ba_transport_debug_name((th)->t))
 
 #endif
diff --git a/src/bluealsa-config.c b/src/bluealsa-config.c
index cf38b1f..6c9cb4d 100644
--- a/src/bluealsa-config.c
+++ b/src/bluealsa-config.c
@@ -9,6 +9,7 @@
  */
 
 #include "bluealsa-config.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <fcntl.h>
 #include <stdbool.h>
@@ -65,10 +66,10 @@ struct ba_config config = {
 		HFP_AG_FEAT_ECC |
 		0,
 
-	/* build-in Apple accessory identification */
+	/* built-in Apple accessory identification */
 	.hfp.xapl_vendor_id = 0xB103,
 	.hfp.xapl_product_id = 0xA15A,
-	.hfp.xapl_software_version = "0300",
+	.hfp.xapl_sw_version = 0x0400,
 	.hfp.xapl_product_name = "BlueALSA",
 	.hfp.xapl_features =
 		XAPL_FEATURE_BATTERY |
diff --git a/src/bluealsa-config.h b/src/bluealsa-config.h
index a8988d8..daefc4e 100644
--- a/src/bluealsa-config.h
+++ b/src/bluealsa-config.h
@@ -21,7 +21,7 @@
 #include <stdbool.h>
 #include <stdint.h>
 
-#include <bluetooth/bluetooth.h>
+#include <bluetooth/bluetooth.h> /* IWYU pragma: keep */
 #include <bluetooth/hci.h>
 
 #include <gio/gio.h>
@@ -89,7 +89,7 @@ struct ba_config {
 		/* information exposed via Apple AT extension */
 		unsigned int xapl_vendor_id;
 		unsigned int xapl_product_id;
-		const char *xapl_software_version;
+		unsigned int xapl_sw_version;
 		const char *xapl_product_name;
 		unsigned int xapl_features;
 
diff --git a/src/bluealsa-dbus.c b/src/bluealsa-dbus.c
index 7c6f9ae..1dae4e9 100644
--- a/src/bluealsa-dbus.c
+++ b/src/bluealsa-dbus.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - bluealsa-dbus.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,12 +9,15 @@
  */
 
 #include "bluealsa-dbus.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <fcntl.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
 #include <string.h>
 #include <sys/socket.h>
 #include <unistd.h>
@@ -33,6 +36,7 @@
 #include "bluealsa-config.h"
 #include "bluealsa-iface.h"
 #include "bluealsa-skeleton.h"
+#include "bluez.h"
 #include "dbus.h"
 #include "hfp.h"
 #include "utils.h"
@@ -110,7 +114,8 @@ static GVariant *ba_variant_new_bluealsa_codecs(void) {
 	}
 
 	/* Expose A2DP codecs always in the same order. */
-	a2dp_codecs_qsort(a2dp_codecs_tmp, n);
+	qsort(a2dp_codecs_tmp, n, sizeof(*a2dp_codecs_tmp),
+			QSORT_COMPAR(a2dp_codec_ptr_cmp));
 
 	for (size_t i = 0; i < n; i++) {
 		const char *profile = a2dp_codecs_tmp[i]->dir == A2DP_SOURCE ?
@@ -175,24 +180,38 @@ GVariant *ba_variant_new_device_battery(const struct ba_device *d) {
 }
 
 static GVariant *ba_variant_new_transport_type(const struct ba_transport *t) {
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
+	switch (t->profile) {
+	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_A2DP_SOURCE);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK)
+	case BA_TRANSPORT_PROFILE_A2DP_SINK:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_A2DP_SINK);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_HFP_AG)
+	case BA_TRANSPORT_PROFILE_HFP_AG:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_HFP_AG);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_HFP_HF)
+	case BA_TRANSPORT_PROFILE_HFP_HF:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_HFP_HF);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_HSP_AG)
+	case BA_TRANSPORT_PROFILE_HSP_AG:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_HSP_AG);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_HSP_HS)
+	case BA_TRANSPORT_PROFILE_HSP_HS:
 		return g_variant_new_string(BLUEALSA_TRANSPORT_TYPE_HSP_HS);
-	warn("Unsupported transport type: %#x", t->type.profile);
-	return g_variant_new_string("<null>");
+	case BA_TRANSPORT_PROFILE_NONE:
+		break;
+	}
+	error("Unsupported transport type: %#x", t->profile);
+	g_assert_not_reached();
+	return NULL;
 }
 
 static GVariant *ba_variant_new_rfcomm_features(const struct ba_rfcomm *r) {
-	return g_variant_new_uint32(r->hfp_features);
+
+	const char *strv[32];
+	size_t n = 0;
+
+	if (r->sco->profile & BA_TRANSPORT_PROFILE_HFP_AG)
+		n = hfp_hf_features_to_strings(r->hf_features, strv, ARRAYSIZE(strv));
+	if (r->sco->profile & BA_TRANSPORT_PROFILE_HFP_HF)
+		n = hfp_ag_features_to_strings(r->ag_features, strv, ARRAYSIZE(strv));
+
+	return g_variant_new_strv(strv, n);
 }
 
 static GVariant *ba_variant_new_pcm_mode(const struct ba_transport_pcm *pcm) {
@@ -201,6 +220,10 @@ static GVariant *ba_variant_new_pcm_mode(const struct ba_transport_pcm *pcm) {
 	return g_variant_new_string(BLUEALSA_PCM_MODE_SINK);
 }
 
+static GVariant *ba_variant_new_pcm_running(const struct ba_transport_pcm *pcm) {
+	return g_variant_new_boolean(pcm->th->state == BA_TRANSPORT_THREAD_STATE_RUNNING);
+}
+
 static GVariant *ba_variant_new_pcm_format(const struct ba_transport_pcm *pcm) {
 	return g_variant_new_uint16(pcm->format);
 }
@@ -216,13 +239,21 @@ static GVariant *ba_variant_new_pcm_sampling(const struct ba_transport_pcm *pcm)
 static GVariant *ba_variant_new_pcm_codec(const struct ba_transport_pcm *pcm) {
 	const struct ba_transport *t = pcm->t;
 	const char *codec = NULL;
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
-		codec = a2dp_codecs_codec_id_to_string(t->type.codec);
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO)
-		codec = hfp_codec_id_to_string(t->type.codec);
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+		codec = a2dp_codecs_codec_id_to_string(t->codec_id);
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO)
+		codec = hfp_codec_id_to_string(t->codec_id);
 	if (codec != NULL)
 		return g_variant_new_string(codec);
-	return g_variant_new_string("<null>");
+	return NULL;
+}
+
+static GVariant *ba_variant_new_pcm_codec_config(const struct ba_transport_pcm *pcm) {
+	const struct ba_transport *t = pcm->t;
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+		return g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, &t->a2dp.configuration,
+				t->a2dp.codec->capabilities_size, sizeof(uint8_t));
+	return NULL;
 }
 
 static GVariant *ba_variant_new_pcm_delay(const struct ba_transport_pcm *pcm) {
@@ -238,26 +269,36 @@ static uint8_t ba_volume_pack_dbus_volume(bool muted, int value) {
 }
 
 static GVariant *ba_variant_new_pcm_volume(const struct ba_transport_pcm *pcm) {
+	const bool is_sco = pcm->t->profile & BA_TRANSPORT_PROFILE_MASK_SCO;
+	const int max = is_sco ? HFP_VOLUME_GAIN_MAX : BLUEZ_A2DP_VOLUME_MAX;
 	uint8_t ch1 = ba_volume_pack_dbus_volume(pcm->volume[0].scale == 0,
-			ba_transport_pcm_volume_level_to_bt(pcm, pcm->volume[0].level));
+			ba_transport_pcm_volume_level_to_range(pcm->volume[0].level, max));
 	uint8_t ch2 = ba_volume_pack_dbus_volume(pcm->volume[1].scale == 0,
-			ba_transport_pcm_volume_level_to_bt(pcm, pcm->volume[1].level));
+			ba_transport_pcm_volume_level_to_range(pcm->volume[1].level, max));
 	return g_variant_new_uint16((ch1 << 8) | (pcm->channels == 1 ? 0 : ch2));
 }
 
 static void ba_variant_populate_pcm(GVariantBuilder *props, const struct ba_transport_pcm *pcm) {
+
+	GVariant *value;
 	g_variant_builder_init(props, G_VARIANT_TYPE("a{sv}"));
+
 	g_variant_builder_add(props, "{sv}", "Device", ba_variant_new_device_path(pcm->t->d));
 	g_variant_builder_add(props, "{sv}", "Sequence", ba_variant_new_device_sequence(pcm->t->d));
 	g_variant_builder_add(props, "{sv}", "Transport", ba_variant_new_transport_type(pcm->t));
 	g_variant_builder_add(props, "{sv}", "Mode", ba_variant_new_pcm_mode(pcm));
+	g_variant_builder_add(props, "{sv}", "Running", ba_variant_new_pcm_running(pcm));
 	g_variant_builder_add(props, "{sv}", "Format", ba_variant_new_pcm_format(pcm));
 	g_variant_builder_add(props, "{sv}", "Channels", ba_variant_new_pcm_channels(pcm));
 	g_variant_builder_add(props, "{sv}", "Sampling", ba_variant_new_pcm_sampling(pcm));
-	g_variant_builder_add(props, "{sv}", "Codec", ba_variant_new_pcm_codec(pcm));
+	if ((value = ba_variant_new_pcm_codec(pcm)) != NULL)
+		g_variant_builder_add(props, "{sv}", "Codec", value);
+	if ((value = ba_variant_new_pcm_codec_config(pcm)) != NULL)
+		g_variant_builder_add(props, "{sv}", "CodecConfiguration", value);
 	g_variant_builder_add(props, "{sv}", "Delay", ba_variant_new_pcm_delay(pcm));
 	g_variant_builder_add(props, "{sv}", "SoftVolume", ba_variant_new_pcm_soft_volume(pcm));
 	g_variant_builder_add(props, "{sv}", "Volume", ba_variant_new_pcm_volume(pcm));
+
 }
 
 static bool ba_variant_populate_sep(GVariantBuilder *props, const struct a2dp_sep *sep) {
@@ -322,74 +363,6 @@ static bool ba_variant_populate_sep(GVariantBuilder *props, const struct a2dp_se
 	return true;
 }
 
-static void bluealsa_manager_get_pcms(GDBusMethodInvocation *inv, void *userdata) {
-	(void)userdata;
-
-	GVariantBuilder pcms;
-	g_variant_builder_init(&pcms, G_VARIANT_TYPE("a{oa{sv}}"));
-
-	struct ba_adapter *a;
-	size_t i;
-
-	for (i = 0; i < ARRAYSIZE(config.adapters); i++) {
-
-		if ((a = ba_adapter_lookup(i)) == NULL)
-			continue;
-
-		GHashTableIter iter_d, iter_t;
-		GVariantBuilder props;
-		struct ba_device *d;
-		struct ba_transport *t;
-
-		pthread_mutex_lock(&a->devices_mutex);
-		g_hash_table_iter_init(&iter_d, a->devices);
-		while (g_hash_table_iter_next(&iter_d, NULL, (gpointer)&d)) {
-
-			pthread_mutex_lock(&d->transports_mutex);
-			g_hash_table_iter_init(&iter_t, d->transports);
-			while (g_hash_table_iter_next(&iter_t, NULL, (gpointer)&t)) {
-
-				if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
-
-					if (t->a2dp.pcm.ba_dbus_exported) {
-						ba_variant_populate_pcm(&props, &t->a2dp.pcm);
-						g_variant_builder_add(&pcms, "{oa{sv}}", t->a2dp.pcm.ba_dbus_path, &props);
-						g_variant_builder_clear(&props);
-					}
-
-					if (t->a2dp.pcm_bc.ba_dbus_exported) {
-						ba_variant_populate_pcm(&props, &t->a2dp.pcm_bc);
-						g_variant_builder_add(&pcms, "{oa{sv}}", t->a2dp.pcm_bc.ba_dbus_path, &props);
-						g_variant_builder_clear(&props);
-					}
-
-				}
-				else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
-
-					ba_variant_populate_pcm(&props, &t->sco.spk_pcm);
-					g_variant_builder_add(&pcms, "{oa{sv}}", t->sco.spk_pcm.ba_dbus_path, &props);
-					g_variant_builder_clear(&props);
-
-					ba_variant_populate_pcm(&props, &t->sco.mic_pcm);
-					g_variant_builder_add(&pcms, "{oa{sv}}", t->sco.mic_pcm.ba_dbus_path, &props);
-					g_variant_builder_clear(&props);
-
-				}
-
-			}
-
-			pthread_mutex_unlock(&d->transports_mutex);
-		}
-
-		pthread_mutex_unlock(&a->devices_mutex);
-		ba_adapter_unref(a);
-
-	}
-
-	g_dbus_method_invocation_return_value(inv, g_variant_new("(a{oa{sv}})", &pcms));
-	g_variant_builder_clear(&pcms);
-}
-
 static GVariant *bluealsa_manager_get_property(const char *property,
 		GError **error, void *userdata) {
 	(void)error;
@@ -412,14 +385,7 @@ static GVariant *bluealsa_manager_get_property(const char *property,
  * Register BlueALSA D-Bus manager interfaces. */
 void bluealsa_dbus_register(void) {
 
-	static const GDBusMethodCallDispatcher dispatchers[] = {
-		{ .method = "GetPCMs",
-			.handler = bluealsa_manager_get_pcms },
-		{ 0 },
-	};
-
 	static const GDBusInterfaceSkeletonVTable vtable = {
-		.dispatchers = dispatchers,
 		.get_property = bluealsa_manager_get_property,
 	};
 
@@ -500,17 +466,21 @@ static void bluealsa_pcm_open(GDBusMethodInvocation *inv, void *userdata) {
 
 	/* Prevent two (or more) clients trying to
 	 * open the same PCM at the same time. */
-	pthread_mutex_lock(&pcm->mutex);
+	pthread_mutex_lock(&pcm->client_mtx);
 
 	/* preliminary check whether HFP codes is selected */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO &&
-			t->type.codec == HFP_CODEC_UNDEFINED) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO &&
+			ba_transport_get_codec(t) == HFP_CODEC_UNDEFINED) {
 		g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
 				G_DBUS_ERROR_FAILED, "HFP audio codec not selected");
 		goto fail;
 	}
 
-	if (pcm->fd != -1) {
+	pthread_mutex_lock(&pcm->mutex);
+	const int pcm_fd = pcm->fd;
+	pthread_mutex_unlock(&pcm->mutex);
+
+	if (pcm_fd != -1) {
 		g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
 				G_DBUS_ERROR_FAILED, "%s", strerror(EBUSY));
 		goto fail;
@@ -536,60 +506,54 @@ static void bluealsa_pcm_open(GDBusMethodInvocation *inv, void *userdata) {
 	 * headset will not run voltage converter (power-on its circuit board) until
 	 * the transport is acquired in order to extend battery life. For profiles
 	 * like A2DP Sink and HFP headset, we will wait for incoming connection. */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE ||
-			t->type.profile & BA_TRANSPORT_PROFILE_MASK_AG) {
-
-		enum ba_transport_thread_state state;
+	if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE ||
+			t->profile & BA_TRANSPORT_PROFILE_MASK_AG) {
 
 		if (ba_transport_acquire(t) == -1) {
 			g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
-					G_DBUS_ERROR_FAILED, "Acquire transport: %s", strerror(errno));
+					G_DBUS_ERROR_IO_ERROR, "Acquire transport: %s", strerror(errno));
 			goto fail;
 		}
 
-		/* wait until ready to process audio */
-		pthread_mutex_lock(&th->state_mtx);
-		while ((state = th->state) < BA_TRANSPORT_THREAD_STATE_RUNNING)
-			pthread_cond_wait(&th->changed, &th->state_mtx);
-		pthread_mutex_unlock(&th->state_mtx);
-
-		/* bail if something has gone wrong */
-		if (state != BA_TRANSPORT_THREAD_STATE_RUNNING) {
+		/* Wait until transport thread is ready to process audio. */
+		if (ba_transport_thread_state_wait_running(th) == -1) {
 			g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
-					G_DBUS_ERROR_IO_ERROR, "Acquire transport: %s", strerror(EIO));
+					G_DBUS_ERROR_IO_ERROR, "Acquire transport: %s", strerror(errno));
 			goto fail;
 		}
 
 	}
 
+	pthread_mutex_lock(&pcm->mutex);
 	/* get correct PIPE endpoint - PIPE is unidirectional */
 	pcm->fd = pcm_fds[is_sink ? 0 : 1];
 	/* set newly opened PCM as active */
 	pcm->active = true;
+	pthread_mutex_unlock(&pcm->mutex);
 
 	GIOChannel *ch = g_io_channel_unix_new(pcm_fds[2]);
+	g_io_channel_set_close_on_unref(ch, TRUE);
+	g_io_channel_set_encoding(ch, NULL, NULL);
+
 	g_io_add_watch_full(ch, G_PRIORITY_DEFAULT, G_IO_IN,
 			bluealsa_pcm_controller, ba_transport_pcm_ref(pcm),
 			(GDestroyNotify)ba_transport_pcm_unref);
-	g_io_channel_set_close_on_unref(ch, TRUE);
-	g_io_channel_set_encoding(ch, NULL, NULL);
 	g_io_channel_unref(ch);
 
 	/* notify our audio thread that the FIFO is ready */
 	ba_transport_thread_signal_send(th, BA_TRANSPORT_THREAD_SIGNAL_PCM_OPEN);
 
-	pthread_mutex_unlock(&pcm->mutex);
-
 	int fds[2] = { pcm_fds[is_sink ? 1 : 0], pcm_fds[3] };
 	GUnixFDList *fd_list = g_unix_fd_list_new_from_array(fds, 2);
 	g_dbus_method_invocation_return_value_with_unix_fd_list(inv,
 			g_variant_new("(hh)", 0, 1), fd_list);
 	g_object_unref(fd_list);
 
+	pthread_mutex_unlock(&pcm->client_mtx);
 	return;
 
 fail:
-	pthread_mutex_unlock(&pcm->mutex);
+	pthread_mutex_unlock(&pcm->client_mtx);
 	/* clean up created file descriptors */
 	for (i = 0; i < ARRAYSIZE(pcm_fds); i++)
 		if (pcm_fds[i] != -1)
@@ -605,7 +569,7 @@ static void bluealsa_pcm_get_codecs(GDBusMethodInvocation *inv, void *userdata)
 	GVariantBuilder codecs;
 	g_variant_builder_init(&codecs, G_VARIANT_TYPE("a{sa{sv}}"));
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 
 		GArray *codec_ids = g_array_sized_new(FALSE, FALSE, sizeof(uint16_t), 16);
 		size_t i;
@@ -643,15 +607,28 @@ static void bluealsa_pcm_get_codecs(GDBusMethodInvocation *inv, void *userdata)
 		g_array_free(codec_ids, TRUE);
 
 	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
+	else if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
 
-		if (config.hfp.codecs.cvsd)
+		const struct ba_rfcomm *t_sco_rfcomm = t->sco.rfcomm;
+
+		/* HFP codec is selected by the AG. Because of that, HF is not aware of
+		 * AG supported codecs until the codec is actually selected. Anyway, we
+		 * will try to provide some heuristic here.
+		 * For built-in HFP profiles we will mark given codec as available, if
+		 * both AG and HF can support it. When HFP is provided by an external
+		 * application like oFono, we will mark given codec as available, if it
+		 * is enabled by our global configuration. */
+
+		if ((t_sco_rfcomm != NULL &&
+					t_sco_rfcomm->ag_codecs.cvsd && t_sco_rfcomm->hf_codecs.cvsd) ||
+				(t_sco_rfcomm == NULL && config.hfp.codecs.cvsd))
 			g_variant_builder_add(&codecs, "{sa{sv}}",
 					hfp_codec_id_to_string(HFP_CODEC_CVSD), NULL);
 
 #if ENABLE_MSBC
-		if (config.hfp.codecs.msbc &&
-				t->sco.rfcomm != NULL && t->sco.rfcomm->codecs.msbc)
+		if ((t_sco_rfcomm != NULL &&
+					t_sco_rfcomm->ag_codecs.msbc && t_sco_rfcomm->hf_codecs.msbc) ||
+				(t_sco_rfcomm == NULL && config.hfp.codecs.msbc))
 			g_variant_builder_add(&codecs, "{sa{sv}}",
 					hfp_codec_id_to_string(HFP_CODEC_MSBC), NULL);
 #endif
@@ -674,10 +651,16 @@ static void bluealsa_pcm_select_codec(GDBusMethodInvocation *inv, void *userdata
 	const char *codec_name;
 	const char *property;
 
+	/* Since transport can provide more than one PCM interface, i.e., source
+	 * and sink for bi-directional transports like HSP/HFP. In such case, both
+	 * PCMs should use the same codec. Given that, we need to lock codec
+	 * selection on the transport level. */
+	pthread_mutex_lock(&t->codec_select_client_mtx);
+
 	a2dp_t a2dp_configuration = {};
 	size_t a2dp_configuration_size = 0;
 
-	g_variant_get(params, "(sa{sv})", &codec_name, &properties);
+	g_variant_get(params, "(&sa{sv})", &codec_name, &properties);
 	while (g_variant_iter_next(properties, "{&sv}", &property, &value)) {
 
 		if (strcmp(property, "Configuration") == 0 &&
@@ -700,7 +683,7 @@ static void bluealsa_pcm_select_codec(GDBusMethodInvocation *inv, void *userdata
 		value = NULL;
 	}
 
-	if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP) {
 
 		/* support for Stream End-Points not enabled in BlueZ */
 		if (t->d->seps == NULL) {
@@ -754,7 +737,12 @@ static void bluealsa_pcm_select_codec(GDBusMethodInvocation *inv, void *userdata
 	}
 	else {
 
-		uint16_t codec_id = hfp_codec_id_from_string(codec_name);
+		uint16_t codec_id;
+		if ((codec_id = hfp_codec_id_from_string(codec_name)) == HFP_CODEC_UNDEFINED) {
+			errmsg = "HFP codec not available";
+			goto fail;
+		}
+
 		if (ba_transport_select_codec_sco(t, codec_id) == -1)
 			goto fail;
 
@@ -771,6 +759,7 @@ fail:
 			G_DBUS_ERROR_FAILED, "%s", errmsg);
 
 final:
+	pthread_mutex_unlock(&t->codec_select_client_mtx);
 	g_variant_iter_free(properties);
 	if (value != NULL)
 		g_variant_unref(value);
@@ -815,10 +804,10 @@ static GVariant *bluealsa_pcm_get_properties(void *userdata) {
 
 static GVariant *bluealsa_pcm_get_property(const char *property,
 		GError **error, void *userdata) {
-	(void)error;
 
 	struct ba_transport_pcm *pcm = (struct ba_transport_pcm *)userdata;
 	struct ba_device *d = pcm->t->d;
+	GVariant *value;
 
 	if (strcmp(property, "Device") == 0)
 		return ba_variant_new_device_path(d);
@@ -828,14 +817,24 @@ static GVariant *bluealsa_pcm_get_property(const char *property,
 		return ba_variant_new_transport_type(pcm->t);
 	if (strcmp(property, "Mode") == 0)
 		return ba_variant_new_pcm_mode(pcm);
+	if (strcmp(property, "Running") == 0)
+		return ba_variant_new_pcm_running(pcm);
 	if (strcmp(property, "Format") == 0)
 		return ba_variant_new_pcm_format(pcm);
 	if (strcmp(property, "Channels") == 0)
 		return ba_variant_new_pcm_channels(pcm);
 	if (strcmp(property, "Sampling") == 0)
 		return ba_variant_new_pcm_sampling(pcm);
-	if (strcmp(property, "Codec") == 0)
-		return ba_variant_new_pcm_codec(pcm);
+	if (strcmp(property, "Codec") == 0) {
+		if ((value = ba_variant_new_pcm_codec(pcm)) == NULL)
+			goto unavailable;
+		return value;
+	}
+	if (strcmp(property, "CodecConfiguration") == 0) {
+		if ((value = ba_variant_new_pcm_codec_config(pcm)) == NULL)
+			goto unavailable;
+		return value;
+	}
 	if (strcmp(property, "Delay") == 0)
 		return ba_variant_new_pcm_delay(pcm);
 	if (strcmp(property, "SoftVolume") == 0)
@@ -845,6 +844,12 @@ static GVariant *bluealsa_pcm_get_property(const char *property,
 
 	g_assert_not_reached();
 	return NULL;
+
+unavailable:
+	if (error != NULL)
+		*error = g_error_new(G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS,
+				"No such property '%s'", property);
+	return NULL;
 }
 
 static bool bluealsa_pcm_set_property(const char *property, GVariant *value,
@@ -861,17 +866,22 @@ static bool bluealsa_pcm_set_property(const char *property, GVariant *value,
 
 	if (strcmp(property, "Volume") == 0) {
 
+		const bool is_sco = pcm->t->profile & BA_TRANSPORT_PROFILE_MASK_SCO;
+		const int max = is_sco ? HFP_VOLUME_GAIN_MAX : BLUEZ_A2DP_VOLUME_MAX;
+
 		uint16_t packed = g_variant_get_uint16(value);
 		uint8_t ch1 = packed >> 8;
 		uint8_t ch2 = packed & 0xFF;
 
-		int ch1_level = ba_transport_pcm_volume_bt_to_level(pcm, ch1 & 0x7F);
+		int ch1_level = ba_transport_pcm_volume_range_to_level(ch1 & 0x7F, max);
 		bool ch1_muted = !!(ch1 & 0x80);
-		int ch2_level = ba_transport_pcm_volume_bt_to_level(pcm, ch2 & 0x7F);
+		int ch2_level = ba_transport_pcm_volume_range_to_level(ch2 & 0x7F, max);
 		bool ch2_muted = !!(ch2 & 0x80);
 
+		pthread_mutex_lock(&pcm->mutex);
 		ba_transport_pcm_volume_set(&pcm->volume[0], &ch1_level, &ch1_muted, NULL);
 		ba_transport_pcm_volume_set(&pcm->volume[1], &ch2_level, &ch2_muted, NULL);
+		pthread_mutex_unlock(&pcm->mutex);
 
 		debug("Setting volume: %u [%.2f dB] %c%c %u [%.2f dB]",
 				ch1 & 0x7F, 0.01 * ch1_level, ch1_muted ? 'x' : '<',
@@ -922,13 +932,6 @@ int bluealsa_dbus_pcm_register(struct ba_transport_pcm *pcm) {
 	g_dbus_object_manager_server_export(bluealsa_dbus_manager, skeleton);
 	pcm->ba_dbus_exported = true;
 
-	GVariantBuilder props;
-	ba_variant_populate_pcm(&props, pcm);
-	g_dbus_connection_emit_signal(config.dbus, NULL,
-			bluealsa_dbus_manager_path, BLUEALSA_IFACE_MANAGER, "PCMAdded",
-			g_variant_new("(oa{sv})", pcm->ba_dbus_path, &props), NULL);
-	g_variant_builder_clear(&props);
-
 fail:
 
 	if (skeleton != NULL)
@@ -944,6 +947,8 @@ void bluealsa_dbus_pcm_update(struct ba_transport_pcm *pcm, unsigned int mask) {
 	GVariantBuilder props;
 	g_variant_builder_init(&props, G_VARIANT_TYPE("a{sv}"));
 
+	if (mask & BA_DBUS_PCM_UPDATE_RUNNING)
+		g_variant_builder_add(&props, "{sv}", "Running", ba_variant_new_pcm_running(pcm));
 	if (mask & BA_DBUS_PCM_UPDATE_FORMAT)
 		g_variant_builder_add(&props, "{sv}", "Format", ba_variant_new_pcm_format(pcm));
 	if (mask & BA_DBUS_PCM_UPDATE_CHANNELS)
@@ -952,6 +957,8 @@ void bluealsa_dbus_pcm_update(struct ba_transport_pcm *pcm, unsigned int mask) {
 		g_variant_builder_add(&props, "{sv}", "Sampling", ba_variant_new_pcm_sampling(pcm));
 	if (mask & BA_DBUS_PCM_UPDATE_CODEC)
 		g_variant_builder_add(&props, "{sv}", "Codec", ba_variant_new_pcm_codec(pcm));
+	if (mask & BA_DBUS_PCM_UPDATE_CODEC_CONFIG)
+		g_variant_builder_add(&props, "{sv}", "CodecConfiguration", ba_variant_new_pcm_codec_config(pcm));
 	if (mask & BA_DBUS_PCM_UPDATE_DELAY)
 		g_variant_builder_add(&props, "{sv}", "Delay", ba_variant_new_pcm_delay(pcm));
 	if (mask & BA_DBUS_PCM_UPDATE_SOFT_VOLUME)
@@ -973,10 +980,6 @@ void bluealsa_dbus_pcm_unregister(struct ba_transport_pcm *pcm) {
 	g_dbus_object_manager_server_unexport(bluealsa_dbus_manager, pcm->ba_dbus_path);
 	pcm->ba_dbus_exported = false;
 
-	g_dbus_connection_emit_signal(config.dbus, NULL,
-			bluealsa_dbus_manager_path, BLUEALSA_IFACE_MANAGER, "PCMRemoved",
-			g_variant_new("(o)", pcm->ba_dbus_path), NULL);
-
 }
 
 static GVariant *bluealsa_rfcomm_get_properties(void *userdata) {
diff --git a/src/bluealsa-dbus.h b/src/bluealsa-dbus.h
index 2547fed..568b3ca 100644
--- a/src/bluealsa-dbus.h
+++ b/src/bluealsa-dbus.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - bluealsa-dbus.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -22,13 +22,15 @@
 #include "ba-device.h"
 #include "ba-transport.h"
 
-#define BA_DBUS_PCM_UPDATE_FORMAT      (1 << 0)
-#define BA_DBUS_PCM_UPDATE_CHANNELS    (1 << 1)
-#define BA_DBUS_PCM_UPDATE_SAMPLING    (1 << 2)
-#define BA_DBUS_PCM_UPDATE_CODEC       (1 << 3)
-#define BA_DBUS_PCM_UPDATE_DELAY       (1 << 4)
-#define BA_DBUS_PCM_UPDATE_SOFT_VOLUME (1 << 5)
-#define BA_DBUS_PCM_UPDATE_VOLUME      (1 << 6)
+#define BA_DBUS_PCM_UPDATE_FORMAT       (1 << 0)
+#define BA_DBUS_PCM_UPDATE_CHANNELS     (1 << 1)
+#define BA_DBUS_PCM_UPDATE_SAMPLING     (1 << 2)
+#define BA_DBUS_PCM_UPDATE_CODEC        (1 << 3)
+#define BA_DBUS_PCM_UPDATE_CODEC_CONFIG (1 << 4)
+#define BA_DBUS_PCM_UPDATE_DELAY        (1 << 5)
+#define BA_DBUS_PCM_UPDATE_SOFT_VOLUME  (1 << 6)
+#define BA_DBUS_PCM_UPDATE_VOLUME       (1 << 7)
+#define BA_DBUS_PCM_UPDATE_RUNNING      (1 << 8)
 
 #define BA_DBUS_RFCOMM_UPDATE_FEATURES (1 << 0)
 #define BA_DBUS_RFCOMM_UPDATE_BATTERY  (1 << 1)
diff --git a/src/bluealsa-iface.c b/src/bluealsa-iface.c
index 7be3455..7106184 100644
--- a/src/bluealsa-iface.c
+++ b/src/bluealsa-iface.c
@@ -24,83 +24,10 @@ static const GDBusArgInfo arg_fd = {
 	-1, "fd", "h", NULL
 };
 
-static const GDBusArgInfo arg_path = {
-	-1, "path", "o", NULL
-};
-
-static const GDBusArgInfo arg_PCMs = {
-	-1, "PCMs", "a{oa{sv}}", NULL
-};
-
 static const GDBusArgInfo arg_props = {
 	-1, "props", "a{sv}", NULL
 };
 
-static const GDBusAnnotationInfo ann_deprecated = {
-	-1, "org.freedesktop.DBus.Deprecated", "true", NULL
-};
-
-static const GDBusArgInfo *GetPCMs_out[] = {
-	&arg_PCMs,
-	NULL,
-};
-
-static const GDBusAnnotationInfo *GetPCMs_annotations[] = {
-	&ann_deprecated,
-	NULL,
-};
-
-static const GDBusMethodInfo bluealsa_iface_manager_GetPCMs = {
-	-1, "GetPCMs",
-	NULL,
-	(GDBusArgInfo **)GetPCMs_out,
-	(GDBusAnnotationInfo **)GetPCMs_annotations,
-};
-
-static const GDBusMethodInfo *bluealsa_iface_manager_methods[] = {
-	&bluealsa_iface_manager_GetPCMs,
-	NULL,
-};
-
-static const GDBusArgInfo *PCMAdded_args[] = {
-	&arg_path,
-	&arg_props,
-	NULL,
-};
-
-static const GDBusArgInfo *PCMRemoved_args[] = {
-	&arg_path,
-	NULL,
-};
-
-static const GDBusAnnotationInfo *PCMAdded_annotations[] = {
-	&ann_deprecated,
-	NULL,
-};
-
-static const GDBusSignalInfo bluealsa_iface_manager_PCMAdded = {
-	-1, "PCMAdded",
-	(GDBusArgInfo **)PCMAdded_args,
-	(GDBusAnnotationInfo **)PCMAdded_annotations,
-};
-
-static const GDBusAnnotationInfo *PCMRemoved_annotations[] = {
-	&ann_deprecated,
-	NULL,
-};
-
-static const GDBusSignalInfo bluealsa_iface_manager_PCMRemoved = {
-	-1, "PCMRemoved",
-	(GDBusArgInfo **)PCMRemoved_args,
-	(GDBusAnnotationInfo **)PCMRemoved_annotations,
-};
-
-static const GDBusSignalInfo *bluealsa_iface_manager_signals[] = {
-	&bluealsa_iface_manager_PCMAdded,
-	&bluealsa_iface_manager_PCMRemoved,
-	NULL,
-};
-
 static const GDBusPropertyInfo bluealsa_iface_manager_Version = {
 	-1, "Version", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
@@ -186,6 +113,10 @@ static const GDBusPropertyInfo bluealsa_iface_pcm_Mode = {
 	-1, "Mode", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
 
+static const GDBusPropertyInfo bluealsa_iface_pcm_Running = {
+	-1, "Running", "b", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
 static const GDBusPropertyInfo bluealsa_iface_pcm_Format = {
 	-1, "Format", "q", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
@@ -202,6 +133,10 @@ static const GDBusPropertyInfo bluealsa_iface_pcm_Codec = {
 	-1, "Codec", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
 
+static const GDBusPropertyInfo bluealsa_iface_pcm_CodecConfiguration = {
+	-1, "CodecConfiguration", "ay", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
 static const GDBusPropertyInfo bluealsa_iface_pcm_Delay = {
 	-1, "Delay", "q", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
@@ -225,10 +160,12 @@ static const GDBusPropertyInfo *bluealsa_iface_pcm_properties[] = {
 	&bluealsa_iface_pcm_Sequence,
 	&bluealsa_iface_pcm_Transport,
 	&bluealsa_iface_pcm_Mode,
+	&bluealsa_iface_pcm_Running,
 	&bluealsa_iface_pcm_Format,
 	&bluealsa_iface_pcm_Channels,
 	&bluealsa_iface_pcm_Sampling,
 	&bluealsa_iface_pcm_Codec,
+	&bluealsa_iface_pcm_CodecConfiguration,
 	&bluealsa_iface_pcm_Delay,
 	&bluealsa_iface_pcm_SoftVolume,
 	&bluealsa_iface_pcm_Volume,
@@ -257,7 +194,7 @@ static const GDBusPropertyInfo bluealsa_iface_rfcomm_Transport = {
 };
 
 static const GDBusPropertyInfo bluealsa_iface_rfcomm_Features = {
-	-1, "Features", "u", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+	-1, "Features", "as", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
 };
 
 static const GDBusPropertyInfo bluealsa_iface_rfcomm_Battery = {
@@ -273,8 +210,8 @@ static const GDBusPropertyInfo *bluealsa_iface_rfcomm_properties[] = {
 
 const GDBusInterfaceInfo bluealsa_iface_manager = {
 	-1, BLUEALSA_IFACE_MANAGER,
-	(GDBusMethodInfo **)bluealsa_iface_manager_methods,
-	(GDBusSignalInfo **)bluealsa_iface_manager_signals,
+	NULL,
+	NULL,
 	(GDBusPropertyInfo **)bluealsa_iface_manager_properties,
 	NULL,
 };
diff --git a/src/bluealsa.conf b/src/bluealsa.conf.in
similarity index 94%
rename from src/bluealsa.conf
rename to src/bluealsa.conf.in
index e8a639d..ba425ce 100644
--- a/src/bluealsa.conf
+++ b/src/bluealsa.conf.in
@@ -7,7 +7,7 @@
 
   <!-- ../system.conf have denied everything, so we just punch some holes -->
 
-  <policy user="root">
+  <policy user="@bluealsauser@">
     <allow own_prefix="org.bluealsa"/>
     <allow send_destination="org.bluealsa"/>
   </policy>
diff --git a/src/bluez.c b/src/bluez.c
index 9149b31..d8e17ea 100644
--- a/src/bluez.c
+++ b/src/bluez.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - bluez.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "bluez.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <pthread.h>
@@ -59,8 +60,8 @@ struct bluez_dbus_object_data {
 	GDBusInterfaceSkeleton *ifs;
 	/* associated adapter */
 	int hci_dev_id;
-	/* the type of the transport */
-	struct ba_transport_type ttype;
+	/* registered profile */
+	enum ba_transport_profile profile;
 	/* media endpoint codec */
 	const struct a2dp_codec *codec;
 	/* determine whether object is registered in BlueZ */
@@ -146,6 +147,99 @@ static bool bluez_match_dbus_adapter(
 	return false;
 }
 
+/**
+ * Get BlueZ D-Bus object path for given transport profile. */
+static const char *bluez_transport_profile_to_bluez_object_path(
+		enum ba_transport_profile profile,
+		uint16_t codec_id) {
+	switch (profile) {
+	case BA_TRANSPORT_PROFILE_NONE:
+		return "/";
+	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
+		switch (codec_id) {
+		case A2DP_CODEC_SBC:
+			return "/A2DP/SBC/source";
+#if ENABLE_MPEG
+		case A2DP_CODEC_MPEG12:
+			return "/A2DP/MPEG/source";
+#endif
+#if ENABLE_AAC
+		case A2DP_CODEC_MPEG24:
+			return "/A2DP/AAC/source";
+#endif
+#if ENABLE_APTX
+		case A2DP_CODEC_VENDOR_APTX:
+			return "/A2DP/aptX/source";
+#endif
+#if ENABLE_APTX_HD
+		case A2DP_CODEC_VENDOR_APTX_HD:
+			return "/A2DP/aptXHD/source";
+#endif
+#if ENABLE_FASTSTREAM
+		case A2DP_CODEC_VENDOR_FASTSTREAM:
+			return "/A2DP/FastStream/source";
+#endif
+#if ENABLE_LC3PLUS
+		case A2DP_CODEC_VENDOR_LC3PLUS:
+			return "/A2DP/LC3plus/source";
+#endif
+#if ENABLE_LDAC
+		case A2DP_CODEC_VENDOR_LDAC:
+			return "/A2DP/LDAC/source";
+#endif
+		default:
+			error("Unsupported A2DP codec: %#x", codec_id);
+			g_assert_not_reached();
+		}
+	case BA_TRANSPORT_PROFILE_A2DP_SINK:
+		switch (codec_id) {
+		case A2DP_CODEC_SBC:
+			return "/A2DP/SBC/sink";
+#if ENABLE_MPEG
+		case A2DP_CODEC_MPEG12:
+			return "/A2DP/MPEG/sink";
+#endif
+#if ENABLE_AAC
+		case A2DP_CODEC_MPEG24:
+			return "/A2DP/AAC/sink";
+#endif
+#if ENABLE_APTX
+		case A2DP_CODEC_VENDOR_APTX:
+			return "/A2DP/aptX/sink";
+#endif
+#if ENABLE_APTX_HD
+		case A2DP_CODEC_VENDOR_APTX_HD:
+			return "/A2DP/aptXHD/sink";
+#endif
+#if ENABLE_FASTSTREAM
+		case A2DP_CODEC_VENDOR_FASTSTREAM:
+			return "/A2DP/FastStream/sink";
+#endif
+#if ENABLE_LC3PLUS
+		case A2DP_CODEC_VENDOR_LC3PLUS:
+			return "/A2DP/LC3plus/sink";
+#endif
+#if ENABLE_LDAC
+		case A2DP_CODEC_VENDOR_LDAC:
+			return "/A2DP/LDAC/sink";
+#endif
+		default:
+			error("Unsupported A2DP codec: %#x", codec_id);
+			g_assert_not_reached();
+		}
+	case BA_TRANSPORT_PROFILE_HFP_HF:
+		return "/HFP/HandsFree";
+	case BA_TRANSPORT_PROFILE_HFP_AG:
+		return "/HFP/AudioGateway";
+	case BA_TRANSPORT_PROFILE_HSP_HS:
+		return "/HSP/Headset";
+	case BA_TRANSPORT_PROFILE_HSP_AG:
+		return "/HSP/AudioGateway";
+	}
+	g_assert_not_reached();
+	return "/";
+}
+
 /**
  * Get transport state from BlueZ state string. */
 static enum bluez_a2dp_transport_state bluez_a2dp_transport_state_from_string(
@@ -291,7 +385,7 @@ static void bluez_endpoint_set_configuration(GDBusMethodInvocation *inv, void *u
 		goto fail;
 	}
 
-	if ((t = ba_transport_new_a2dp(d, dbus_obj->ttype,
+	if ((t = ba_transport_new_a2dp(d, dbus_obj->profile,
 					sender, transport_path, codec, &configuration)) == NULL) {
 		error("Couldn't create new transport: %s", strerror(errno));
 		goto fail;
@@ -299,11 +393,16 @@ static void bluez_endpoint_set_configuration(GDBusMethodInvocation *inv, void *u
 
 	/* Skip volume level initialization in case of A2DP Source
 	 * profile and software volume control. */
-	if (!(t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE &&
+	if (!(t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE &&
 				t->a2dp.pcm.soft_volume)) {
-		int level = ba_transport_pcm_volume_bt_to_level(&t->a2dp.pcm, volume);
+
+		int level = ba_transport_pcm_volume_range_to_level(volume, BLUEZ_A2DP_VOLUME_MAX);
+
+		pthread_mutex_lock(&t->a2dp.pcm.mutex);
 		ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[0], &level, NULL, NULL);
 		ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[1], &level, NULL, NULL);
+		pthread_mutex_unlock(&t->a2dp.pcm.mutex);
+
 	}
 
 	t->a2dp.bluez_dbus_sep_path = dbus_obj->path;
@@ -311,7 +410,7 @@ static void bluez_endpoint_set_configuration(GDBusMethodInvocation *inv, void *u
 	t->a2dp.volume = volume;
 
 	debug("%s configured for device %s",
-			ba_transport_type_to_string(t->type),
+			ba_transport_debug_name(t),
 			batostr_(&d->addr));
 	hexdump("A2DP selected configuration blob",
 			&configuration, codec->capabilities_size, true);
@@ -462,11 +561,8 @@ static void bluez_register_a2dp(
 		.dispatchers = dispatchers,
 	};
 
-	struct ba_transport_type ttype = {
-		.profile = codec->dir == A2DP_SOURCE ?
-			BA_TRANSPORT_PROFILE_A2DP_SOURCE : BA_TRANSPORT_PROFILE_A2DP_SINK,
-		.codec = codec->codec_id,
-	};
+	enum ba_transport_profile profile = codec->dir == A2DP_SOURCE ?
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE : BA_TRANSPORT_PROFILE_A2DP_SINK;
 
 	int registered = 0;
 	int connected = 0;
@@ -480,7 +576,8 @@ static void bluez_register_a2dp(
 
 		char path[sizeof(dbus_obj->path)];
 		snprintf(path, sizeof(path), "/org/bluez/%s%s/%d", adapter->hci.name,
-				g_dbus_transport_type_to_bluez_object_path(ttype), ++registered);
+				bluez_transport_profile_to_bluez_object_path(profile, codec->codec_id),
+				++registered);
 
 		if ((dbus_obj = g_hash_table_lookup(dbus_object_data_map, path)) == NULL) {
 
@@ -500,7 +597,7 @@ static void bluez_register_a2dp(
 			strncpy(dbus_obj->path, path, sizeof(dbus_obj->path));
 			dbus_obj->hci_dev_id = adapter->hci.dev_id;
 			dbus_obj->codec = codec;
-			dbus_obj->ttype = ttype;
+			dbus_obj->profile = profile;
 			dbus_obj->ref_count = 2;
 
 			bluez_MediaEndpointIfaceSkeleton *ifs_endpoint;
@@ -727,13 +824,12 @@ static void bluez_profile_new_connection(GDBusMethodInvocation *inv, void *userd
 
 	const char *device_path;
 	GVariantIter *properties;
-	GUnixFDList *fd_list;
 	GError *err = NULL;
 	int fd = -1;
 
-	g_variant_get(params, "(&oha{sv})", &device_path, &fd, &properties);
+	g_variant_get(params, "(&oha{sv})", &device_path, NULL, &properties);
 
-	fd_list = g_dbus_message_get_unix_fd_list(msg);
+	GUnixFDList *fd_list = g_dbus_message_get_unix_fd_list(msg);
 	if ((fd = g_unix_fd_list_get(fd_list, 0, &err)) == -1) {
 		error("Couldn't obtain RFCOMM socket: %s", err->message);
 		goto fail;
@@ -753,7 +849,7 @@ static void bluez_profile_new_connection(GDBusMethodInvocation *inv, void *userd
 		goto fail;
 	}
 
-	if ((t = ba_transport_new_sco(d, dbus_obj->ttype,
+	if ((t = ba_transport_new_sco(d, dbus_obj->profile,
 					sender, device_path, fd)) == NULL) {
 		error("Couldn't create new transport: %s", strerror(errno));
 		goto fail;
@@ -765,7 +861,7 @@ static void bluez_profile_new_connection(GDBusMethodInvocation *inv, void *userd
 	}
 
 	debug("%s configured for device %s",
-			ba_transport_type_to_string(t->type),
+			ba_transport_debug_name(t),
 			batostr_(&d->addr));
 
 	dbus_obj->connected = true;
@@ -892,7 +988,7 @@ final:
  * Register Bluetooth Hands-Free Audio Profile. */
 static void bluez_register_hfp(
 		const char *uuid,
-		uint32_t profile,
+		enum ba_transport_profile profile,
 		uint16_t version,
 		uint16_t features) {
 
@@ -910,10 +1006,6 @@ static void bluez_register_hfp(
 		.dispatchers = dispatchers,
 	};
 
-	struct ba_transport_type ttype = {
-		.profile = profile,
-	};
-
 	pthread_mutex_lock(&bluez_mutex);
 
 	struct bluez_dbus_object_data *dbus_obj;
@@ -921,7 +1013,7 @@ static void bluez_register_hfp(
 
 	char path[sizeof(dbus_obj->path)];
 	snprintf(path, sizeof(path), "/org/bluez%s",
-			g_dbus_transport_type_to_bluez_object_path(ttype));
+			bluez_transport_profile_to_bluez_object_path(profile, -1));
 
 	if ((dbus_obj = g_hash_table_lookup(dbus_object_data_map, path)) == NULL) {
 
@@ -934,7 +1026,7 @@ static void bluez_register_hfp(
 
 		strncpy(dbus_obj->path, path, sizeof(dbus_obj->path));
 		dbus_obj->hci_dev_id = -1;
-		dbus_obj->ttype = ttype;
+		dbus_obj->profile = profile;
 		dbus_obj->ref_count = 2;
 
 		bluez_ProfileIfaceSkeleton *ifs_profile;
@@ -997,6 +1089,26 @@ static void bluez_register_hfp_all(void) {
  * Register to the BlueZ service. */
 static void bluez_register(void) {
 
+	const struct {
+		const char *uuid;
+		enum ba_transport_profile profile;
+		bool enabled;
+		bool global;
+	} uuids[] = {
+		{ BLUETOOTH_UUID_A2DP_SOURCE, BA_TRANSPORT_PROFILE_A2DP_SOURCE,
+			config.profile.a2dp_source, false },
+		{ BLUETOOTH_UUID_A2DP_SINK, BA_TRANSPORT_PROFILE_A2DP_SINK,
+			config.profile.a2dp_sink, false },
+		{ BLUETOOTH_UUID_HSP_HS, BA_TRANSPORT_PROFILE_HSP_HS,
+			config.profile.hsp_hs, true },
+		{ BLUETOOTH_UUID_HSP_AG, BA_TRANSPORT_PROFILE_HSP_AG,
+			config.profile.hsp_ag, true },
+		{ BLUETOOTH_UUID_HFP_HF, BA_TRANSPORT_PROFILE_HFP_HF,
+			config.profile.hfp_hf, true },
+		{ BLUETOOTH_UUID_HFP_AG, BA_TRANSPORT_PROFILE_HFP_AG,
+			config.profile.hfp_ag, true },
+	};
+
 	GError *err = NULL;
 	GVariantIter *objects = NULL;
 	if ((objects = g_dbus_get_managed_objects(config.dbus, BLUEZ_SERVICE, "/", &err)) == NULL) {
@@ -1006,6 +1118,8 @@ static void bluez_register(void) {
 	}
 
 	bool adapters[HCI_MAX_DEV] = { 0 };
+	unsigned int adapters_profiles[HCI_MAX_DEV] = { 0 };
+	unsigned int profiles = 0;
 
 	GVariantIter *interfaces;
 	GVariantIter *properties;
@@ -1016,14 +1130,33 @@ static void bluez_register(void) {
 
 	while (g_variant_iter_next(objects, "{&oa{sa{sv}}}", &object_path, &interfaces)) {
 		while (g_variant_iter_next(interfaces, "{&sa{sv}}", &interface, &properties)) {
-			if (strcmp(interface, BLUEZ_IFACE_ADAPTER) == 0)
+			if (strcmp(interface, BLUEZ_IFACE_ADAPTER) == 0) {
+
+				int hci_dev_id = g_dbus_bluez_object_path_to_hci_dev_id(object_path);
+				unsigned int adapter_profiles = 0;
+				bool valid = false;
+
 				while (g_variant_iter_next(properties, "{&sv}", &property, &value)) {
-					if (strcmp(property, "Address") == 0 &&
-							bluez_match_dbus_adapter(object_path, g_variant_get_string(value, NULL)))
-						/* mark adapter as valid for registration */
-						adapters[g_dbus_bluez_object_path_to_hci_dev_id(object_path)] = true;
+					if (strcmp(property, "Address") == 0)
+						/* check if adapter as valid for registration */
+						valid = bluez_match_dbus_adapter(object_path, g_variant_get_string(value, NULL));
+					else if (strcmp(property, "UUIDs") == 0) {
+						const char **value_uuids = g_variant_get_strv(value, NULL);
+						/* map UUIDs to BlueALSA transport profile mask */
+						for (size_t i = 0; value_uuids[i] != NULL; i++)
+							for (size_t ii = 0; ii < ARRAYSIZE(uuids); ii++)
+								if (strcasecmp(value_uuids[i], uuids[ii].uuid) == 0)
+									adapter_profiles |= uuids[ii].profile;
+						g_free(value_uuids);
+					}
 					g_variant_unref(value);
 				}
+
+				adapters[hci_dev_id] = valid;
+				adapters_profiles[hci_dev_id] = adapter_profiles;
+				profiles |= adapter_profiles;
+
+			}
 			g_variant_iter_free(properties);
 		}
 		g_variant_iter_free(interfaces);
@@ -1035,9 +1168,20 @@ static void bluez_register(void) {
 	for (i = 0; i < ARRAYSIZE(adapters); i++)
 		if (adapters[i] &&
 				(a = ba_adapter_new(i)) != NULL) {
+
+			for (size_t ii = 0; ii < ARRAYSIZE(uuids); ii++)
+				if (uuids[ii].enabled && !uuids[ii].global && adapters_profiles[i] & uuids[ii].profile)
+					warn("UUID already registered in BlueZ [%s]: %s", a->hci.name, uuids[ii].uuid);
+
+			/* register media endpoints */
 			bluez_adapter_new(a);
+
 		}
 
+	for (size_t ii = 0; ii < ARRAYSIZE(uuids); ii++)
+		if (uuids[ii].enabled && uuids[ii].global && profiles & uuids[ii].profile)
+			warn("UUID already registered in BlueZ: %s", uuids[ii].uuid);
+
 	/* HFP has to be registered globally */
 	bluez_register_hfp_all();
 
@@ -1146,6 +1290,10 @@ static void bluez_signal_interfaces_added(GDBusConnection *conn, const char *sen
 				a2dp_codecs_codec_id_to_string(sep.codec_id));
 		g_array_append_val(seps, sep);
 
+		/* Collected SEPs are exposed via BlueALSA D-Bus API. We will sort them
+		 * here, so the D-Bus API will return codecs in the defined order. */
+		g_array_sort(seps, (GCompareFunc)a2dp_sep_cmp);
+
 	}
 
 }
@@ -1171,6 +1319,11 @@ static void bluez_signal_interfaces_removed(GDBusConnection *conn, const char *s
 
 	pthread_mutex_lock(&bluez_mutex);
 
+	/* Check whether this interface belongs to a HCI which exists in our
+	 * local BlueZ adapter cache - HCI that matches our HCI filter. */
+	if (hci_dev_id == -1 || bluez_adapters[hci_dev_id].adapter == NULL)
+		goto final;
+
 	while (g_variant_iter_next(interfaces, "&s", &interface))
 		if (strcmp(interface, BLUEZ_IFACE_ADAPTER) == 0) {
 
@@ -1201,9 +1354,9 @@ static void bluez_signal_interfaces_removed(GDBusConnection *conn, const char *s
 
 		}
 
+final:
 	pthread_mutex_unlock(&bluez_mutex);
 	g_variant_iter_free(interfaces);
-
 }
 
 static void bluez_signal_transport_changed(GDBusConnection *conn, const char *sender,
@@ -1222,26 +1375,26 @@ static void bluez_signal_transport_changed(GDBusConnection *conn, const char *se
 	int hci_dev_id = g_dbus_bluez_object_path_to_hci_dev_id(transport_path);
 	if ((a = ba_adapter_lookup(hci_dev_id)) == NULL) {
 		error("Adapter not available: %s", transport_path);
-		return;
+		goto fail;
 	}
 
-	GVariantIter *properties = NULL;
-	const char *interface;
-	const char *property;
-	GVariant *value;
-
 	bdaddr_t addr;
 	g_dbus_bluez_object_path_to_bdaddr(transport_path, &addr);
 	if ((d = ba_device_lookup(a, &addr)) == NULL) {
 		error("Device not available: %s", transport_path);
-		goto final;
+		goto fail;
 	}
 
 	if ((t = ba_transport_lookup(d, transport_path)) == NULL) {
 		error("Transport not available: %s", transport_path);
-		goto final;
+		goto fail;
 	}
 
+	GVariantIter *properties;
+	const char *interface;
+	const char *property;
+	GVariant *value;
+
 	g_variant_get(params, "(&sa{sv}as)", &interface, &properties, NULL);
 	while (g_variant_iter_next(properties, "{&sv}", &property, &value)) {
 		debug("Signal: %s.%s(): %s: %s", interface_, signal, interface, property);
@@ -1260,15 +1413,21 @@ static void bluez_signal_transport_changed(GDBusConnection *conn, const char *se
 				g_variant_validate_value(value, G_VARIANT_TYPE_UINT16, property)) {
 			/* received volume is in range [0, 127] */
 			uint16_t volume = t->a2dp.volume = g_variant_get_uint16(value);
-			if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE &&
+			if (t->profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE &&
 					t->a2dp.pcm.soft_volume)
 				debug("Skipping A2DP volume update: %u", volume);
 			else {
-				int level = ba_transport_pcm_volume_bt_to_level(&t->a2dp.pcm, volume);
+
+				int level = ba_transport_pcm_volume_range_to_level(volume, BLUEZ_A2DP_VOLUME_MAX);
 				debug("Updating A2DP volume: %u [%.2f dB]", volume, 0.01 * level);
+
+				pthread_mutex_lock(&t->a2dp.pcm.mutex);
 				ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[0], &level, NULL, NULL);
 				ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[1], &level, NULL, NULL);
+				pthread_mutex_unlock(&t->a2dp.pcm.mutex);
+
 				bluealsa_dbus_pcm_update(&t->a2dp.pcm, BA_DBUS_PCM_UPDATE_VOLUME);
+
 			}
 		}
 
@@ -1276,7 +1435,7 @@ static void bluez_signal_transport_changed(GDBusConnection *conn, const char *se
 	}
 	g_variant_iter_free(properties);
 
-final:
+fail:
 	if (a != NULL)
 		ba_adapter_unref(a);
 	if (d != NULL)
diff --git a/src/bluez.h b/src/bluez.h
index 71125bd..f860655 100644
--- a/src/bluez.h
+++ b/src/bluez.h
@@ -27,6 +27,9 @@
 #define BLUETOOTH_UUID_HFP_HF      "0000111E-0000-1000-8000-00805F9B34FB"
 #define BLUETOOTH_UUID_HFP_AG      "0000111F-0000-1000-8000-00805F9B34FB"
 
+#define BLUEZ_A2DP_VOLUME_MIN 0
+#define BLUEZ_A2DP_VOLUME_MAX 127
+
 enum bluez_a2dp_transport_state {
 	BLUEZ_A2DP_TRANSPORT_STATE_IDLE,
 	BLUEZ_A2DP_TRANSPORT_STATE_PENDING,
diff --git a/src/codec-aptx.c b/src/codec-aptx.c
index 2e256e4..bb991b8 100644
--- a/src/codec-aptx.c
+++ b/src/codec-aptx.c
@@ -9,10 +9,12 @@
  */
 
 #include "codec-aptx.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <endian.h>
 #include <errno.h>
 #include <stdbool.h>
+#include <stddef.h>
 #include <stdint.h>
 #include <stdlib.h>
 
diff --git a/src/codec-aptx.h b/src/codec-aptx.h
index b88e2ac..3b6077f 100644
--- a/src/codec-aptx.h
+++ b/src/codec-aptx.h
@@ -15,7 +15,6 @@
 # include <config.h>
 #endif
 
-#include <stddef.h>
 #include <stdint.h>
 #include <sys/types.h>
 
diff --git a/src/codec-msbc.c b/src/codec-msbc.c
index 7895454..ec13ef4 100644
--- a/src/codec-msbc.c
+++ b/src/codec-msbc.c
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - codec-msbc.c
  * Copyright (c) 2016-2022 Arkadiusz Bokowy
- *               2017 Juha Kuikka
+ * Copyright (c) 2017 Juha Kuikka
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,6 +10,7 @@
  */
 
 #include "codec-msbc.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <endian.h>
 #include <errno.h>
@@ -114,10 +115,10 @@ int msbc_init(struct esco_msbc *msbc) {
 	msbc->seq_number = 0;
 	msbc->frames = 0;
 
-	/* Initialize PLC context. When calling with non-NULL parameter,
-	 * this function does not allocate anything - there is no need
-	 * to call plc_free(). */
-	plc_init(&msbc->plc);
+	if (msbc->plc != NULL)
+		plc_init(msbc->plc);
+	else if (!(msbc->plc = plc_init(NULL)))
+		goto fail;
 
 	msbc->initialized = true;
 	return 0;
@@ -139,6 +140,9 @@ void msbc_finish(struct esco_msbc *msbc) {
 	ffb_free(&msbc->data);
 	ffb_free(&msbc->pcm);
 
+	plc_free(msbc->plc);
+	msbc->plc = NULL;
+
 }
 
 /**
@@ -183,7 +187,7 @@ ssize_t msbc_decode(struct esco_msbc *msbc) {
 
 		msbc->seq_number = _seq;
 
-		plc_fillin(&msbc->plc, msbc->pcm.tail, missing * MSBC_CODESAMPLES);
+		plc_fillin(msbc->plc, msbc->pcm.tail, missing * MSBC_CODESAMPLES);
 		ffb_seek(&msbc->pcm, missing * MSBC_CODESAMPLES);
 		rv += missing * MSBC_CODESAMPLES;
 
@@ -200,7 +204,7 @@ ssize_t msbc_decode(struct esco_msbc *msbc) {
 #if MSBC_DECODE_ERROR_PLC
 
 		warn("Couldn't decode mSBC frame: %s", sbc_strerror(len));
-		plc_fillin(&msbc->plc, msbc->pcm.tail, MSBC_CODESAMPLES);
+		plc_fillin(msbc->plc, msbc->pcm.tail, MSBC_CODESAMPLES);
 		ffb_seek(&msbc->pcm, MSBC_CODESAMPLES);
 		rv += MSBC_CODESAMPLES;
 
@@ -212,7 +216,7 @@ ssize_t msbc_decode(struct esco_msbc *msbc) {
 	}
 
 	/* record PCM history and blend new data after PLC */
-	plc_rx(&msbc->plc, msbc->pcm.tail, MSBC_CODESAMPLES);
+	plc_rx(msbc->plc, msbc->pcm.tail, MSBC_CODESAMPLES);
 
 	ffb_seek(&msbc->pcm, MSBC_CODESAMPLES);
 	input += sizeof(*frame);
diff --git a/src/codec-msbc.h b/src/codec-msbc.h
index b793f6a..22fcdc2 100644
--- a/src/codec-msbc.h
+++ b/src/codec-msbc.h
@@ -65,7 +65,7 @@ struct esco_msbc {
 	size_t frames;
 
 	/* packet loss concealment */
-	plc_state_t plc;
+	plc_state_t *plc;
 
 	/* Determine whether structure has been initialized. This field is
 	 * used for reinitialization - it makes msbc_init() idempotent. */
diff --git a/src/codec-sbc.c b/src/codec-sbc.c
index c168222..a6ea352 100644
--- a/src/codec-sbc.c
+++ b/src/codec-sbc.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - codec-sbc.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "codec-sbc.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <stdbool.h>
@@ -109,27 +110,14 @@ uint8_t sbc_a2dp_get_bitpool(const a2dp_sbc_t *conf, unsigned int quality) {
 }
 
 #if ENABLE_FASTSTREAM
-/**
- * Initialize SBC audio codec for A2DP FastStream connection.
- *
- * @param sbc SBC structure which shall be initialized.
- * @param flags SBC initialization flags.
- * @param conf A2DP FastStream configuration blob.
- * @param size Size of the configuration blob.
- * @param voice It true, SBC will be initialized for voice direction.
- * @return This function returns 0 on success or a negative error value
- *   in case of SBC audio codec initialization failure. */
-int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
+
+static int sbc_set_a2dp_faststream(sbc_t *sbc,
 		const void *conf, size_t size, bool voice) {
 
 	const a2dp_faststream_t *a2dp = conf;
 	if (size != sizeof(*a2dp))
 		return -EINVAL;
 
-	int rv;
-	if ((rv = sbc_init(sbc, flags)) != 0)
-		return rv;
-
 	sbc->blocks = SBC_BLK_16;
 	sbc->subbands = SBC_SB_8;
 	sbc->allocation = SBC_AM_LOUDNESS;
@@ -137,14 +125,14 @@ int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
 	if (voice) {
 
 		if (!(a2dp->direction & FASTSTREAM_DIRECTION_VOICE))
-			goto fail;
+			return -EINVAL;
 
 		switch (a2dp->frequency_voice) {
 		case FASTSTREAM_SAMPLING_FREQ_VOICE_16000:
 			sbc->frequency = SBC_FREQ_16000;
 			break;
 		default:
-			goto fail;
+			return -EINVAL;
 		}
 
 		sbc->mode = SBC_MODE_MONO;
@@ -154,7 +142,7 @@ int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
 	else {
 
 		if (!(a2dp->direction & FASTSTREAM_DIRECTION_MUSIC))
-			goto fail;
+			return -EINVAL;
 
 		switch (a2dp->frequency_music) {
 		case FASTSTREAM_SAMPLING_FREQ_MUSIC_44100:
@@ -164,7 +152,7 @@ int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
 			sbc->frequency = SBC_FREQ_48000;
 			break;
 		default:
-			goto fail;
+			return -EINVAL;
 		}
 
 		sbc->mode = SBC_MODE_JOINT_STEREO;
@@ -173,11 +161,51 @@ int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
 	}
 
 	return 0;
+}
 
-fail:
-	sbc_finish(sbc);
-	return -EINVAL;
+/**
+ * Initialize SBC audio codec for A2DP FastStream connection.
+ *
+ * @param sbc SBC structure which shall be initialized.
+ * @param flags SBC initialization flags.
+ * @param conf A2DP FastStream configuration blob.
+ * @param size Size of the configuration blob.
+ * @param voice It true, SBC will be initialized for voice direction.
+ * @return This function returns 0 on success or a negative error value
+ *   in case of SBC audio codec initialization failure. */
+int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
+		const void *conf, size_t size, bool voice) {
+
+	int rv;
+	if ((rv = sbc_init(sbc, flags)) != 0)
+		return rv;
+
+	if ((rv = sbc_set_a2dp_faststream(sbc, conf, size, voice)) != 0) {
+		sbc_finish(sbc);
+		return rv;
+	}
+
+	return 0;
 }
+
+/**
+ * Reinitialize SBC audio codec for A2DP FastStream connection.
+ *
+ * @param sbc SBC structure which shall be reinitialized.
+ * @param flags SBC initialization flags.
+ * @param conf A2DP FastStream configuration blob.
+ * @param size Size of the configuration blob.
+ * @param voice It true, SBC will be initialized for voice direction.
+ * @return This function returns 0 on success or a negative error value
+ *   in case of SBC audio codec initialization failure. */
+int sbc_reinit_a2dp_faststream(sbc_t *sbc, unsigned long flags,
+		const void *conf, size_t size, bool voice) {
+	int rv;
+	if ((rv = sbc_reinit(sbc, flags)) != 0)
+		return rv;
+	return sbc_set_a2dp_faststream(sbc, conf, size, voice);
+}
+
 #endif
 
 #if ENABLE_MSBC
diff --git a/src/codec-sbc.h b/src/codec-sbc.h
index 3a25970..2e226bf 100644
--- a/src/codec-sbc.h
+++ b/src/codec-sbc.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - codec-sbc.h
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -35,6 +35,8 @@ uint8_t sbc_a2dp_get_bitpool(const a2dp_sbc_t *conf, unsigned int quality);
 #if ENABLE_FASTSTREAM
 int sbc_init_a2dp_faststream(sbc_t *sbc, unsigned long flags,
 		const void *conf, size_t size, bool voice);
+int sbc_reinit_a2dp_faststream(sbc_t *sbc, unsigned long flags,
+		const void *conf, size_t size, bool voice);
 #endif
 
 #if ENABLE_MSBC
diff --git a/src/dbus.c b/src/dbus.c
index 2dae627..7cad2bd 100644
--- a/src/dbus.c
+++ b/src/dbus.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - dbus.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,32 +10,13 @@
 
 #include "dbus.h"
 
-#include <pthread.h>
 #include <stdbool.h>
 #include <string.h>
 
 #include <glib-object.h>
 
-#include "shared/defs.h"
 #include "shared/log.h"
 
-/* Compatibility patch for glib < 2.68. */
-#if !GLIB_CHECK_VERSION(2, 68, 0)
-# define g_memdup2 g_memdup
-#endif
-
-struct dispatch_method_caller_data {
-	void (*handler)(GDBusMethodInvocation *, void *);
-	GDBusMethodInvocation *invocation;
-	void *userdata;
-};
-
-static void *dispatch_method_caller(struct dispatch_method_caller_data *data) {
-	data->handler(data->invocation, data->userdata);
-	g_free(data);
-	return NULL;
-}
-
 /**
  * Dispatch incoming D-Bus method call.
  *
@@ -64,33 +45,7 @@ bool g_dbus_dispatch_method_call(const GDBusMethodCallDispatcher *dispatchers,
 			continue;
 
 		debug("Called: %s.%s() on %s", interface, method, path);
-
-		if (!dispatcher->asynchronous_call)
-			dispatcher->handler(invocation, userdata);
-		else {
-
-			struct dispatch_method_caller_data data = {
-				.handler = dispatcher->handler,
-				.invocation = invocation,
-				.userdata = userdata,
-			};
-
-			pthread_t thread;
-			int ret;
-
-			void *ptr = g_memdup2(&data, sizeof(data));
-			if ((ret = pthread_create(&thread, NULL,
-							PTHREAD_ROUTINE(dispatch_method_caller), ptr)) != 0) {
-				error("Couldn't create D-Bus call dispatcher: %s", strerror(ret));
-				return false;
-			}
-
-			if ((ret = pthread_detach(thread)) != 0) {
-				error("Couldn't detach D-Bus call dispatcher: %s", strerror(ret));
-				return false;
-			}
-
-		}
+		dispatcher->handler(invocation, userdata);
 
 		return true;
 	}
diff --git a/src/dbus.h b/src/dbus.h
index ed4a082..2e09385 100644
--- a/src/dbus.h
+++ b/src/dbus.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - dbus.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -25,7 +25,7 @@
 #define DBUS_IFACE_PROPERTIES       DBUS_SERVICE ".Properties"
 
 /* Compatibility patch for glib < 2.42. */
-#ifndef G_DBUS_ERROR_UNKNOWN_OBJECT
+#if !GLIB_CHECK_VERSION(2, 42, 0)
 # define G_DBUS_ERROR_UNKNOWN_OBJECT G_DBUS_ERROR_FAILED
 #endif
 
@@ -37,8 +37,6 @@ typedef struct _GDBusMethodCallDispatcher {
 	const char *interface;
 	const char *method;
 	void (*handler)(GDBusMethodInvocation *, void *);
-	/* if true, handler will be called in a separate thread */
-	bool asynchronous_call;
 } GDBusMethodCallDispatcher;
 
 typedef struct _GDBusInterfaceSkeletonVTable {
diff --git a/src/hci.c b/src/hci.c
index 5029270..d30c50f 100644
--- a/src/hci.c
+++ b/src/hci.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - hci.c
- * Copyright (c) 2016-2019 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "hci.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <poll.h>
@@ -221,6 +222,7 @@ int hci_bcm_write_sco_pcm_params(int dd, uint8_t routing, uint8_t clock,
 	return 0;
 }
 
+#if DEBUG
 /**
  * Convert Bluetooth address into a human-readable string.
  *
@@ -240,3 +242,4 @@ const char *batostr_(const bdaddr_t *ba) {
 		return addr;
 	return NULL;
 }
+#endif
diff --git a/src/hci.h b/src/hci.h
index f53fe01..739bb1d 100644
--- a/src/hci.h
+++ b/src/hci.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - hci.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -12,11 +12,14 @@
 #ifndef BLUEALSA_HCI_H_
 #define BLUEALSA_HCI_H_
 
-#include <stdbool.h>
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
 #include <stdint.h>
 
 #include <bluetooth/bluetooth.h>
-#include <bluetooth/hci.h>
+#include <bluetooth/hci.h> /* IWYU pragma: keep */
 #include <bluetooth/hci_lib.h>
 
 /**
@@ -72,6 +75,8 @@ int hci_bcm_read_sco_pcm_params(int dd, uint8_t *routing, uint8_t *clock,
 int hci_bcm_write_sco_pcm_params(int dd, uint8_t routing, uint8_t clock,
 		uint8_t frame, uint8_t sync, uint8_t clk, int to);
 
+#if DEBUG
 const char *batostr_(const bdaddr_t *ba);
+#endif
 
 #endif
diff --git a/src/hfp.c b/src/hfp.c
index 7c6a1de..577ef5b 100644
--- a/src/hfp.c
+++ b/src/hfp.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - hfp.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,6 +10,7 @@
 
 #include "hfp.h"
 
+#include <errno.h>
 #include <stddef.h>
 #include <stdint.h>
 #include <strings.h>
@@ -24,18 +25,101 @@ static const struct {
 	{ HFP_CODEC_MSBC, { "mSBC" } },
 };
 
+/**
+ * Convert HFP AG features into human-readable strings.
+ *
+ * @param features HFP AG feature mask.
+ * @param out Array of strings to be filled with feature names.
+ * @param size Size of the output array.
+ * @return On success this function returns number of features. Otherwise, -1
+ *   is returned and errno is set to indicate the error. */
+ssize_t hfp_ag_features_to_strings(uint32_t features, const char **out, size_t size) {
+
+	if (size < 12)
+		return errno = ENOMEM, -1;
+
+	size_t i = 0;
+
+	if (features & HFP_AG_FEAT_3WC)
+		out[i++] = "three-way-calling";
+	if (features & HFP_AG_FEAT_ECNR)
+		out[i++] = "echo-canceling-and-noise-reduction";
+	if (features & HFP_AG_FEAT_VOICE)
+		out[i++] = "voice-recognition";
+	if (features & HFP_AG_FEAT_RING)
+		out[i++] = "in-band-ring-tone";
+	if (features & HFP_AG_FEAT_VTAG)
+		out[i++] = "attach-voice-tag";
+	if (features & HFP_AG_FEAT_REJECT)
+		out[i++] = "reject-call";
+	if (features & HFP_AG_FEAT_ECS)
+		out[i++] = "enhanced-call-status";
+	if (features & HFP_AG_FEAT_ECC)
+		out[i++] = "enhanced-call-control";
+	if (features & HFP_AG_FEAT_EERC)
+		out[i++] = "extended-error-codecs";
+	if (features & HFP_AG_FEAT_CODEC)
+		out[i++] = "codec-negotiation";
+	if (features & HFP_AG_FEAT_HF_IND)
+		out[i++] = "hf-indicators";
+	if (features & HFP_AG_FEAT_ESCO)
+		out[i++] = "esco-s4-settings";
+
+	return i;
+}
+
+/**
+ * Convert HFP HF features into human-readable strings.
+ *
+ * @param features HFP HF feature mask.
+ * @param out Array of strings to be filled with feature names.
+ * @param size Size of the output array.
+ * @return On success this function returns number of features. Otherwise, -1
+ *   is returned and errno is set to indicate the error. */
+ssize_t hfp_hf_features_to_strings(uint32_t features, const char **out, size_t size) {
+
+	if (size < 10)
+		return errno = ENOMEM, -1;
+
+	size_t i = 0;
+
+	if (features & HFP_HF_FEAT_ECNR)
+		out[i++] = "echo-canceling-and-noise-reduction";
+	if (features & HFP_HF_FEAT_3WC)
+		out[i++] = "three-way-calling";
+	if (features & HFP_HF_FEAT_CLI)
+		out[i++] = "cli-presentation";
+	if (features & HFP_HF_FEAT_VOICE)
+		out[i++] = "voice-recognition";
+	if (features & HFP_HF_FEAT_VOLUME)
+		out[i++] = "volume-control";
+	if (features & HFP_HF_FEAT_ECS)
+		out[i++] = "enhanced-call-status";
+	if (features & HFP_HF_FEAT_ECC)
+		out[i++] = "enhanced-call-control";
+	if (features & HFP_HF_FEAT_CODEC)
+		out[i++] = "codec-negotiation";
+	if (features & HFP_HF_FEAT_HF_IND)
+		out[i++] = "hf-indicators";
+	if (features & HFP_HF_FEAT_ESCO)
+		out[i++] = "esco-s4-settings";
+
+	return i;
+}
+
 /**
  * Get BlueALSA HFP codec ID from string representation.
  *
  * @param alias Alias of HFP audio codec name.
- * @return BlueALSA audio codec ID or 0xFFFF if there was no match. */
+ * @return BlueALSA HFP audio codec ID or HFP_CODEC_UNDEFINED if there was no
+ *   match. */
 uint16_t hfp_codec_id_from_string(const char *alias) {
 	for (size_t i = 0; i < ARRAYSIZE(codecs); i++)
 		for (size_t n = 0; n < ARRAYSIZE(codecs[i].aliases); n++)
 			if (codecs[i].aliases[n] != NULL &&
 					strcasecmp(codecs[i].aliases[n], alias) == 0)
 				return codecs[i].codec_id;
-	return 0xFFFF;
+	return HFP_CODEC_UNDEFINED;
 }
 
 /**
diff --git a/src/hfp.h b/src/hfp.h
index c171c20..9a58217 100644
--- a/src/hfp.h
+++ b/src/hfp.h
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - hfp.h
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
- *               2017 Juha Kuikka
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ * Copyright (c) 2017 Juha Kuikka
  *
  * This file is a part of bluez-alsa.
  *
@@ -14,55 +14,70 @@
 #define BLUEALSA_HFP_H_
 
 #include <stdint.h>
+#include <sys/types.h>
 
 /* HFP codec IDs */
 #define HFP_CODEC_UNDEFINED 0x00
 #define HFP_CODEC_CVSD      0x01
 #define HFP_CODEC_MSBC      0x02
 
-/* SDP AG feature flags */
-#define SDP_HFP_AG_FEAT_TWC    (1 << 0)
-#define SDP_HFP_AG_FEAT_ECNR   (1 << 1)
-#define SDP_HFP_AG_FEAT_VREC   (1 << 2)
-#define SDP_HFP_AG_FEAT_RING   (1 << 3)
-#define SDP_HFP_AG_FEAT_VTAG   (1 << 4)
-#define SDP_HFP_AG_FEAT_WBAND  (1 << 5)
-
-/* SDP HF feature flags */
-#define SDP_HFP_HF_FEAT_ECNR   (1 << 0)
-#define SDP_HFP_HF_FEAT_TWC    (1 << 1)
-#define SDP_HFP_HF_FEAT_CLI    (1 << 2)
-#define SDP_HFP_HF_FEAT_VREC   (1 << 3)
-#define SDP_HFP_HF_FEAT_VOLUME (1 << 4)
-#define SDP_HFP_HF_FEAT_WBAND  (1 << 5)
-
-/* AG feature flags */
-#define HFP_AG_FEAT_3WC    (1 << 0)
-#define HFP_AG_FEAT_ECNR   (1 << 1)
-#define HFP_AG_FEAT_VOICE  (1 << 2)
-#define HFP_AG_FEAT_RING   (1 << 3)
-#define HFP_AG_FEAT_VTAG   (1 << 4)
-#define HFP_AG_FEAT_REJECT (1 << 5)
-#define HFP_AG_FEAT_ECS    (1 << 6)
-#define HFP_AG_FEAT_ECC    (1 << 7)
-#define HFP_AG_FEAT_EERC   (1 << 8)
-#define HFP_AG_FEAT_CODEC  (1 << 9)
-#define HFP_AG_FEAT_HFIND  (1 << 10)
-#define HFP_AG_FEAT_ESCO   (1 << 11)
-
-/* HF feature flags */
-#define HFP_HF_FEAT_ECNR   (1 << 0)
-#define HFP_HF_FEAT_3WC    (1 << 1)
-#define HFP_HF_FEAT_CLI    (1 << 2)
-#define HFP_HF_FEAT_VOICE  (1 << 3)
-#define HFP_HF_FEAT_VOLUME (1 << 4)
-#define HFP_HF_FEAT_ECS    (1 << 5)
-#define HFP_HF_FEAT_ECC    (1 << 6)
-#define HFP_HF_FEAT_CODEC  (1 << 7)
-#define HFP_HF_FEAT_HFIND  (1 << 8)
-#define HFP_HF_FEAT_ESCO   (1 << 9)
-
-/* Apple's extension feature flags */
+/**
+ * HSP/HFP volume gain range */
+#define HFP_VOLUME_GAIN_MIN 0
+#define HFP_VOLUME_GAIN_MAX 15
+
+/**
+ * SDP AG feature flags */
+#define SDP_HFP_AG_FEAT_TWC    (1 << 0) /* three-way calling */
+#define SDP_HFP_AG_FEAT_ECNR   (1 << 1) /* EC and/or NR function */
+#define SDP_HFP_AG_FEAT_VREC   (1 << 2) /* voice recognition function */
+#define SDP_HFP_AG_FEAT_RING   (1 << 3) /* in-band ring tone capability */
+#define SDP_HFP_AG_FEAT_VTAG   (1 << 4) /* attach a number to a voice tag */
+#define SDP_HFP_AG_FEAT_WBAND  (1 << 5) /* wide band speech support */
+
+/**
+ * SDP HF feature flags */
+#define SDP_HFP_HF_FEAT_ECNR   (1 << 0) /* EC and/or NR function */
+#define SDP_HFP_HF_FEAT_TWC    (1 << 1) /* three-way calling */
+#define SDP_HFP_HF_FEAT_CLI    (1 << 2) /* CLI presentation capability */
+#define SDP_HFP_HF_FEAT_VREC   (1 << 3) /* voice recognition activation */
+#define SDP_HFP_HF_FEAT_VOLUME (1 << 4) /* remote volume control */
+#define SDP_HFP_HF_FEAT_WBAND  (1 << 5) /* wide band speech support */
+
+/**
+ * AG feature flags */
+#define HFP_AG_FEAT_3WC    (1 << 0)  /* three-way calling */
+#define HFP_AG_FEAT_ECNR   (1 << 1)  /* EC and/or NR function */
+#define HFP_AG_FEAT_VOICE  (1 << 2)  /* voice recognition function */
+#define HFP_AG_FEAT_RING   (1 << 3)  /* in-band ring tone capability */
+#define HFP_AG_FEAT_VTAG   (1 << 4)  /* attach a number to a voice tag */
+#define HFP_AG_FEAT_REJECT (1 << 5)  /* ability to reject a call */
+#define HFP_AG_FEAT_ECS    (1 << 6)  /* enhanced call status */
+#define HFP_AG_FEAT_ECC    (1 << 7)  /* enhanced call control */
+#define HFP_AG_FEAT_EERC   (1 << 8)  /* extended error result codes */
+#define HFP_AG_FEAT_CODEC  (1 << 9)  /* codec negotiation */
+#define HFP_AG_FEAT_HF_IND (1 << 10) /* HF indicators */
+#define HFP_AG_FEAT_ESCO   (1 << 11) /* enhanced SCO S4 settings supported */
+#define HFP_AG_FEAT_EVRS   (1 << 12) /* enhanced voice recognition status */
+#define HFP_AG_FEAT_VR_TXT (1 << 13) /* voice recognition text */
+
+/**
+ * HF feature flags */
+#define HFP_HF_FEAT_ECNR   (1 << 0)  /* EC and/or NR function */
+#define HFP_HF_FEAT_3WC    (1 << 1)  /* three-way calling */
+#define HFP_HF_FEAT_CLI    (1 << 2)  /* CLI presentation capability */
+#define HFP_HF_FEAT_VOICE  (1 << 3)  /* voice recognition activation */
+#define HFP_HF_FEAT_VOLUME (1 << 4)  /* remote volume control */
+#define HFP_HF_FEAT_ECS    (1 << 5)  /* enhanced call status */
+#define HFP_HF_FEAT_ECC    (1 << 6)  /* enhanced call control */
+#define HFP_HF_FEAT_CODEC  (1 << 7)  /* codec negotiation */
+#define HFP_HF_FEAT_HF_IND (1 << 8)  /* HF indicators */
+#define HFP_HF_FEAT_ESCO   (1 << 9)  /* enhanced SCO S4 settings supported */
+#define HFP_HF_FEAT_EVRS   (1 << 10) /* enhanced voice recognition status */
+#define HFP_HF_FEAT_VR_TXT (1 << 11) /* voice recognition text */
+
+/**
+ * Apple's extension feature flags */
 #define XAPL_FEATURE_BATTERY (1 << 1)
 #define XAPL_FEATURE_DOCKING (1 << 2)
 #define XAPL_FEATURE_SIRI    (1 << 3)
@@ -90,6 +105,7 @@ enum __attribute__ ((packed)) hfp_setup {
 	HFP_SETUP_GAIN_SPK,
 	HFP_SETUP_ACCESSORY_XAPL,
 	HFP_SETUP_ACCESSORY_BATT,
+	HFP_SETUP_SELECT_CODEC,
 	HFP_SETUP_COMPLETE,
 };
 
@@ -134,6 +150,9 @@ enum __attribute__ ((packed)) hfp_ind {
 /* a roaming is active */
 #define HFP_IND_ROAM_ACTIVE         1
 
+ssize_t hfp_ag_features_to_strings(uint32_t features, const char **out, size_t size);
+ssize_t hfp_hf_features_to_strings(uint32_t features, const char **out, size_t size);
+
 uint16_t hfp_codec_id_from_string(const char *alias);
 const char *hfp_codec_id_to_string(uint16_t codec_id);
 
diff --git a/src/io.c b/src/io.c
index c57ec6a..6e5c3a6 100644
--- a/src/io.c
+++ b/src/io.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - io.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -14,6 +14,8 @@
 #include <fcntl.h>
 #include <poll.h>
 #include <pthread.h>
+#include <stdbool.h>
+#include <stdint.h>
 #include <string.h>
 #include <unistd.h>
 
@@ -34,18 +36,20 @@ ssize_t io_bt_read(
 	const int fd = th->bt_fd;
 	ssize_t ret;
 
-	if (fd == -1)
-		return errno = EBADFD, -1;
-
-	while ((ret = read(fd, buffer, count)) == -1 &&
-			errno == EINTR)
-		continue;
-	if (ret == -1 && (
-				errno == ECONNABORTED ||
-				errno == ECONNRESET ||
-				errno == ETIMEDOUT)) {
-		error("BT socket disconnected: %s", strerror(errno));
-		ret = 0;
+retry:
+	if ((ret = read(fd, buffer, count)) == -1)
+		switch (errno) {
+		case EINTR:
+			goto retry;
+		case ECONNRESET:
+		case ENOTCONN:
+			debug("BT socket disconnected: %s", strerror(errno));
+			ret = 0;
+			break;
+		case ECONNABORTED:
+		case ETIMEDOUT:
+			error("BT read error: %s", strerror(errno));
+			ret = 0;
 	}
 
 	if (ret == 0)
@@ -64,14 +68,10 @@ ssize_t io_bt_write(
 		const void *buffer,
 		size_t count) {
 
+	const int fd = th->bt_fd;
 	ssize_t ret;
-	int fd;
 
 retry:
-
-	if ((fd = th->bt_fd) == -1)
-		return errno = EBADFD, -1;
-
 	if ((ret = write(fd, buffer, count)) == -1)
 		switch (errno) {
 		case EINTR:
@@ -84,11 +84,14 @@ retry:
 			poll(&pfd, 1, -1);
 			pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 			goto retry;
-		case ECONNABORTED:
 		case ECONNRESET:
 		case ENOTCONN:
+			debug("BT socket disconnected: %s", strerror(errno));
+			ret = 0;
+			break;
+		case ECONNABORTED:
 		case ETIMEDOUT:
-			error("BT socket disconnected: %s", strerror(errno));
+			error("BT write error: %s", strerror(errno));
 			ret = 0;
 		}
 
@@ -101,26 +104,34 @@ retry:
 /**
  * Scale PCM signal according to the volume configuration. */
 void io_pcm_scale(
-		const struct ba_transport_pcm *pcm,
+		struct ba_transport_pcm *pcm,
 		void *buffer,
 		size_t samples) {
 
+	pthread_mutex_lock(&pcm->mutex);
+
 	const unsigned int channels = pcm->channels;
-	size_t frames = samples / channels;
+	const bool pcm_soft_volume = pcm->soft_volume;
+	const double pcm_volume_ch_scales[2] = {
+		pcm->volume[0].scale,
+		pcm->volume[1].scale,
+	};
+
+	pthread_mutex_unlock(&pcm->mutex);
 
-	if (!pcm->soft_volume) {
+	if (!pcm_soft_volume) {
 		/* In case of hardware volume control we will perform mute operation,
 		 * because hardware muting is an equivalent of gain=0 which with some
 		 * headsets does not entirely silence audio. */
 		switch (pcm->format) {
 		case BA_TRANSPORT_PCM_FORMAT_S16_2LE:
-			audio_silence_s16_2le(buffer, frames, channels,
-					pcm->volume[0].scale == 0, pcm->volume[1].scale == 0);
+			audio_silence_s16_2le(buffer, samples / channels, channels,
+					pcm_volume_ch_scales[0] == 0, pcm_volume_ch_scales[1] == 0);
 			break;
 		case BA_TRANSPORT_PCM_FORMAT_S24_4LE:
 		case BA_TRANSPORT_PCM_FORMAT_S32_4LE:
-			audio_silence_s32_4le(buffer, frames, channels,
-					pcm->volume[0].scale == 0, pcm->volume[1].scale == 0);
+			audio_silence_s32_4le(buffer, samples / channels, channels,
+					pcm_volume_ch_scales[0] == 0, pcm_volume_ch_scales[1] == 0);
 			break;
 		default:
 			g_assert_not_reached();
@@ -130,13 +141,13 @@ void io_pcm_scale(
 
 	switch (pcm->format) {
 	case BA_TRANSPORT_PCM_FORMAT_S16_2LE:
-		audio_scale_s16_2le(buffer, frames, channels,
-				pcm->volume[0].scale, pcm->volume[1].scale);
+		audio_scale_s16_2le(buffer, samples / channels, channels,
+				pcm_volume_ch_scales[0], pcm_volume_ch_scales[1]);
 		break;
 	case BA_TRANSPORT_PCM_FORMAT_S24_4LE:
 	case BA_TRANSPORT_PCM_FORMAT_S32_4LE:
-		audio_scale_s32_4le(buffer, frames, channels,
-				pcm->volume[0].scale, pcm->volume[1].scale);
+		audio_scale_s32_4le(buffer, samples / channels, channels,
+				pcm_volume_ch_scales[0], pcm_volume_ch_scales[1]);
 		break;
 	default:
 		g_assert_not_reached();
@@ -147,12 +158,26 @@ void io_pcm_scale(
 /**
  * Flush read buffer of the transport PCM FIFO. */
 ssize_t io_pcm_flush(struct ba_transport_pcm *pcm) {
-	ssize_t rv = splice(pcm->fd, NULL, config.null_fd, NULL, 1024 * 32, SPLICE_F_NONBLOCK);
-	if (rv > 0)
-		rv /= BA_TRANSPORT_PCM_FORMAT_BYTES(pcm->format);
-	else if (rv == -1 && errno == EAGAIN)
-		rv = 0;
-	return rv;
+
+	ssize_t samples = 0;
+	ssize_t rv;
+
+	pthread_mutex_lock(&pcm->mutex);
+
+	const int fd = pcm->fd;
+	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(pcm->format);
+
+	while ((rv = splice(fd, NULL, config.null_fd, NULL, 32 * 1024, SPLICE_F_NONBLOCK)) > 0) {
+		debug("Flushed PCM samples [%d]: %zd", fd, rv / sample_size);
+		samples += rv / sample_size;
+	}
+
+	pthread_mutex_unlock(&pcm->mutex);
+
+	if (rv == -1 && errno != EAGAIN)
+		return rv;
+
+	return samples;
 }
 
 /**
@@ -164,20 +189,17 @@ ssize_t io_pcm_read(
 
 	pthread_mutex_lock(&pcm->mutex);
 
-	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(pcm->format);
 	const int fd = pcm->fd;
-	ssize_t ret = -1;
-
-	if (fd == -1)
-		errno = EBADFD;
-	else {
-		while ((ret = read(fd, buffer, samples * sample_size)) == -1 &&
-				errno == EINTR)
-			continue;
-		if (ret == 0) {
-			debug("PCM has been closed: %d", fd);
-			ba_transport_pcm_release(pcm);
-		}
+	const size_t sample_size = BA_TRANSPORT_PCM_FORMAT_BYTES(pcm->format);
+	ssize_t ret;
+
+	while ((ret = read(fd, buffer, samples * sample_size)) == -1 &&
+			errno == EINTR)
+		continue;
+
+	if (ret == 0) {
+		debug("PCM client closed connection: %d", fd);
+		ba_transport_pcm_release(pcm);
 	}
 
 	pthread_mutex_unlock(&pcm->mutex);
@@ -191,10 +213,7 @@ ssize_t io_pcm_read(
 }
 
 /**
- * Write PCM signal to the transport PCM FIFO.
- *
- * Note:
- * This function may temporally re-enable thread cancellation! */
+ * Write PCM signal to the transport PCM FIFO. */
 ssize_t io_pcm_write(
 		struct ba_transport_pcm *pcm,
 		const void *buffer,
@@ -202,36 +221,29 @@ ssize_t io_pcm_write(
 
 	pthread_mutex_lock(&pcm->mutex);
 
+	const int fd = pcm->fd;
+	const uint8_t *buffer_ = buffer;
 	size_t len = samples * BA_TRANSPORT_PCM_FORMAT_BYTES(pcm->format);
 	ssize_t ret;
 
 	do {
 
-		const int fd = pcm->fd;
-		if (fd == -1) {
-			errno = EBADFD;
-			ret = -1;
-			goto final;
-		}
-
-		if ((ret = write(fd, buffer, len)) == -1)
+		if ((ret = write(fd, buffer_, len)) == -1)
 			switch (errno) {
 			case EINTR:
 				continue;
 			case EAGAIN:
-				/* In order to provide a way of escaping from the infinite poll()
-				 * we have to temporally re-enable thread cancellation. */
-				pthread_cleanup_push(PTHREAD_CLEANUP(pthread_mutex_unlock), &pcm->mutex);
-				pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
-				struct pollfd pfd = { fd, POLLOUT, 0 };
-				poll(&pfd, 1, -1);
-				pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-				pthread_cleanup_pop(0);
-				continue;
+				/* If the client is so slow that the FIFO fills up, then it
+				 * is inevitable that audio frames will be eventually be
+				 * dropped in the bluetooth controller if we block here.
+				 * It is better that we discard frames here so that the
+				 * decoder is not interrupted. */
+				ret = len;
+				break;
 			case EPIPE:
 				/* This errno value will be received only, when the SIGPIPE
 				 * signal is caught, blocked or ignored. */
-				debug("PCM has been closed: %d", fd);
+				debug("PCM client closed connection: %d", fd);
 				ba_transport_pcm_release(pcm);
 				ret = 0;
 				/* fall-through */
@@ -239,7 +251,7 @@ ssize_t io_pcm_write(
 				goto final;
 			}
 
-		buffer += ret;
+		buffer_ += ret;
 		len -= ret;
 
 	} while (len != 0);
@@ -274,15 +286,15 @@ ssize_t io_poll_and_read_bt(
 		{ th->pipe[0], POLLIN, 0 },
 		{ th->bt_fd, POLLIN, 0 }};
 
-	/* Allow escaping from the poll() by thread cancellation. */
-	pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
-
 repoll:
 
-	if (poll(fds, ARRAYSIZE(fds), io->timeout) == -1) {
+	pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+	int poll_rv = poll(fds, ARRAYSIZE(fds), io->timeout);
+	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+
+	if (poll_rv == -1) {
 		if (errno == EINTR)
 			goto repoll;
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 		return -1;
 	}
 
@@ -298,8 +310,6 @@ repoll:
 		}
 	}
 
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-
 	return io_bt_read(th, buffer, count);
 }
 
@@ -321,23 +331,27 @@ ssize_t io_poll_and_read_pcm(
 
 repoll:
 
-	/* Allow escaping from the poll() by thread cancellation. */
-	pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
-
+	pthread_mutex_lock(&pcm->mutex);
 	/* Add PCM socket to the poll if it is active. */
-	fds[1].fd = ba_transport_pcm_is_active(pcm) ? pcm->fd : -1;
+	fds[1].fd = pcm->active ? pcm->fd : -1;
+	pthread_mutex_unlock(&pcm->mutex);
+
+	pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+	int poll_rv = poll(fds, ARRAYSIZE(fds), io->timeout);
+	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 
 	/* Poll for reading with optional sync timeout. */
-	switch (poll(fds, ARRAYSIZE(fds), io->timeout)) {
+	switch (poll_rv) {
 	case 0:
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-		pthread_cond_signal(&pcm->synced);
+		pthread_mutex_lock(&pcm->mutex);
+		pcm->synced = true;
+		pthread_mutex_unlock(&pcm->mutex);
+		pthread_cond_signal(&pcm->cond);
 		io->timeout = -1;
 		return 0;
 	case -1:
 		if (errno == EINTR)
 			goto repoll;
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 		return -1;
 	}
 
@@ -360,20 +374,23 @@ repoll:
 			io->timeout = 100;
 			goto repoll;
 		case BA_TRANSPORT_THREAD_SIGNAL_PCM_DROP:
-			io_pcm_flush(pcm);
-			goto repoll;
+			/* Notify caller that the PCM FIFO has been dropped. This will give
+			 * the caller a chance to reinitialize its internal state. */
+			errno = ESTALE;
+			return -1;
 		default:
 			goto repoll;
 		}
 	}
 
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+	if (fds[1].revents == 0)
+		return 0;
 
 	ssize_t samples_read;
 	if ((samples_read = io_pcm_read(pcm, buffer, samples)) == -1) {
 		if (errno == EAGAIN)
 			goto repoll;
-		if (errno != EBADFD)
+		if (errno != EBADF)
 			return -1;
 		samples_read = 0;
 	}
diff --git a/src/io.h b/src/io.h
index c85f8a2..59878f6 100644
--- a/src/io.h
+++ b/src/io.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - io.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -16,7 +16,7 @@
 # include <config.h>
 #endif
 
-#include <stddef.h>
+#include <sys/types.h>
 
 #include "ba-transport.h"
 #include "shared/rt.h"
@@ -55,7 +55,7 @@ ssize_t io_bt_write(
 		size_t count);
 
 void io_pcm_scale(
-		const struct ba_transport_pcm *pcm,
+		struct ba_transport_pcm *pcm,
 		void *buffer,
 		size_t samples);
 
diff --git a/src/main.c b/src/main.c
index 8202ab2..f0b276a 100644
--- a/src/main.c
+++ b/src/main.c
@@ -43,6 +43,7 @@
 #if ENABLE_OFONO
 # include "ofono.h"
 #endif
+#include "storage.h"
 #if ENABLE_UPOWER
 # include "upower.h"
 #endif
@@ -78,7 +79,8 @@ static char *get_a2dp_codecs(enum a2dp_dir dir) {
 	}
 
 	/* Sort A2DP codecs before displaying them. */
-	a2dp_codecs_qsort(a2dp_codecs_tmp, n);
+	qsort(a2dp_codecs_tmp, n, sizeof(*a2dp_codecs_tmp),
+			QSORT_COMPAR(a2dp_codec_ptr_cmp));
 
 	for (size_t i = 0; i < n; i++)
 		strv[i] = a2dp_codecs_codec_id_to_string(a2dp_codecs_tmp[i]->codec_id);
@@ -143,12 +145,12 @@ static void g_bus_name_lost(GDBusConnection *conn, const char *name, void *userd
 int main(int argc, char **argv) {
 
 	int opt;
-	const char *opts = "hVB:Si:p:c:";
+	const char *opts = "hVSB:i:p:c:";
 	const struct option longopts[] = {
 		{ "help", no_argument, NULL, 'h' },
 		{ "version", no_argument, NULL, 'V' },
-		{ "dbus", required_argument, NULL, 'B' },
 		{ "syslog", no_argument, NULL, 'S' },
+		{ "dbus", required_argument, NULL, 'B' },
 		{ "device", required_argument, NULL, 'i' },
 		{ "profile", required_argument, NULL, 'p' },
 		{ "codec", required_argument, NULL, 'c' },
@@ -189,31 +191,14 @@ int main(int argc, char **argv) {
 	opterr = 0;
 	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
 		switch (opt) {
-		case 'S' /* --syslog */ :
-			syslog = true;
-			break;
-		}
-
-	log_open(argv[0], syslog, BLUEALSA_LOGTIME);
-
-	if (bluealsa_config_init() != 0) {
-		error("Couldn't initialize bluealsa config");
-		return EXIT_FAILURE;
-	}
-
-	/* parse options */
-	optind = 0; opterr = 1;
-	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
-		switch (opt) {
-
 		case 'h' /* --help */ :
 			printf("Usage:\n"
 					"  %s -p PROFILE [OPTION]...\n"
 					"\nOptions:\n"
 					"  -h, --help\t\t\tprint this help and exit\n"
 					"  -V, --version\t\t\tprint version and exit\n"
-					"  -B, --dbus=NAME\t\tD-Bus service name suffix\n"
 					"  -S, --syslog\t\t\tsend output to syslog\n"
+					"  -B, --dbus=NAME\t\tD-Bus service name suffix\n"
 					"  -i, --device=hciX\t\tHCI device(s) to use\n"
 					"  -p, --profile=NAME\t\tset enabled BT profiles\n"
 					"  -c, --codec=NAME\t\tset enabled BT audio codecs\n"
@@ -271,6 +256,27 @@ int main(int argc, char **argv) {
 			printf("%s\n", PACKAGE_VERSION);
 			return EXIT_SUCCESS;
 
+		case 'S' /* --syslog */ :
+			syslog = true;
+			break;
+		}
+
+	log_open(basename(argv[0]), syslog);
+
+	if (bluealsa_config_init() != 0) {
+		error("Couldn't initialize bluealsa config");
+		return EXIT_FAILURE;
+	}
+
+	/* parse options */
+	optind = 0; opterr = 1;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+		case 'V' /* --version */ :
+		case 'S' /* --syslog */ :
+			break;
+
 		case 'B' /* --dbus=NAME */ :
 			snprintf(dbus_service, sizeof(dbus_service), BLUEALSA_SERVICE ".%s", optarg);
 			if (!g_dbus_is_name(dbus_service)) {
@@ -279,9 +285,6 @@ int main(int argc, char **argv) {
 			}
 			break;
 
-		case 'S' /* --syslog */ :
-			break;
-
 		case 'i' /* --device=HCI */ :
 			g_array_append_val(config.hci_filter, optarg);
 			break;
@@ -415,13 +418,15 @@ int main(int argc, char **argv) {
 		case 5 /* --aac-bitrate=BPS */ :
 			config.aac_bitrate = atoi(optarg);
 			break;
-		case 15 /* --aac-latm-version=NUM */ :
-			config.aac_latm_version = atoi(optarg);
-			if (config.aac_latm_version > 2) {
-				error("Invalid LATM version [0, 2]: %s", optarg);
+		case 15 /* --aac-latm-version=NUM */ : {
+			char *tmp;
+			config.aac_latm_version = strtoul(optarg, &tmp, 10);
+			if (config.aac_latm_version > 2 || optarg == tmp || *tmp != '\0') {
+				error("Invalid LATM version {0, 1, 2}: %s", optarg);
 				return EXIT_FAILURE;
 			}
 			break;
+		}
 		case 18 /* --aac-true-bps */ :
 			config.aac_true_bps = true;
 			break;
@@ -566,6 +571,14 @@ int main(int argc, char **argv) {
 
 	a2dp_codecs_init();
 
+	const char *storage_base_dir = BLUEALSA_STORAGE_DIR;
+#if ENABLE_SYSTEMD
+	const char *systemd_state_dir;
+	if ((systemd_state_dir = getenv("STATE_DIRECTORY")) != NULL)
+		storage_base_dir = systemd_state_dir;
+#endif
+	storage_init(storage_base_dir);
+
 	/* In order to receive EPIPE while writing to the pipe whose reading end
 	 * is closed, the SIGPIPE signal has to be handled. For more information
 	 * see the io_thread_write_pcm() function. */
@@ -589,5 +602,10 @@ int main(int argc, char **argv) {
 	for (size_t i = 0; i < ARRAYSIZE(config.adapters); i++)
 		ba_adapter_destroy(config.adapters[i]);
 
+	storage_destroy();
+	g_dbus_connection_close_sync(config.dbus, NULL, NULL);
+	g_main_loop_unref(loop);
+	g_free(address);
+
 	return retval;
 }
diff --git a/src/ofono-iface.h b/src/ofono-iface.h
index d5b9065..9be6811 100644
--- a/src/ofono-iface.h
+++ b/src/ofono-iface.h
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - ofono-iface.h
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
- *               2018 Thierry Bultel
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ * Copyright (c) 2018 Thierry Bultel
  *
  * This file is a part of bluez-alsa.
  *
@@ -20,6 +20,7 @@
 #define OFONO_IFACE_HF_AUDIO_AGENT   OFONO_SERVICE ".HandsfreeAudioAgent"
 #define OFONO_IFACE_HF_AUDIO_CARD    OFONO_SERVICE ".HandsfreeAudioCard"
 #define OFONO_IFACE_HF_AUDIO_MANAGER OFONO_SERVICE ".HandsfreeAudioManager"
+#define OFONO_IFACE_CALL_VOLUME      OFONO_SERVICE ".CallVolume"
 
 #define OFONO_AUDIO_CARD_TYPE_AG "gateway"
 #define OFONO_AUDIO_CARD_TYPE_HF "handsfree"
@@ -27,6 +28,10 @@
 #define OFONO_AUDIO_CODEC_CVSD 0x01
 #define OFONO_AUDIO_CODEC_MSBC 0x02
 
+#define OFONO_MODEM_TYPE_HARDWARE "hardware"
+#define OFONO_MODEM_TYPE_HFP      "hfp"
+#define OFONO_MODEM_TYPE_SAP      "sap"
+
 extern const GDBusInterfaceInfo ofono_iface_hf_audio_agent;
 
 #endif
diff --git a/src/ofono.c b/src/ofono.c
index 5571f3b..4612469 100644
--- a/src/ofono.c
+++ b/src/ofono.c
@@ -1,14 +1,14 @@
 /*
  * BlueALSA - ofono.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
- *               2018 Thierry Bultel
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ * Copyright (c) 2018 Thierry Bultel
  *
  * This file is a part of bluez-alsa.
  *
  * This project is licensed under the terms of the MIT license.
  *
  * When oFono is running on a system, it registers itself to BlueZ as an HFP
- * profile, which conflicts with our internal "--hfp-ag" and "--hpf-hf" ones.
+ * profile, which conflicts with our internal "--hfp-ag" and "--hfp-hf" ones.
  * This file is an implementation of the oFono back-end for bluez-alsa.
  *
  * For more details, see:
@@ -17,9 +17,9 @@
  */
 
 #include "ofono.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
-#include <poll.h>
 #include <pthread.h>
 #include <stdbool.h>
 #include <stdint.h>
@@ -27,6 +27,8 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sys/socket.h>
+#include <sys/time.h>
+#include <time.h>
 #include <unistd.h>
 
 #include <bluetooth/bluetooth.h>
@@ -42,19 +44,27 @@
 #include "ba-device.h"
 #include "ba-transport.h"
 #include "bluealsa-config.h"
+#include "bluealsa-dbus.h"
 #include "dbus.h"
 #include "hci.h"
 #include "hfp.h"
 #include "ofono-iface.h"
 #include "ofono-skeleton.h"
+#include "utils.h"
+#include "shared/defs.h"
 #include "shared/log.h"
+#include "shared/rt.h"
 
 /**
  * Lookup data associated with oFono card. */
 struct ofono_card_data {
+	char card[64];
 	int hci_dev_id;
 	bdaddr_t bt_addr;
-	char transport_path[32];
+	/* if true, card is an HFP AG */
+	bool is_gateway;
+	/* object path of a modem associated with this card */
+	char modem_path[64];
 };
 
 static GHashTable *ofono_card_data_map = NULL;
@@ -62,40 +72,30 @@ static const char *dbus_agent_object_path = "/org/bluez/HFP/oFono";
 static ofono_HFAudioAgentIfaceSkeleton *dbus_hf_agent = NULL;
 
 /**
- * Authorize oFono SCO connection.
- *
- * @param fd SCO socket file descriptor.
- * @return On success this function returns 0. Otherwise, -1 is returned and
- *	 errno is set to indicate the error. */
-static int ofono_sco_socket_authorize(int fd) {
-
-	struct pollfd pfd = { fd, POLLOUT, 0 };
-	char c;
-
-	if (poll(&pfd, 1, 0) == -1)
-		return -1;
-
-	/* If socket is not writable, it means that it is in the defer setup
-	 * state, so it needs to be read to authorize the connection. */
-	if (!(pfd.revents & POLLOUT) && read(fd, &c, 1) == -1)
-		return -1;
-
-	return 0;
-}
-
-/**
- * Ask oFono to connect to a card (in return it will call NewConnection). */
+ * Ask oFono to connect to a card. */
 static int ofono_acquire_bt_sco(struct ba_transport *t) {
 
 	GDBusMessage *msg = NULL, *rep = NULL;
 	GError *err = NULL;
+	uint8_t codec;
+	int fd = -1;
 	int ret = 0;
 
-	debug("Requesting new oFono SCO connection: %s", t->bluez_dbus_path);
+	debug("Requesting new oFono SCO link: %s", t->sco.ofono_dbus_path_card);
+	msg = g_dbus_message_new_method_call(t->bluez_dbus_owner,
+			t->sco.ofono_dbus_path_card, OFONO_IFACE_HF_AUDIO_CARD, "Acquire");
 
-	const char *ofono_dbus_path = &t->bluez_dbus_path[6];
-	msg = g_dbus_message_new_method_call(t->bluez_dbus_owner, ofono_dbus_path,
-			OFONO_IFACE_HF_AUDIO_CARD, "Connect");
+	struct timespec now;
+	struct timespec delay = {
+		.tv_nsec = HCI_SCO_CLOSE_CONNECT_QUIRK_DELAY * 1000000 };
+
+	gettimestamp(&now);
+	timespecadd(&t->sco.closed_at, &delay, &delay);
+	if (difftimespec(&now, &delay, &delay) > 0) {
+		info("SCO link close-connect quirk delay: %d ms",
+				(int)(delay.tv_nsec / 1000000));
+		nanosleep(&delay, NULL);
+	}
 
 	if ((rep = g_dbus_connection_send_message_with_reply_sync(config.dbus, msg,
 					G_DBUS_SEND_MESSAGE_FLAGS_NONE, -1, NULL, NULL, &err)) == NULL)
@@ -106,6 +106,34 @@ static int ofono_acquire_bt_sco(struct ba_transport *t) {
 		goto fail;
 	}
 
+	GVariant *body = g_dbus_message_get_body(rep);
+	g_variant_get(body, "(hy)", NULL, &codec);
+
+	GUnixFDList *fd_list = g_dbus_message_get_unix_fd_list(rep);
+	if ((fd = g_unix_fd_list_get(fd_list, 0, &err)) == -1)
+		goto fail;
+
+#if ENABLE_MSBC
+	if (codec != ba_transport_get_codec(t)) {
+		/* Although this connection has succeeded, it is not the codec expected
+		 * by the client. So we have to return an error ... */
+		error("Rejecting oFono SCO link: %s", "Codec mismatch");
+		/* ... but still update the codec ready for next client request. */
+		ba_transport_set_codec(t, codec);
+		if (fd != -1) {
+			shutdown(fd, SHUT_RDWR);
+			close(fd);
+		}
+		goto fail;
+	}
+#endif
+
+	t->bt_fd = fd;
+	t->mtu_read = t->mtu_write = hci_sco_get_mtu(fd, t->d->a->hci.type);
+	ba_transport_set_codec(t, codec);
+
+	debug("New oFono SCO link (codec: %#x): %d", codec, fd);
+
 	goto final;
 
 fail:
@@ -117,7 +145,7 @@ final:
 	if (rep != NULL)
 		g_object_unref(rep);
 	if (err != NULL) {
-		warn("Couldn't connect to card: %s", err->message);
+		error("Couldn't establish oFono SCO link: %s", err->message);
 		g_error_free(err);
 	}
 
@@ -139,6 +167,10 @@ static int ofono_release_bt_sco(struct ba_transport *t) {
 	close(t->bt_fd);
 	t->bt_fd = -1;
 
+	/* Keep the time-stamp when the SCO link has been closed. It will be used
+	 * for calculating close-connect quirk delay in the acquire function. */
+	gettimestamp(&t->sco.closed_at);
+
 	return 0;
 }
 
@@ -153,45 +185,47 @@ static int ofono_release_bt_sco(struct ba_transport *t) {
  *   set to indicated the cause of the error. */
 static struct ba_transport *ofono_transport_new(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_owner,
-		const char *dbus_path) {
+		const char *dbus_path_card,
+		const char *dbus_path_modem) {
 
 	struct ba_transport *t;
+	int err;
 
-	if ((t = ba_transport_new_sco(device, type, dbus_owner, dbus_path, -1)) == NULL)
+	if ((t = ba_transport_new_sco(device, profile, dbus_owner, dbus_path_card, -1)) == NULL)
 		return NULL;
 
+	if ((t->sco.ofono_dbus_path_card = strdup(dbus_path_card)) == NULL)
+		goto fail;
+	if ((t->sco.ofono_dbus_path_modem = strdup(dbus_path_modem)) == NULL)
+		goto fail;
+
 	t->acquire = ofono_acquire_bt_sco;
 	t->release = ofono_release_bt_sco;
 
 	return t;
+
+fail:
+	err = errno;
+	ba_transport_unref(t);
+	errno = err;
+	return NULL;
 }
 
 /**
- * Lookup a transport associated with oFono card.
- *
- * @param card A path associated with oFono card.
- * @return On success this function returns a transport associated with
- *   a given oFono card path. Otherwise, NULL is returned. */
-static struct ba_transport *ofono_transport_lookup(const char *card) {
+ * Lookup a transport associated with oFono card data. */
+static struct ba_transport *ofono_transport_lookup(struct ofono_card_data *ocd) {
 
 	struct ba_adapter *a = NULL;
 	struct ba_device *d = NULL;
 	struct ba_transport *t = NULL;
 
-	struct ofono_card_data *ocd;
-	if ((ocd = g_hash_table_lookup(ofono_card_data_map, card)) == NULL) {
-		error("Couldn't lookup oFono card data: %s", card);
-		goto fail;
-	}
-
 	if ((a = ba_adapter_lookup(ocd->hci_dev_id)) == NULL)
 		goto fail;
 	if ((d = ba_device_lookup(a, &ocd->bt_addr)) == NULL)
 		goto fail;
-	if ((t = ba_transport_lookup(d, ocd->transport_path)) == NULL)
-		goto fail;
+	t = ba_transport_lookup(d, ocd->card);
 
 fail:
 	if (a != NULL)
@@ -201,6 +235,350 @@ fail:
 	return t;
 }
 
+/**
+ * Lookup a transport associated with oFono card.
+ *
+ * @param card A path associated with oFono card.
+ * @return On success this function returns a transport associated with
+ *   a given oFono card path. Otherwise, NULL is returned. */
+static struct ba_transport *ofono_transport_lookup_card(const char *card) {
+
+	struct ofono_card_data *ocd;
+	if ((ocd = g_hash_table_lookup(ofono_card_data_map, card)) != NULL)
+		return ofono_transport_lookup(ocd);
+
+	error("Couldn't lookup oFono card data: %s", card);
+	return NULL;
+}
+
+/**
+ * Lookup a transport associated with oFono modem.
+ *
+ * @param modem A path associated with oFono modem.
+ * @return On success this function returns a transport associated with
+ *   a given oFono modem path. Otherwise, NULL is returned. */
+static struct ba_transport *ofono_transport_lookup_modem(const char *modem) {
+
+	GHashTableIter iter;
+	struct ofono_card_data *ocd;
+
+	g_hash_table_iter_init(&iter, ofono_card_data_map);
+	while (g_hash_table_iter_next(&iter, NULL, (void *)&ocd)) {
+		if (strcmp(ocd->modem_path, modem) == 0)
+			return ofono_transport_lookup(ocd);
+	}
+
+	return NULL;
+}
+
+#if ENABLE_MSBC
+static void ofono_new_connection_finish(GObject *source, GAsyncResult *result,
+		void *userdata) {
+	(void)userdata;
+
+	GError *err = NULL;
+	GDBusMessage *rep = g_dbus_connection_send_message_with_reply_finish(
+			G_DBUS_CONNECTION(source), result, &err);
+	if (rep != NULL &&
+			g_dbus_message_get_message_type(rep) == G_DBUS_MESSAGE_TYPE_ERROR)
+		g_dbus_message_to_gerror(rep, &err);
+
+	if (rep != NULL)
+		g_object_unref(rep);
+	if (err != NULL) {
+		error("Couldn't establish oFono SCO link: %s", err->message);
+		g_error_free(err);
+	}
+
+}
+
+/**
+ * Ask oFono to create an HFP codec connection.
+ *
+ * Codec selection can take a long time with oFono (up to 20 seconds with
+ * some devices) so we make the request asynchronously. oFono will invoke
+ * the HandsFreeAudioAgent NewConnection method when the codec selection is
+ * complete. */
+static void ofono_new_connection_request(struct ba_transport *t) {
+
+	GDBusMessage *msg;
+
+	debug("Requesting new oFono SCO link: %s", t->sco.ofono_dbus_path_card);
+	msg = g_dbus_message_new_method_call(t->bluez_dbus_owner,
+			t->sco.ofono_dbus_path_card, OFONO_IFACE_HF_AUDIO_CARD, "Connect");
+
+	g_dbus_connection_send_message_with_reply(config.dbus, msg,
+			G_DBUS_SEND_MESSAGE_FLAGS_NONE, -1, NULL, NULL,
+			ofono_new_connection_finish, NULL);
+
+	g_object_unref(msg);
+
+}
+#endif
+
+/**
+ * Link oFono card with a modem. */
+static int ofono_card_link_modem(struct ofono_card_data *ocd) {
+
+	GDBusMessage *msg = NULL, *rep = NULL;
+	GError *err = NULL;
+	int ret = -1;
+
+	msg = g_dbus_message_new_method_call(OFONO_SERVICE, "/",
+			OFONO_IFACE_MANAGER, "GetModems");
+
+	if ((rep = g_dbus_connection_send_message_with_reply_sync(config.dbus, msg,
+					G_DBUS_SEND_MESSAGE_FLAGS_NONE, -1, NULL, NULL, &err)) == NULL)
+		goto fail;
+
+	if (g_dbus_message_get_message_type(rep) == G_DBUS_MESSAGE_TYPE_ERROR) {
+		g_dbus_message_to_gerror(rep, &err);
+		goto fail;
+	}
+
+	GVariant *body = g_dbus_message_get_body(rep);
+
+	GVariantIter *modems;
+	GVariantIter *properties;
+	const char *modem;
+
+	g_variant_get(body, "(a(oa{sv}))", &modems);
+	while (g_variant_iter_next(modems, "(&oa{sv})", &modem, &properties)) {
+
+		bdaddr_t bt_addr = { 0 };
+		bool is_powered = false;
+		bool is_bt_device = false;
+		const char *serial = "";
+		const char *key;
+		GVariant *value;
+
+		while (g_variant_iter_next(properties, "{&sv}", &key, &value)) {
+			if (strcmp(key, "Powered") == 0)
+				is_powered = g_variant_get_boolean(value);
+			else if (strcmp(key, "Type") == 0) {
+				const char *type = g_variant_get_string(value, NULL);
+				if (strcmp(type, OFONO_MODEM_TYPE_HFP) == 0 ||
+						strcmp(type, OFONO_MODEM_TYPE_SAP) == 0)
+					is_bt_device = true;
+			}
+			else if (strcmp(key, "Serial") == 0)
+				serial = g_variant_get_string(value, NULL);
+			g_variant_unref(value);
+		}
+
+		if (is_bt_device)
+			str2ba(serial, &bt_addr);
+
+		g_variant_iter_free(properties);
+
+		if (!is_powered)
+			continue;
+
+		/* In case of HFP AG, we are looking for a modem which is not a BT device.
+		 * Unfortunately, oFono does not link card (Bluetooth HF device) with a
+		 * particular modem. In case where more than one card is connected oFono
+		 * uses all of them for call notification... However, in our setup we need
+		 * a 1:1 mapping between card and modem. So, we will link the first modem
+		 * which is not a BT device.
+		 *
+		 * TODO: Find a better way to link oFono card with a modem. */
+		if (ocd->is_gateway && is_bt_device)
+			continue;
+
+		/* In case of HFP HF, we are looking for a modem which is a BT device and
+		 * its serial number matches with the card BT address. */
+		if (!ocd->is_gateway &&
+				!(is_bt_device && bacmp(&bt_addr, &ocd->bt_addr) == 0))
+			continue;
+
+		debug("Linking oFono card with modem: %s", modem);
+		strncpy(ocd->modem_path, modem, sizeof(ocd->modem_path) - 1);
+		ocd->modem_path[sizeof(ocd->modem_path) - 1] = '\0';
+		ret = 0;
+		break;
+
+	}
+
+	g_variant_iter_free(modems);
+
+fail:
+	if (msg != NULL)
+		g_object_unref(msg);
+	if (rep != NULL)
+		g_object_unref(rep);
+	if (err != NULL) {
+		error("Couldn't get oFono modems: %s", err->message);
+		g_error_free(err);
+	}
+
+	return ret;
+}
+
+#define OFONO_CALL_VOLUME_NONE       (0)
+#define OFONO_CALL_VOLUME_SPEAKER    (1 << 0)
+#define OFONO_CALL_VOLUME_MICROPHONE (1 << 1)
+
+static unsigned int ofono_call_volume_property_sync(struct ba_transport *t,
+		const char *property, GVariant *value) {
+
+	struct ba_transport_pcm *spk = &t->sco.spk_pcm;
+	struct ba_transport_pcm *mic = &t->sco.mic_pcm;
+	unsigned int mask = OFONO_CALL_VOLUME_NONE;
+
+	if (strcmp(property, "Muted") == 0 &&
+			g_variant_validate_value(value, G_VARIANT_TYPE_BOOLEAN, property)) {
+
+		if (t->profile & BA_TRANSPORT_PROFILE_MASK_AG &&
+				mic->soft_volume) {
+			debug("Skipping SCO microphone mute update: %s", "Software volume enabled");
+			goto final;
+		}
+
+		bool muted = g_variant_get_boolean(value);
+		debug("Updating SCO microphone mute: %s", muted ? "true" : "false");
+		mask |= OFONO_CALL_VOLUME_MICROPHONE;
+
+		pthread_mutex_lock(&mic->mutex);
+		ba_transport_pcm_volume_set(&mic->volume[0], NULL, &muted, NULL);
+		pthread_mutex_unlock(&mic->mutex);
+
+	}
+	else if (strcmp(property, "SpeakerVolume") == 0 &&
+			g_variant_validate_value(value, G_VARIANT_TYPE_BYTE, property)) {
+		/* received volume is in range [0, 100] */
+
+		if (t->profile & BA_TRANSPORT_PROFILE_MASK_AG &&
+				spk->soft_volume) {
+			debug("Skipping SCO speaker volume update: %s", "Software volume enabled");
+			goto final;
+		}
+
+		uint8_t volume = g_variant_get_byte(value) * HFP_VOLUME_GAIN_MAX / 100;
+		int level = ba_transport_pcm_volume_range_to_level(volume, HFP_VOLUME_GAIN_MAX);
+		debug("Updating SCO speaker volume: %u [%.2f dB]", volume, 0.01 * level);
+		mask |= OFONO_CALL_VOLUME_SPEAKER;
+
+		pthread_mutex_lock(&spk->mutex);
+		ba_transport_pcm_volume_set(&spk->volume[0], &level, NULL, NULL);
+		pthread_mutex_unlock(&spk->mutex);
+
+	}
+	else if (strcmp(property, "MicrophoneVolume") == 0 &&
+			g_variant_validate_value(value, G_VARIANT_TYPE_BYTE, property)) {
+		/* received volume is in range [0, 100] */
+
+		if (t->profile & BA_TRANSPORT_PROFILE_MASK_AG &&
+				mic->soft_volume) {
+			debug("Skipping SCO microphone volume update: %s", "Software volume enabled");
+			goto final;
+		}
+
+		uint8_t volume = g_variant_get_byte(value) * HFP_VOLUME_GAIN_MAX / 100;
+		int level = ba_transport_pcm_volume_range_to_level(volume, HFP_VOLUME_GAIN_MAX);
+		debug("Updating SCO microphone volume: %u [%.2f dB]", volume, 0.01 * level);
+		mask |= OFONO_CALL_VOLUME_MICROPHONE;
+
+		pthread_mutex_lock(&mic->mutex);
+		ba_transport_pcm_volume_set(&mic->volume[0], &level, NULL, NULL);
+		pthread_mutex_unlock(&mic->mutex);
+
+	}
+
+final:
+	return mask;
+}
+
+/**
+ * Get all oFono call volume properties and update transport volumes. */
+static int ofono_call_volume_get_properties(struct ba_transport *t) {
+
+	GDBusMessage *msg = NULL, *rep = NULL;
+	GError *err = NULL;
+	int ret = 0;
+
+	msg = g_dbus_message_new_method_call(t->bluez_dbus_owner,
+			t->sco.ofono_dbus_path_modem, OFONO_IFACE_CALL_VOLUME, "GetProperties");
+
+	if ((rep = g_dbus_connection_send_message_with_reply_sync(config.dbus, msg,
+					G_DBUS_SEND_MESSAGE_FLAGS_NONE, -1, NULL, NULL, &err)) == NULL)
+		goto fail;
+
+	if (g_dbus_message_get_message_type(rep) == G_DBUS_MESSAGE_TYPE_ERROR) {
+		g_dbus_message_to_gerror(rep, &err);
+		goto fail;
+	}
+
+	GVariantIter *properties;
+	g_variant_get(g_dbus_message_get_body(rep), "(a{sv})", &properties);
+
+	const char *property;
+	GVariant *value;
+
+	unsigned int mask = OFONO_CALL_VOLUME_NONE;
+	while (g_variant_iter_next(properties, "{&sv}", &property, &value)) {
+		mask |= ofono_call_volume_property_sync(t, property, value);
+		g_variant_unref(value);
+	}
+
+	if (mask & OFONO_CALL_VOLUME_SPEAKER)
+		bluealsa_dbus_pcm_update(&t->sco.spk_pcm, BA_DBUS_PCM_UPDATE_VOLUME);
+	if (mask & OFONO_CALL_VOLUME_MICROPHONE)
+		bluealsa_dbus_pcm_update(&t->sco.mic_pcm, BA_DBUS_PCM_UPDATE_VOLUME);
+
+	g_variant_iter_free(properties);
+	goto final;
+
+fail:
+	ret = -1;
+
+final:
+	if (msg != NULL)
+		g_object_unref(msg);
+	if (rep != NULL)
+		g_object_unref(rep);
+	if (err != NULL) {
+		error("Couldn't get oFono call volume: %s", err->message);
+		g_error_free(err);
+	}
+
+	return ret;
+}
+
+/**
+ * Set oFono call volume property. */
+static int ofono_call_volume_set_property(struct ba_transport *t,
+		const char *property, GVariant *value, GError **error) {
+
+	GDBusMessage *msg = NULL, *rep = NULL;
+	int ret = 0;
+
+	msg = g_dbus_message_new_method_call(t->bluez_dbus_owner,
+			t->sco.ofono_dbus_path_modem, OFONO_IFACE_CALL_VOLUME, "SetProperty");
+
+	g_dbus_message_set_body(msg, g_variant_new("(sv)", property, value));
+
+	if ((rep = g_dbus_connection_send_message_with_reply_sync(config.dbus, msg,
+					G_DBUS_SEND_MESSAGE_FLAGS_NONE, -1, NULL, NULL, error)) == NULL)
+		goto fail;
+
+	if (g_dbus_message_get_message_type(rep) == G_DBUS_MESSAGE_TYPE_ERROR) {
+		g_dbus_message_to_gerror(rep, error);
+		goto fail;
+	}
+
+	goto final;
+
+fail:
+	ret = -1;
+
+final:
+	if (msg != NULL)
+		g_object_unref(msg);
+	if (rep != NULL)
+		g_object_unref(rep);
+	return ret;
+}
+
 /**
  * Add new oFono card (phone). */
 static void ofono_card_add(const char *dbus_sender, const char *card,
@@ -210,6 +588,7 @@ static void ofono_card_add(const char *dbus_sender, const char *card,
 	struct ba_device *d = NULL;
 	struct ba_transport *t = NULL;
 
+	enum ba_transport_profile profile = BA_TRANSPORT_PROFILE_NONE;
 	struct ofono_card_data *ocd = NULL;
 	const char *key = NULL;
 	GVariant *value = NULL;
@@ -217,11 +596,6 @@ static void ofono_card_add(const char *dbus_sender, const char *card,
 	bdaddr_t addr_hci = { 0 };
 	int hci_dev_id = -1;
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_HFP_HF,
-		.codec = HFP_CODEC_UNDEFINED,
-	};
-
 	while (g_variant_iter_next(properties, "{&sv}", &key, &value)) {
 		if (strcmp(key, "RemoteAddress") == 0)
 			str2ba(g_variant_get_string(value, NULL), &addr_dev);
@@ -232,9 +606,9 @@ static void ofono_card_add(const char *dbus_sender, const char *card,
 		else if (strcmp(key, "Type") == 0) {
 			const char *type = g_variant_get_string(value, NULL);
 			if (strcmp(type, OFONO_AUDIO_CARD_TYPE_AG) == 0)
-				ttype.profile = BA_TRANSPORT_PROFILE_HFP_AG;
+				profile = BA_TRANSPORT_PROFILE_HFP_AG;
 			else if (strcmp(type, OFONO_AUDIO_CARD_TYPE_HF) == 0)
-				ttype.profile = BA_TRANSPORT_PROFILE_HFP_HF;
+				profile = BA_TRANSPORT_PROFILE_HFP_HF;
 			else {
 				error("Unsupported profile type: %s", type);
 				goto fail;
@@ -264,14 +638,32 @@ static void ofono_card_add(const char *dbus_sender, const char *card,
 
 	ocd->hci_dev_id = hci_dev_id;
 	ocd->bt_addr = addr_dev;
-	snprintf(ocd->transport_path, sizeof(ocd->transport_path), "/ofono%s", card);
+	ocd->is_gateway = profile & BA_TRANSPORT_PROFILE_MASK_AG;
+	strncpy(ocd->card, card, sizeof(ocd->card) - 1);
+	ocd->card[sizeof(ocd->card) - 1] = '\0';
 
-	if ((t = ofono_transport_new(d, ttype, dbus_sender, ocd->transport_path)) == NULL) {
+	if (ofono_card_link_modem(ocd) == -1) {
+		error("Couldn't link oFono card with modem: %s", card);
+		goto fail;
+	}
+
+	if ((t = ofono_transport_new(d, profile, dbus_sender,
+				card, ocd->modem_path)) == NULL) {
 		error("Couldn't create new transport: %s", strerror(errno));
 		goto fail;
 	}
 
-	g_hash_table_insert(ofono_card_data_map, g_strdup(card), ocd);
+#if ENABLE_MSBC
+	if (config.hfp.codecs.msbc &&
+			profile == BA_TRANSPORT_PROFILE_HFP_AG &&
+			ba_transport_get_codec(t) == HFP_CODEC_UNDEFINED)
+		ofono_new_connection_request(t);
+#endif
+
+	/* initialize speaker and microphone volumes */
+	ofono_call_volume_get_properties(t);
+
+	g_hash_table_insert(ofono_card_data_map, ocd->card, ocd);
 	ocd = NULL;
 
 fail:
@@ -310,13 +702,16 @@ static int ofono_get_all_cards(void) {
 	GVariant *body = g_dbus_message_get_body(rep);
 
 	GVariantIter *cards;
-	GVariantIter *properties = NULL;
+	GVariantIter *properties;
 	const char *card;
 
 	g_variant_get(body, "(a(oa{sv}))", &cards);
-	while (g_variant_iter_next(cards, "(&oa{sv})", &card, &properties))
+	while (g_variant_iter_next(cards, "(&oa{sv})", &card, &properties)) {
 		ofono_card_add(sender, card, properties);
+		g_variant_iter_free(properties);
+	}
 
+	g_variant_iter_free(cards);
 	goto final;
 
 fail:
@@ -343,28 +738,18 @@ static void ofono_remove_all_cards(void) {
 	struct ofono_card_data *ocd;
 
 	g_hash_table_iter_init(&iter, ofono_card_data_map);
-	while (g_hash_table_iter_next(&iter, NULL, (gpointer)&ocd)) {
+	while (g_hash_table_iter_next(&iter, NULL, (void *)&ocd)) {
 
-		struct ba_adapter *a = NULL;
-		struct ba_device *d = NULL;
-		struct ba_transport *t;
-
-		if ((a = ba_adapter_lookup(ocd->hci_dev_id)) == NULL)
-			goto fail;
-		if ((d = ba_device_lookup(a, &ocd->bt_addr)) == NULL)
-			goto fail;
-		if ((t = ba_transport_lookup(d, ocd->transport_path)) == NULL)
-			goto fail;
+		debug("Removing oFono card: %s", ocd->card);
 
-		ba_transport_destroy(t);
+		struct ba_transport *t;
+		if ((t = ofono_transport_lookup(ocd)) != NULL)
+			ba_transport_destroy(t);
 
-fail:
-		if (a != NULL)
-			ba_adapter_unref(a);
-		if (d != NULL)
-			ba_device_unref(d);
 	}
 
+	g_hash_table_remove_all(ofono_card_data_map);
+
 }
 
 static void ofono_agent_new_connection(GDBusMethodInvocation *inv, void *userdata) {
@@ -375,34 +760,64 @@ static void ofono_agent_new_connection(GDBusMethodInvocation *inv, void *userdat
 
 	struct ba_transport *t = NULL;
 	GError *err = NULL;
-	GUnixFDList *fd_list;
 	const char *card;
 	uint8_t codec;
 	int fd;
 
-	g_variant_get(params, "(&ohy)", &card, &fd, &codec);
+	g_variant_get(params, "(&ohy)", &card, NULL, &codec);
 
-	fd_list = g_dbus_message_get_unix_fd_list(msg);
+	GUnixFDList *fd_list = g_dbus_message_get_unix_fd_list(msg);
 	if ((fd = g_unix_fd_list_get(fd_list, 0, &err)) == -1) {
-		error("Couldn't obtain SCO socket: %s", err->message);
+		error("Couldn't obtain oFono SCO link socket: %s", err->message);
 		goto fail;
 	}
 
-	if ((t = ofono_transport_lookup(card)) == NULL) {
+	if ((t = ofono_transport_lookup_card(card)) == NULL) {
 		error("Couldn't lookup transport: %s: %s", card, strerror(errno));
 		goto fail;
 	}
 
-	if (ofono_sco_socket_authorize(fd) == -1) {
-		error("Couldn't authorize SCO connection: %s", strerror(errno));
-		goto fail;
+#if ENABLE_MSBC
+	/* In AG mode, we obtain the codec when the device connects by
+	 * performing a temporary acquisition. The response to that initial
+	 * acquisition request is the only situation in which this function
+	 * is called with the transport codec not yet set. */
+	if (config.hfp.codecs.msbc &&
+			t->profile == BA_TRANSPORT_PROFILE_HFP_AG &&
+			ba_transport_get_codec(t) == HFP_CODEC_UNDEFINED) {
+
+		/* Immediately release the SCO connection to save battery: we are only
+		 * interested in the selected codec here. */
+		if (fd != -1) {
+			shutdown(fd, SHUT_RDWR);
+			close(fd);
+		}
+
+		debug("Initialized oFono SCO link codec: %#x", codec);
+		ba_transport_set_codec(t, codec);
+		ba_transport_unref(t);
+
+		g_dbus_method_invocation_return_value(inv, NULL);
+		return;
 	}
 
+	/* For HF, oFono does not authorize after setting the voice option, so we
+	 * have to do it ourselves here. */
+	if (t->profile == BA_TRANSPORT_PROFILE_HFP_HF &&
+			codec == HFP_CODEC_MSBC) {
+		uint8_t auth;
+		if (read(fd, &auth, sizeof(auth)) == -1) {
+			error("Couldn't authorize oFono SCO link: %s", strerror(errno));
+			goto fail;
+		}
+	}
+#endif
+
 	ba_transport_stop(t);
 
 	pthread_mutex_lock(&t->bt_fd_mtx);
 
-	debug("New oFono SCO connection (codec: %#x): %d", codec, fd);
+	debug("New oFono SCO link (codec: %#x): %d", codec, fd);
 
 	t->bt_fd = fd;
 	t->mtu_read = t->mtu_write = hci_sco_get_mtu(fd, t->d->a->hci.type);
@@ -410,6 +825,8 @@ static void ofono_agent_new_connection(GDBusMethodInvocation *inv, void *userdat
 
 	pthread_mutex_unlock(&t->bt_fd_mtx);
 
+	ba_transport_thread_state_set_idle(&t->thread_enc);
+	ba_transport_thread_state_set_idle(&t->thread_dec);
 	ba_transport_start(t);
 
 	g_dbus_method_invocation_return_value(inv, NULL);
@@ -418,8 +835,10 @@ static void ofono_agent_new_connection(GDBusMethodInvocation *inv, void *userdat
 fail:
 	g_dbus_method_invocation_return_error(inv, G_DBUS_ERROR,
 		G_DBUS_ERROR_INVALID_ARGS, "Unable to get connection");
-	if (fd != -1)
+	if (fd != -1) {
+		shutdown(fd, SHUT_RDWR);
 		close(fd);
+	}
 
 final:
 	if (t != NULL)
@@ -525,7 +944,6 @@ final:
 static void ofono_signal_card_added(GDBusConnection *conn, const char *sender,
 		const char *path, const char *interface, const char *signal, GVariant *params,
 		void *userdata) {
-	debug("Signal: %s.%s()", interface, signal);
 	(void)conn;
 	(void)path;
 	(void)interface;
@@ -536,9 +954,12 @@ static void ofono_signal_card_added(GDBusConnection *conn, const char *sender,
 	GVariantIter *properties = NULL;
 
 	g_variant_get(params, "(&oa{sv})", &card, &properties);
+	debug("Signal: %s.%s(%s, ...)", interface, signal, card);
+
 	ofono_card_add(sender, card, properties);
 
 	g_variant_iter_free(properties);
+
 }
 
 /**
@@ -546,7 +967,6 @@ static void ofono_signal_card_added(GDBusConnection *conn, const char *sender,
 static void ofono_signal_card_removed(GDBusConnection *conn, const char *sender,
 		const char *path, const char *interface, const char *signal, GVariant *params,
 		void *userdata) {
-	debug("Signal: %s.%s()", interface, signal);
 	(void)conn;
 	(void)sender;
 	(void)path;
@@ -556,15 +976,47 @@ static void ofono_signal_card_removed(GDBusConnection *conn, const char *sender,
 
 	const char *card = NULL;
 	g_variant_get(params, "(&o)", &card);
+	debug("Signal: %s.%s(%s)", interface, signal, card);
 
-	struct ba_transport *t = NULL;
-	if ((t = ofono_transport_lookup(card)) == NULL) {
-		error("Couldn't lookup transport: %s: %s", card, strerror(errno));
+	struct ba_transport *t;
+	if ((t = ofono_transport_lookup_card(card)) != NULL)
+		ba_transport_destroy(t);
+
+	g_hash_table_remove(ofono_card_data_map, card);
+
+}
+
+/**
+ * Callback for the PropertyChanged signal on the CallVolume interface. */
+static void ofono_signal_volume_changed(GDBusConnection *conn, const char *sender,
+		const char *modem_path, const char *interface, const char *signal, GVariant *params,
+		void *userdata) {
+	(void)conn;
+	(void)sender;
+	(void)interface;
+	(void)signal;
+	(void)userdata;
+
+	struct ba_transport *t;
+	if ((t = ofono_transport_lookup_modem(modem_path)) == NULL) {
+		error("Couldn't lookup transport: %s: %s", modem_path, strerror(errno));
 		return;
 	}
 
-	debug("Removing oFono card: %s", card);
-	ba_transport_destroy(t);
+	const char *property;
+	GVariant *value;
+
+	g_variant_get(params, "(&sv)", &property, &value);
+	debug("Signal: %s.%s(%s, ...)", interface, signal, property);
+
+	unsigned int mask = ofono_call_volume_property_sync(t, property, value);
+	if (mask & OFONO_CALL_VOLUME_SPEAKER)
+		bluealsa_dbus_pcm_update(&t->sco.spk_pcm, BA_DBUS_PCM_UPDATE_VOLUME);
+	if (mask & OFONO_CALL_VOLUME_MICROPHONE)
+		bluealsa_dbus_pcm_update(&t->sco.mic_pcm, BA_DBUS_PCM_UPDATE_VOLUME);
+
+	g_variant_unref(value);
+	ba_transport_unref(t);
 
 }
 
@@ -603,7 +1055,7 @@ int ofono_init(void) {
 		return 0;
 
 	if (ofono_card_data_map == NULL)
-		ofono_card_data_map = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+		ofono_card_data_map = g_hash_table_new_full(g_str_hash, g_str_equal, NULL, free);
 
 	g_dbus_connection_signal_subscribe(config.dbus, OFONO_SERVICE,
 			OFONO_IFACE_HF_AUDIO_MANAGER, "CardAdded", NULL, NULL,
@@ -612,6 +1064,10 @@ int ofono_init(void) {
 			OFONO_IFACE_HF_AUDIO_MANAGER, "CardRemoved", NULL, NULL,
 			G_DBUS_SIGNAL_FLAGS_NONE, ofono_signal_card_removed, NULL, NULL);
 
+	g_dbus_connection_signal_subscribe(config.dbus, OFONO_SERVICE,
+			OFONO_IFACE_CALL_VOLUME, "PropertyChanged", NULL, NULL,
+			G_DBUS_SIGNAL_FLAGS_NONE, ofono_signal_volume_changed, NULL, NULL);
+
 	g_bus_watch_name_on_connection(config.dbus, OFONO_SERVICE,
 			G_BUS_NAME_WATCHER_FLAGS_NONE, ofono_appeared, ofono_disappeared,
 			NULL, NULL);
@@ -642,3 +1098,37 @@ bool ofono_detect_service(void) {
 
 	return status;
 }
+
+/**
+ * Update oFono call volume properties. */
+int ofono_call_volume_update(struct ba_transport *t) {
+
+	struct ba_transport_pcm *spk = &t->sco.spk_pcm;
+	struct ba_transport_pcm *mic = &t->sco.mic_pcm;
+	int ret = 0;
+
+	struct {
+		const char *name;
+		GVariant *value;
+	} props[] = {
+		{ "Muted",
+			g_variant_new_boolean(mic->volume[0].scale == 0) },
+		{ "SpeakerVolume",
+			g_variant_new_byte(MIN(100,
+					ba_transport_pcm_volume_level_to_range(spk->volume[0].level, 106))) },
+		{ "MicrophoneVolume",
+			g_variant_new_byte(MIN(100,
+					ba_transport_pcm_volume_level_to_range(mic->volume[0].level, 106))) },
+	};
+
+	for (size_t i = 0; i < ARRAYSIZE(props); i++) {
+		GError *err = NULL;
+		if (ofono_call_volume_set_property(t, props[i].name, props[i].value, &err) == -1) {
+			error("Couldn't set oFono call volume: %s: %s", props[i].name, err->message);
+			g_error_free(err);
+			ret = -1;
+		}
+	}
+
+	return ret;
+}
diff --git a/src/ofono.h b/src/ofono.h
index dc1c759..1c8e6d7 100644
--- a/src/ofono.h
+++ b/src/ofono.h
@@ -1,7 +1,7 @@
 /*
  * BlueALSA - ofono.h
  * Copyright (c) 2016-2022 Arkadiusz Bokowy
- *               2018 Thierry Bultel
+ * Copyright (c) 2018 Thierry Bultel
  *
  * This file is a part of bluez-alsa.
  *
@@ -13,9 +13,17 @@
 #ifndef BLUEALSA_OFONO_H_
 #define BLUEALSA_OFONO_H_
 
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
 #include <stdbool.h>
 
+#include "ba-transport.h"
+
 int ofono_init(void);
 bool ofono_detect_service(void);
 
+int ofono_call_volume_update(struct ba_transport *t);
+
 #endif
diff --git a/src/rtp.c b/src/rtp.c
index a412173..63d002c 100644
--- a/src/rtp.c
+++ b/src/rtp.c
@@ -9,6 +9,7 @@
  */
 
 #include "rtp.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <endian.h>
 #include <stdlib.h>
diff --git a/src/sco.c b/src/sco.c
index 1a4354e..4b3132c 100644
--- a/src/sco.c
+++ b/src/sco.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - sco.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,10 +9,12 @@
  */
 
 #include "sco.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <errno.h>
 #include <poll.h>
 #include <pthread.h>
+#include <signal.h>
 #include <stdbool.h>
 #include <stdint.h>
 #include <string.h>
@@ -24,6 +26,8 @@
 #include <bluetooth/hci_lib.h>
 #include <bluetooth/sco.h>
 
+#include <glib.h>
+
 #include "ba-device.h"
 #include "bluealsa-config.h"
 #if ENABLE_MSBC
@@ -33,7 +37,6 @@
 #include "hci.h"
 #include "hfp.h"
 #include "io.h"
-#include "utils.h"
 #include "shared/defs.h"
 #include "shared/ffb.h"
 #include "shared/log.h"
@@ -59,6 +62,12 @@ static void *sco_dispatcher_thread(struct ba_adapter *a) {
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_push(PTHREAD_CLEANUP(sco_dispatcher_cleanup), &data);
 
+	sigset_t sigset;
+	/* See the ba_transport_thread_create() function for information
+	 * why we have to mask all signals. */
+	sigfillset(&sigset);
+	pthread_sigmask(SIG_SETMASK, &sigset, NULL);
+
 	if ((data.pfd.fd = hci_sco_open(data.a->hci.dev_id)) == -1) {
 		error("Couldn't open SCO socket: %s", strerror(errno));
 		goto fail;
@@ -81,20 +90,21 @@ static void *sco_dispatcher_thread(struct ba_adapter *a) {
 	for (;;) {
 
 		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+		int poll_rv = poll(&data.pfd, 1, -1);
+		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 
-		if (poll(&data.pfd, 1, -1) == -1) {
+		if (poll_rv == -1) {
 			if (errno == EINTR)
 				continue;
 			error("SCO dispatcher poll error: %s", strerror(errno));
 			goto fail;
 		}
 
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-
 		struct sockaddr_sco addr;
 		socklen_t addrlen = sizeof(addr);
 		struct ba_device *d = NULL;
 		struct ba_transport *t = NULL;
+		char addrstr[18];
 		int fd = -1;
 
 		if ((fd = accept(data.pfd.fd, (struct sockaddr *)&addr, &addrlen)) == -1) {
@@ -102,10 +112,11 @@ static void *sco_dispatcher_thread(struct ba_adapter *a) {
 			goto cleanup;
 		}
 
-		debug("New incoming SCO link: %s: %d", batostr_(&addr.sco_bdaddr), fd);
+		ba2str(&addr.sco_bdaddr, addrstr);
+		debug("New incoming SCO link: %s: %d", addrstr, fd);
 
 		if ((d = ba_device_lookup(data.a, &addr.sco_bdaddr)) == NULL) {
-			error("Couldn't lookup device: %s", batostr_(&addr.sco_bdaddr));
+			error("Couldn't lookup device: %s", addrstr);
 			goto cleanup;
 		}
 
@@ -116,7 +127,7 @@ static void *sco_dispatcher_thread(struct ba_adapter *a) {
 
 #if ENABLE_MSBC
 		struct bt_voice voice = { .setting = BT_VOICE_TRANSPARENT };
-		if (t->type.codec == HFP_CODEC_MSBC &&
+		if (ba_transport_get_codec(t) == HFP_CODEC_MSBC &&
 				setsockopt(fd, SOL_BLUETOOTH, BT_VOICE, &voice, sizeof(voice)) == -1) {
 			error("Couldn't setup transparent voice: %s", strerror(errno));
 			goto cleanup;
@@ -137,6 +148,8 @@ static void *sco_dispatcher_thread(struct ba_adapter *a) {
 
 		pthread_mutex_unlock(&t->bt_fd_mtx);
 
+		ba_transport_thread_state_set_idle(&t->thread_enc);
+		ba_transport_thread_state_set_idle(&t->thread_dec);
 		ba_transport_start(t);
 
 cleanup:
@@ -150,7 +163,6 @@ cleanup:
 	}
 
 fail:
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_pop(1);
 	return NULL;
 }
@@ -196,7 +208,7 @@ int sco_setup_connection_dispatcher(struct ba_adapter *a) {
 	 * during the whole live-span of the thread, because the thread is canceled
 	 * in the adapter cleanup routine. See the ba_adapter_unref() function. */
 	if ((ret = pthread_create(&a->sco_dispatcher, NULL,
-					PTHREAD_ROUTINE(sco_dispatcher_thread), a)) != 0) {
+					PTHREAD_FUNC(sco_dispatcher_thread), a)) != 0) {
 		error("Couldn't create SCO dispatcher: %s", strerror(ret));
 		a->sco_dispatcher = config.main_thread;
 		return -1;
@@ -214,7 +226,7 @@ static void *sco_cvsd_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *pcm = &t->sco.spk_pcm;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	const size_t mtu_samples = t->mtu_write / sizeof(int16_t);
@@ -230,14 +242,19 @@ static void *sco_cvsd_enc_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t samples = ffb_len_in(&buffer);
-		if ((samples = io_poll_and_read_pcm(&io, pcm, buffer.tail, samples)) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
-			else if (samples == 0)
-				ba_transport_stop_if_no_clients(t);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, buffer.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				ffb_rewind(&buffer);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
+			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
 
@@ -262,7 +279,7 @@ static void *sco_cvsd_enc_thread(struct ba_transport_thread *th) {
 			/* keep data transfer at a constant bit rate */
 			asrsync_sync(&io.asrs, mtu_samples);
 			/* update busy delay (encoding overhead) */
-			pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 		}
 
@@ -272,8 +289,6 @@ static void *sco_cvsd_enc_thread(struct ba_transport_thread *th) {
 
 exit:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_init:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -286,7 +301,7 @@ static void *sco_cvsd_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *pcm = &t->sco.mic_pcm;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	const size_t mtu_samples = t->mtu_read / sizeof(int16_t);
@@ -301,7 +316,7 @@ static void *sco_cvsd_dec_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&buffer);
 		if ((len = io_poll_and_read_bt(&io, th, buffer.tail, len)) == -1)
@@ -311,27 +326,33 @@ static void *sco_cvsd_dec_thread(struct ba_transport_thread *th) {
 
 		if ((size_t)len == buffer.nmemb * buffer.size) {
 			debug("Resizing CVSD read buffer: %zd -> %zd",
-					buffer.nmemb * buffer.size, buffer.nmemb * buffer.size * 2);
+					buffer.nmemb * buffer.size, buffer.nmemb * 2 * buffer.size);
 			if (ffb_init_int16_t(&buffer, buffer.nmemb * 2) == -1)
 				error("Couldn't resize CVSD read buffer: %s", strerror(errno));
 		}
 
-		if (!ba_transport_pcm_is_active(pcm))
+		if (!ba_transport_pcm_is_active(t_pcm))
 			continue;
 
-		ssize_t samples = len / sizeof(int16_t);
-		io_pcm_scale(pcm, buffer.data, samples);
-		if ((samples = io_pcm_write(pcm, buffer.data, samples)) == -1)
+		if (len > 0)
+			ffb_seek(&buffer, len / buffer.size);
+
+		ssize_t samples;
+		if ((samples = ffb_len_out(&buffer)) <= 0)
+			continue;
+
+		io_pcm_scale(t_pcm, buffer.data, samples);
+		if ((samples = io_pcm_write(t_pcm, buffer.data, samples)) == -1)
 			error("FIFO write error: %s", strerror(errno));
 		else if (samples == 0)
 			ba_transport_stop_if_no_clients(t);
 
+		ffb_shift(&buffer, samples);
+
 	}
 
 exit:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -345,7 +366,7 @@ static void *sco_msbc_enc_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *pcm = &t->sco.spk_pcm;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 	const size_t mtu_write = t->mtu_write;
 
@@ -358,14 +379,20 @@ static void *sco_msbc_enc_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t samples = ffb_len_in(&msbc.pcm);
-		if ((samples = io_poll_and_read_pcm(&io, pcm, msbc.pcm.tail, samples)) <= 0) {
-			if (samples == -1)
-				error("PCM poll and read error: %s", strerror(errno));
-			else if (samples == 0)
-				ba_transport_stop_if_no_clients(t);
+		switch (samples = io_poll_and_read_pcm(&io, t_pcm, msbc.pcm.tail, samples)) {
+		case -1:
+			if (errno == ESTALE) {
+				/* reinitialize mSBC encoder */
+				msbc_init(&msbc);
+				continue;
+			}
+			error("PCM poll and read error: %s", strerror(errno));
+			/* fall-through */
+		case 0:
+			ba_transport_stop_if_no_clients(t);
 			continue;
 		}
 
@@ -399,10 +426,10 @@ static void *sco_msbc_enc_thread(struct ba_transport_thread *th) {
 			/* keep data transfer at a constant bit rate */
 			asrsync_sync(&io.asrs, msbc.frames * MSBC_CODESAMPLES);
 			/* update busy delay (encoding overhead) */
-			pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
+			t_pcm->delay = asrsync_get_busy_usec(&io.asrs) / 100;
 
 			/* Move unprocessed data to the front of our linear
-			* buffer and clear the mSBC frame counter. */
+			 * buffer and clear the mSBC frame counter. */
 			ffb_shift(&msbc.data, ffb_blen_out(&msbc.data) - data_len);
 			msbc.frames = 0;
 
@@ -412,8 +439,6 @@ static void *sco_msbc_enc_thread(struct ba_transport_thread *th) {
 
 exit:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_msbc:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -428,7 +453,7 @@ static void *sco_msbc_dec_thread(struct ba_transport_thread *th) {
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
 	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *pcm = &t->sco.mic_pcm;
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	struct io_poll io = { .timeout = -1 };
 
 	struct esco_msbc msbc = { .initialized = false };
@@ -440,7 +465,7 @@ static void *sco_msbc_dec_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&msbc.data);
 		if ((len = io_poll_and_read_bt(&io, th, msbc.data.tail, len)) == -1)
@@ -448,10 +473,11 @@ static void *sco_msbc_dec_thread(struct ba_transport_thread *th) {
 		else if (len == 0)
 			goto exit;
 
-		if (!ba_transport_pcm_is_active(pcm))
+		if (!ba_transport_pcm_is_active(t_pcm))
 			continue;
 
-		ffb_seek(&msbc.data, len);
+		if (len > 0)
+			ffb_seek(&msbc.data, len);
 
 		int err;
 		if ((err = msbc_decode(&msbc)) < 0) {
@@ -463,8 +489,8 @@ static void *sco_msbc_dec_thread(struct ba_transport_thread *th) {
 		if ((samples = ffb_len_out(&msbc.pcm)) <= 0)
 			continue;
 
-		io_pcm_scale(pcm, msbc.pcm.data, samples);
-		if ((samples = io_pcm_write(pcm, msbc.pcm.data, samples)) == -1)
+		io_pcm_scale(t_pcm, msbc.pcm.data, samples);
+		if ((samples = io_pcm_write(t_pcm, msbc.pcm.data, samples)) == -1)
 			error("FIFO write error: %s", strerror(errno));
 		else if (samples == 0)
 			ba_transport_stop_if_no_clients(t);
@@ -475,8 +501,6 @@ static void *sco_msbc_dec_thread(struct ba_transport_thread *th) {
 
 exit:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_msbc:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
@@ -485,7 +509,7 @@ fail_msbc:
 #endif
 
 void *sco_enc_thread(struct ba_transport_thread *th) {
-	switch (th->t->type.codec) {
+	switch (ba_transport_get_codec(th->t)) {
 	case HFP_CODEC_CVSD:
 	default:
 		return sco_cvsd_enc_thread(th);
@@ -496,8 +520,9 @@ void *sco_enc_thread(struct ba_transport_thread *th) {
 	}
 }
 
+__attribute__ ((weak))
 void *sco_dec_thread(struct ba_transport_thread *th) {
-	switch (th->t->type.codec) {
+	switch (ba_transport_get_codec(th->t)) {
 	case HFP_CODEC_CVSD:
 	default:
 		return sco_cvsd_dec_thread(th);
@@ -507,3 +532,23 @@ void *sco_dec_thread(struct ba_transport_thread *th) {
 #endif
 	}
 }
+
+int sco_transport_start(struct ba_transport *t) {
+
+	int rv = 0;
+
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_AG) {
+		rv |= ba_transport_thread_create(&t->thread_enc, sco_enc_thread, "ba-sco-enc", true);
+		rv |= ba_transport_thread_create(&t->thread_dec, sco_dec_thread, "ba-sco-dec", false);
+		return rv;
+	}
+
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_HF) {
+		rv |= ba_transport_thread_create(&t->thread_dec, sco_dec_thread, "ba-sco-dec", true);
+		rv |= ba_transport_thread_create(&t->thread_enc, sco_enc_thread, "ba-sco-enc", false);
+		return rv;
+	}
+
+	g_assert_not_reached();
+	return -1;
+}
diff --git a/src/sco.h b/src/sco.h
index e60f6c4..3d157df 100644
--- a/src/sco.h
+++ b/src/sco.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - sco.h
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -20,8 +20,6 @@
 #include "ba-transport.h"
 
 int sco_setup_connection_dispatcher(struct ba_adapter *a);
-
-void *sco_enc_thread(struct ba_transport_thread *th);
-void *sco_dec_thread(struct ba_transport_thread *th);
+int sco_transport_start(struct ba_transport *t);
 
 #endif
diff --git a/src/shared/a2dp-codecs.c b/src/shared/a2dp-codecs.c
index 339b0e0..f1a73ce 100644
--- a/src/shared/a2dp-codecs.c
+++ b/src/shared/a2dp-codecs.c
@@ -10,7 +10,6 @@
 
 #include "shared/a2dp-codecs.h"
 
-#include <stdbool.h>
 #include <stddef.h>
 #include <stdint.h>
 #include <strings.h>
diff --git a/src/shared/dbus-client.c b/src/shared/dbus-client.c
index ef906ad..47dba1a 100644
--- a/src/shared/dbus-client.c
+++ b/src/shared/dbus-client.c
@@ -240,13 +240,69 @@ dbus_bool_t bluealsa_dbus_connection_poll_dispatch(
 	return rv;
 }
 
+static dbus_bool_t bluealsa_dbus_props_get_all(
+		struct ba_dbus_ctx *ctx,
+		const char *path,
+		const char *interface,
+		DBusError *error,
+		dbus_bool_t (*cb)(const char *key, DBusMessageIter *val, void *data, DBusError *err),
+		void *userdata) {
+
+	DBusMessage *msg = NULL, *rep = NULL;
+	dbus_bool_t rv = FALSE;
+
+	if ((msg = dbus_message_new_method_call(ctx->ba_service, path,
+					DBUS_INTERFACE_PROPERTIES, "GetAll")) == NULL) {
+		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
+		goto fail;
+	}
+
+	DBusMessageIter iter;
+	dbus_message_iter_init_append(msg, &iter);
+	if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &interface)) {
+		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
+		goto fail;
+	}
+
+	if ((rep = dbus_connection_send_with_reply_and_block(ctx->conn,
+					msg, DBUS_TIMEOUT_USE_DEFAULT, error)) == NULL)
+		goto fail;
+
+	if (!dbus_message_iter_init(rep, &iter)) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE, "Empty response message");
+		goto fail;
+	}
+
+	if (!bluealsa_dbus_message_iter_dict(&iter, error, cb, userdata))
+		goto fail;
+
+	rv = TRUE;
+
+fail:
+	if (rep != NULL)
+		dbus_message_unref(rep);
+	if (msg != NULL)
+		dbus_message_unref(msg);
+	return rv;
+}
+
 /**
- * Callback function for BlueALSA service properties parser. */
-static dbus_bool_t bluealsa_dbus_message_iter_get_props_cb(const char *key,
+ * Callback function for manager object properties parser. */
+static dbus_bool_t bluealsa_dbus_message_iter_get_manager_props_cb(const char *key,
 		DBusMessageIter *value, void *userdata, DBusError *error) {
 	struct ba_service_props *props = (struct ba_service_props *)userdata;
 
-	char type = dbus_message_iter_get_arg_type(value);
+	char type;
+	if ((type = dbus_message_iter_get_arg_type(value)) != DBUS_TYPE_VARIANT) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+				"Incorrect property value type: %c != %c", type, DBUS_TYPE_VARIANT);
+		return FALSE;
+	}
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
+	type = dbus_message_iter_get_arg_type(&variant);
+
 	char type_expected;
 
 	if (strcmp(key, "Version") == 0) {
@@ -255,7 +311,7 @@ static dbus_bool_t bluealsa_dbus_message_iter_get_props_cb(const char *key,
 			goto fail;
 
 		const char *tmp;
-		dbus_message_iter_get_basic(value, &tmp);
+		dbus_message_iter_get_basic(&variant, &tmp);
 		strncpy(props->version, tmp, sizeof(props->version) - 1);
 
 	}
@@ -266,7 +322,7 @@ static dbus_bool_t bluealsa_dbus_message_iter_get_props_cb(const char *key,
 
 		const char *tmp[ARRAYSIZE(props->adapters)];
 		size_t length = ARRAYSIZE(tmp);
-		if (!bluealsa_dbus_message_iter_array_get_strings(value, error, tmp, &length))
+		if (!bluealsa_dbus_message_iter_array_get_strings(&variant, error, tmp, &length))
 			return FALSE;
 
 		props->adapters_len = MIN(length, ARRAYSIZE(tmp));
@@ -281,7 +337,7 @@ static dbus_bool_t bluealsa_dbus_message_iter_get_props_cb(const char *key,
 
 		const char *tmp[32];
 		size_t length = ARRAYSIZE(tmp);
-		if (!bluealsa_dbus_message_iter_array_get_strings(value, error, tmp, &length))
+		if (!bluealsa_dbus_message_iter_array_get_strings(&variant, error, tmp, &length))
 			return FALSE;
 
 		props->profiles = malloc(length * sizeof(*props->profiles));
@@ -297,7 +353,7 @@ static dbus_bool_t bluealsa_dbus_message_iter_get_props_cb(const char *key,
 
 		const char *tmp[64];
 		size_t length = ARRAYSIZE(tmp);
-		if (!bluealsa_dbus_message_iter_array_get_strings(value, error, tmp, &length))
+		if (!bluealsa_dbus_message_iter_array_get_strings(&variant, error, tmp, &length))
 			return FALSE;
 
 		props->codecs = malloc(length * sizeof(*props->codecs));
@@ -325,49 +381,14 @@ dbus_bool_t bluealsa_dbus_get_props(
 		struct ba_service_props *props,
 		DBusError *error) {
 
-	static const char *interface = BLUEALSA_INTERFACE_MANAGER;
-	DBusMessage *msg = NULL, *rep = NULL;
-	dbus_bool_t rv = FALSE;
-
 	props->profiles = NULL;
 	props->profiles_len = 0;
 	props->codecs = NULL;
 	props->codecs_len = 0;
 
-	if ((msg = dbus_message_new_method_call(ctx->ba_service, "/org/bluealsa",
-					DBUS_INTERFACE_PROPERTIES, "GetAll")) == NULL) {
-		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
-		goto fail;
-	}
-
-	DBusMessageIter iter;
-	dbus_message_iter_init_append(msg, &iter);
-	if (!dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &interface)) {
-		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
-		goto fail;
-	}
-
-	if ((rep = dbus_connection_send_with_reply_and_block(ctx->conn,
-					msg, DBUS_TIMEOUT_USE_DEFAULT, error)) == NULL)
-		goto fail;
-
-	if (!dbus_message_iter_init(rep, &iter)) {
-		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE, "Empty response message");
-		goto fail;
-	}
-
-	if (!bluealsa_dbus_message_iter_dict(&iter, error,
-				bluealsa_dbus_message_iter_get_props_cb, props))
-		goto fail;
-
-	rv = TRUE;
-
-fail:
-	if (rep != NULL)
-		dbus_message_unref(rep);
-	if (msg != NULL)
-		dbus_message_unref(msg);
-	return rv;
+	return bluealsa_dbus_props_get_all(ctx,
+			"/org/bluealsa", BLUEALSA_INTERFACE_MANAGER, error,
+			bluealsa_dbus_message_iter_get_manager_props_cb, props);
 }
 
 /**
@@ -388,6 +409,101 @@ void bluealsa_dbus_props_free(
 	}
 }
 
+/**
+ * Callback function for rfcomm object properties parser. */
+static dbus_bool_t bluealsa_dbus_message_iter_get_rfcomm_props_cb(const char *key,
+		DBusMessageIter *value, void *userdata, DBusError *error) {
+	struct ba_rfcomm_props *props = (struct ba_rfcomm_props *)userdata;
+
+	char type;
+	if ((type = dbus_message_iter_get_arg_type(value)) != DBUS_TYPE_VARIANT) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+				"Incorrect property value type: %c != %c", type, DBUS_TYPE_VARIANT);
+		return FALSE;
+	}
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
+	type = dbus_message_iter_get_arg_type(&variant);
+
+	char type_expected;
+
+	if (strcmp(key, "Transport") == 0) {
+
+		if (type != (type_expected = DBUS_TYPE_STRING))
+			goto fail;
+
+		const char *tmp;
+		dbus_message_iter_get_basic(&variant, &tmp);
+		strncpy(props->transport, tmp, sizeof(props->transport) - 1);
+
+	}
+	else if (strcmp(key, "Features") == 0) {
+
+		if (type != (type_expected = DBUS_TYPE_ARRAY))
+			goto fail;
+
+		const char *tmp[32];
+		size_t length = ARRAYSIZE(tmp);
+		if (!bluealsa_dbus_message_iter_array_get_strings(&variant, error, tmp, &length))
+			return FALSE;
+
+		props->features = malloc(length * sizeof(*props->features));
+		props->features_len = MIN(length, ARRAYSIZE(tmp));
+		for (size_t i = 0; i < length; i++)
+			props->features[i] = strdup(tmp[i]);
+
+	}
+	else if (strcmp(key, "Battery") == 0) {
+
+		if (type != (type_expected = DBUS_TYPE_BYTE))
+			goto fail;
+
+		signed char level;
+		dbus_message_iter_get_basic(&variant, &level);
+		props->battery = level;
+
+	}
+
+	return TRUE;
+
+fail:
+	dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+			"Incorrect variant for '%s': %c != %c", key, type, type_expected);
+	return FALSE;
+}
+
+/**
+ * Get properties of BlueALSA RFCOMM object.
+ *
+ * This function allocates resources within the properties structure, which
+ * shall be freed with the bluealsa_dbus_rfcomm_props_free() function. */
+dbus_bool_t bluealsa_dbus_get_rfcomm_props(
+		struct ba_dbus_ctx *ctx,
+		const char *rfcomm_path,
+		struct ba_rfcomm_props *props,
+		DBusError *error) {
+
+	props->features = NULL;
+	props->features_len = 0;
+
+	return bluealsa_dbus_props_get_all(ctx,
+			rfcomm_path, BLUEALSA_INTERFACE_RFCOMM, error,
+			bluealsa_dbus_message_iter_get_rfcomm_props_cb, props);
+}
+
+/**
+ * Free BlueALSA RFCOMM properties structure. */
+void bluealsa_dbus_rfcomm_props_free(
+		struct ba_rfcomm_props *props) {
+	if (props->features != NULL) {
+		for (size_t i = 0; i < props->features_len; i++)
+			free(props->features[i]);
+		free(props->features);
+		props->features = NULL;
+	}
+}
+
 dbus_bool_t bluealsa_dbus_get_pcms(
 		struct ba_dbus_ctx *ctx,
 		struct ba_pcm **pcms,
@@ -565,7 +681,133 @@ const char *bluealsa_dbus_pcm_get_codec_canonical_name(
 }
 
 /**
- * Select BlueALSA PCM Bluetooth codec. */
+ * Callback function for BlueALSA PCM codec props parser. */
+static dbus_bool_t bluealsa_dbus_message_iter_pcm_get_codec_props_cb(const char *key,
+		DBusMessageIter *value, void *userdata, DBusError *error) {
+	struct ba_pcm_codec *codec = (struct ba_pcm_codec *)userdata;
+
+	char type;
+	if ((type = dbus_message_iter_get_arg_type(value)) != DBUS_TYPE_VARIANT) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+				"Incorrect property value type: %c != %c", type, DBUS_TYPE_VARIANT);
+		return FALSE;
+	}
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
+	type = dbus_message_iter_get_arg_type(&variant);
+
+	char type_expected;
+
+	if (strcmp(key, "Capabilities") == 0) {
+		if (type != (type_expected = DBUS_TYPE_ARRAY))
+			goto fail;
+
+		DBusMessageIter iter;
+		uint8_t *data;
+		int len;
+
+		dbus_message_iter_recurse(&variant, &iter);
+		dbus_message_iter_get_fixed_array(&iter, &data, &len);
+
+		codec->data_len = MIN(len, sizeof(codec->data));
+		memcpy(codec->data, data, codec->data_len);
+
+	}
+
+	return TRUE;
+
+fail:
+	dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+			"Incorrect variant for '%s': %c != %c", key, type, type_expected);
+	return FALSE;
+}
+
+/**
+ * Callback function for BlueALSA PCM codec list parser. */
+static dbus_bool_t bluealsa_dbus_message_iter_pcm_get_codecs_cb(const char *key,
+		DBusMessageIter *value, void *userdata, DBusError *error) {
+
+	struct ba_pcm_codecs *codecs = (struct ba_pcm_codecs *)userdata;
+	const size_t len = codecs->codecs_len;
+
+	struct ba_pcm_codec *tmp = codecs->codecs;
+	if ((tmp = realloc(tmp, (len + 1) * sizeof(*tmp))) == NULL) {
+		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
+		return FALSE;
+	}
+
+	struct ba_pcm_codec *codec = &tmp[len];
+	codecs->codecs = tmp;
+
+	memset(codec, 0, sizeof(*codec));
+	strncpy(codec->name, key, sizeof(codec->name));
+	codec->name[sizeof(codec->name) - 1] = '\0';
+
+	if (!bluealsa_dbus_message_iter_dict(value, error,
+				bluealsa_dbus_message_iter_pcm_get_codec_props_cb, codec))
+		return FALSE;
+
+	codecs->codecs_len = len + 1;
+	return TRUE;
+}
+
+/**
+ * Get BlueALSA PCM Bluetooth audio codecs. */
+dbus_bool_t bluealsa_dbus_pcm_get_codecs(
+		struct ba_dbus_ctx *ctx,
+		const char *pcm_path,
+		struct ba_pcm_codecs *codecs,
+		DBusError *error) {
+
+	DBusMessage *msg = NULL, *rep = NULL;
+	dbus_bool_t rv = FALSE;
+
+	if ((msg = dbus_message_new_method_call(ctx->ba_service, pcm_path,
+					BLUEALSA_INTERFACE_PCM, "GetCodecs")) == NULL) {
+		dbus_set_error(error, DBUS_ERROR_NO_MEMORY, NULL);
+		goto fail;
+	}
+
+	if ((rep = dbus_connection_send_with_reply_and_block(ctx->conn,
+					msg, DBUS_TIMEOUT_USE_DEFAULT, error)) == NULL)
+		goto fail;
+
+	DBusMessageIter iter;
+	if (!dbus_message_iter_init(rep, &iter)) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE, "Empty response message");
+		goto fail;
+	}
+
+	codecs->codecs = NULL;
+	codecs->codecs_len = 0;
+
+	if (!bluealsa_dbus_message_iter_dict(&iter, error,
+				bluealsa_dbus_message_iter_pcm_get_codecs_cb, codecs)) {
+		free(codecs->codecs);
+		goto fail;
+	}
+
+	rv = TRUE;
+
+fail:
+	if (msg != NULL)
+		dbus_message_unref(msg);
+	if (rep != NULL)
+		dbus_message_unref(rep);
+	return rv;
+}
+
+/**
+ * Free BlueALSA PCM codecs structure. */
+void bluealsa_dbus_pcm_codecs_free(
+		struct ba_pcm_codecs *codecs) {
+	free(codecs->codecs);
+	codecs->codecs = NULL;
+}
+
+/**
+ * Select BlueALSA PCM Bluetooth audio codec. */
 dbus_bool_t bluealsa_dbus_pcm_select_codec(
 		struct ba_dbus_ctx *ctx,
 		const char *pcm_path,
@@ -809,7 +1051,6 @@ dbus_bool_t bluealsa_dbus_message_iter_dict(
 			dbus_message_iter_next(&iter_dict)) {
 
 		DBusMessageIter iter_entry;
-		DBusMessageIter iter_entry_val;
 		const char *key;
 
 		if (dbus_message_iter_get_arg_type(&iter_dict) != DBUS_TYPE_DICT_ENTRY)
@@ -818,12 +1059,10 @@ dbus_bool_t bluealsa_dbus_message_iter_dict(
 		if (dbus_message_iter_get_arg_type(&iter_entry) != DBUS_TYPE_STRING)
 			goto fail;
 		dbus_message_iter_get_basic(&iter_entry, &key);
-		if (!dbus_message_iter_next(&iter_entry) ||
-				dbus_message_iter_get_arg_type(&iter_entry) != DBUS_TYPE_VARIANT)
+		if (!dbus_message_iter_next(&iter_entry))
 			goto fail;
-		dbus_message_iter_recurse(&iter_entry, &iter_entry_val);
 
-		if (!cb(key, &iter_entry_val, userdata, error))
+		if (!cb(key, &iter_entry, userdata, error))
 			return FALSE;
 
 	}
@@ -833,7 +1072,7 @@ dbus_bool_t bluealsa_dbus_message_iter_dict(
 fail:
 	signature = dbus_message_iter_get_signature(iter);
 	dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
-			"Incorrect signature: %s != a{sv}", signature);
+			"Incorrect signature: %s != a{s#}", signature);
 	dbus_free(signature);
 	return FALSE;
 }
@@ -905,29 +1144,39 @@ fail:
 /**
  * Callback function for BlueALSA PCM properties parser. */
 static dbus_bool_t bluealsa_dbus_message_iter_get_pcm_props_cb(const char *key,
-		DBusMessageIter *variant, void *userdata, DBusError *error) {
+		DBusMessageIter *value, void *userdata, DBusError *error) {
 	struct ba_pcm *pcm = (struct ba_pcm *)userdata;
 
-	char type = dbus_message_iter_get_arg_type(variant);
+	char type;
+	if ((type = dbus_message_iter_get_arg_type(value)) != DBUS_TYPE_VARIANT) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+				"Incorrect property value type: %c != %c", type, DBUS_TYPE_VARIANT);
+		return FALSE;
+	}
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
+	type = dbus_message_iter_get_arg_type(&variant);
+
 	char type_expected;
 	const char *tmp;
 
 	if (strcmp(key, "Device") == 0) {
 		if (type != (type_expected = DBUS_TYPE_OBJECT_PATH))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &tmp);
+		dbus_message_iter_get_basic(&variant, &tmp);
 		strncpy(pcm->device_path, tmp, sizeof(pcm->device_path) - 1);
 		path2ba(tmp, &pcm->addr);
 	}
 	else if (strcmp(key, "Sequence") == 0) {
 		if (type != (type_expected = DBUS_TYPE_UINT32))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->sequence);
+		dbus_message_iter_get_basic(&variant, &pcm->sequence);
 	}
 	else if (strcmp(key, "Transport") == 0) {
 		if (type != (type_expected = DBUS_TYPE_STRING))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &tmp);
+		dbus_message_iter_get_basic(&variant, &tmp);
 		if (strstr(tmp, "A2DP-source") != NULL)
 			pcm->transport = BA_PCM_TRANSPORT_A2DP_SOURCE;
 		else if (strstr(tmp, "A2DP-sink") != NULL)
@@ -944,47 +1193,67 @@ static dbus_bool_t bluealsa_dbus_message_iter_get_pcm_props_cb(const char *key,
 	else if (strcmp(key, "Mode") == 0) {
 		if (type != (type_expected = DBUS_TYPE_STRING))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &tmp);
+		dbus_message_iter_get_basic(&variant, &tmp);
 		if (strcmp(tmp, "source") == 0)
 			pcm->mode = BA_PCM_MODE_SOURCE;
 		else if (strcmp(tmp, "sink") == 0)
 			pcm->mode = BA_PCM_MODE_SINK;
 	}
+	else if (strcmp(key, "Running") == 0) {
+		if (type != (type_expected = DBUS_TYPE_BOOLEAN))
+			goto fail;
+		dbus_message_iter_get_basic(&variant, &pcm->running);
+	}
 	else if (strcmp(key, "Format") == 0) {
 		if (type != (type_expected = DBUS_TYPE_UINT16))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->format);
+		dbus_message_iter_get_basic(&variant, &pcm->format);
 	}
 	else if (strcmp(key, "Channels") == 0) {
 		if (type != (type_expected = DBUS_TYPE_BYTE))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->channels);
+		dbus_message_iter_get_basic(&variant, &pcm->channels);
 	}
 	else if (strcmp(key, "Sampling") == 0) {
 		if (type != (type_expected = DBUS_TYPE_UINT32))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->sampling);
+		dbus_message_iter_get_basic(&variant, &pcm->sampling);
 	}
 	else if (strcmp(key, "Codec") == 0) {
 		if (type != (type_expected = DBUS_TYPE_STRING))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &tmp);
-		strncpy(pcm->codec, tmp, sizeof(pcm->codec) - 1);
+		dbus_message_iter_get_basic(&variant, &tmp);
+		strncpy(pcm->codec.name, tmp, sizeof(pcm->codec.name) - 1);
+	}
+	else if (strcmp(key, "CodecConfiguration") == 0) {
+		if (type != (type_expected = DBUS_TYPE_ARRAY))
+			goto fail;
+
+		DBusMessageIter iter;
+		uint8_t *data;
+		int len;
+
+		dbus_message_iter_recurse(&variant, &iter);
+		dbus_message_iter_get_fixed_array(&iter, &data, &len);
+
+		pcm->codec.data_len = MIN(len, sizeof(pcm->codec.data));
+		memcpy(pcm->codec.data, data, pcm->codec.data_len);
+
 	}
 	else if (strcmp(key, "Delay") == 0) {
 		if (type != (type_expected = DBUS_TYPE_UINT16))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->delay);
+		dbus_message_iter_get_basic(&variant, &pcm->delay);
 	}
 	else if (strcmp(key, "SoftVolume") == 0) {
 		if (type != (type_expected = DBUS_TYPE_BOOLEAN))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->soft_volume);
+		dbus_message_iter_get_basic(&variant, &pcm->soft_volume);
 	}
 	else if (strcmp(key, "Volume") == 0) {
 		if (type != (type_expected = DBUS_TYPE_UINT16))
 			goto fail;
-		dbus_message_iter_get_basic(variant, &pcm->volume.raw);
+		dbus_message_iter_get_basic(&variant, &pcm->volume.raw);
 	}
 
 	return TRUE;
diff --git a/src/shared/dbus-client.h b/src/shared/dbus-client.h
index 8039d30..6d2dd49 100644
--- a/src/shared/dbus-client.h
+++ b/src/shared/dbus-client.h
@@ -52,6 +52,20 @@
 #define BA_PCM_MODE_SOURCE           (1 << 0)
 #define BA_PCM_MODE_SINK             (1 << 1)
 
+/**
+ * Determine whether given PCM is transported
+ * over A2DP codec main-channel link. */
+#define BA_PCM_A2DP_MAIN_CHANNEL(pcm) ( \
+       ((pcm)->transport & BA_PCM_TRANSPORT_A2DP_SOURCE && (pcm)->mode & BA_PCM_MODE_SINK) || \
+       ((pcm)->transport & BA_PCM_TRANSPORT_A2DP_SINK && (pcm)->mode & BA_PCM_MODE_SOURCE))
+
+/**
+ * Determine whether given PCM is transported
+ * over HFP/HSP speaker channel link. */
+#define BA_PCM_SCO_SPEAKER_CHANNEL(pcm) ( \
+				((pcm)->transport & BA_PCM_TRANSPORT_MASK_AG && (pcm)->mode & BA_PCM_MODE_SINK) || \
+				((pcm)->transport & BA_PCM_TRANSPORT_MASK_HF && (pcm)->mode & BA_PCM_MODE_SOURCE))
+
 /**
  * Get max volume level for given PCM. */
 #define BA_PCM_VOLUME_MAX(pcm) \
@@ -88,6 +102,18 @@ struct ba_service_props {
 	size_t codecs_len;
 };
 
+/**
+ * BlueALSA RFCOMM property object. */
+struct ba_rfcomm_props {
+	/* BlueALSA transport type */
+	char transport[7];
+	/* remote device supported features */
+	char **features;
+	size_t features_len;
+	/* remote device battery level */
+	int battery;
+};
+
 /**
  * BlueALSA PCM object property. */
 enum ba_pcm_property {
@@ -95,6 +121,18 @@ enum ba_pcm_property {
 	BLUEALSA_PCM_VOLUME,
 };
 
+/**
+ * BlueALSA PCM codec object. */
+struct ba_pcm_codec {
+	/* codec canonical name */
+	char name[16];
+	/* Data associated with the codec. For A2DP transport it might
+	 * be a capabilities or a configuration blob respectively for
+	 * the list of available codecs or currently selected codec. */
+	uint8_t data[24];
+	size_t data_len;
+};
+
 /**
  * BlueALSA PCM object. */
 struct ba_pcm {
@@ -111,6 +149,8 @@ struct ba_pcm {
 	unsigned int transport;
 	/* stream mode */
 	unsigned int mode;
+	/* transport running */
+	dbus_bool_t running;
 
 	/* PCM stream format */
 	dbus_uint16_t format;
@@ -122,7 +162,7 @@ struct ba_pcm {
 	/* device address */
 	bdaddr_t addr;
 	/* transport codec */
-	char codec[16];
+	struct ba_pcm_codec codec;
 	/* approximate PCM delay */
 	dbus_uint16_t delay;
 	/* software volume */
@@ -141,6 +181,13 @@ struct ba_pcm {
 
 };
 
+/**
+ * BlueALSA PCM codecs object. */
+struct ba_pcm_codecs {
+	struct ba_pcm_codec *codecs;
+	size_t codecs_len;
+};
+
 dbus_bool_t bluealsa_dbus_connection_ctx_init(
 		struct ba_dbus_ctx *ctx,
 		const char *ba_service_name,
@@ -181,6 +228,15 @@ dbus_bool_t bluealsa_dbus_get_props(
 void bluealsa_dbus_props_free(
 		struct ba_service_props *props);
 
+dbus_bool_t bluealsa_dbus_get_rfcomm_props(
+		struct ba_dbus_ctx *ctx,
+		const char *rfcomm_path,
+		struct ba_rfcomm_props *props,
+		DBusError *error);
+
+void bluealsa_dbus_rfcomm_props_free(
+		struct ba_rfcomm_props *props);
+
 dbus_bool_t bluealsa_dbus_get_pcms(
 		struct ba_dbus_ctx *ctx,
 		struct ba_pcm **pcms,
@@ -205,6 +261,15 @@ dbus_bool_t bluealsa_dbus_pcm_open(
 const char *bluealsa_dbus_pcm_get_codec_canonical_name(
 		const char *alias);
 
+dbus_bool_t bluealsa_dbus_pcm_get_codecs(
+		struct ba_dbus_ctx *ctx,
+		const char *pcm_path,
+		struct ba_pcm_codecs *codecs,
+		DBusError *error);
+
+void bluealsa_dbus_pcm_codecs_free(
+		struct ba_pcm_codecs *codecs);
+
 dbus_bool_t bluealsa_dbus_pcm_select_codec(
 		struct ba_dbus_ctx *ctx,
 		const char *pcm_path,
diff --git a/src/shared/defs.h b/src/shared/defs.h
index 29e5a4d..c9fc4d0 100644
--- a/src/shared/defs.h
+++ b/src/shared/defs.h
@@ -27,8 +27,12 @@
 #define PTHREAD_CLEANUP(f) ((void (*)(void *))(void (*)(void))(f))
 
 /**
- * Thread routing callback casting wrapper. */
-#define PTHREAD_ROUTINE(f) ((void *(*)(void *))(f))
+ * Thread function callback casting wrapper. */
+#define PTHREAD_FUNC(f) ((void *(*)(void *))(f))
+
+/**
+ * Qsort comparision function casting wrapper. */
+#define QSORT_COMPAR(f) ((int (*)(const void *, const void *))(f))
 
 /**
  * Convert macro value to string. */
diff --git a/src/shared/hex.h b/src/shared/hex.h
index 3b1b171..74d1738 100644
--- a/src/shared/hex.h
+++ b/src/shared/hex.h
@@ -16,7 +16,6 @@
 # include <config.h>
 #endif
 
-#include <stddef.h>
 #include <sys/types.h>
 
 ssize_t bin2hex(const void *bin, char *hex, size_t n);
diff --git a/src/shared/log.c b/src/shared/log.c
index efdc187..0f12f01 100644
--- a/src/shared/log.c
+++ b/src/shared/log.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - log.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "shared/log.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <pthread.h>
 #include <stdarg.h>
@@ -16,8 +17,10 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/time.h>
 #include <syslog.h>
 #include <time.h>
+#include <unistd.h>
 
 #if WITH_LIBUNWIND
 # define UNW_LOCAL_ONLY
@@ -33,33 +36,41 @@
 static char *_ident = NULL;
 /* if true, system logging is enabled */
 static bool _syslog = false;
-/* if true, print logging time */
-static bool _time = BLUEALSA_LOGTIME;
 
-void log_open(const char *ident, bool syslog, bool time) {
+#if DEBUG_TIME
 
-	free(_ident);
-	_ident = strdup(ident);
+/* point "zero" for relative time */
+static struct timespec _ts0;
+
+__attribute__ ((constructor))
+static void init_ts0(void) {
+	gettimestamp(&_ts0);
+}
+
+#endif
+
+void log_open(const char *ident, bool syslog) {
+
+	if (ident != NULL)
+		_ident = strdup(ident);
 
 	if ((_syslog = syslog) == true)
 		openlog(ident, 0, LOG_USER);
 
-	_time = time;
-
 }
 
-static void vlog(int priority, const char *format, va_list ap) {
+static const char *priority2str[] = {
+	[LOG_EMERG] = "X",
+	[LOG_ALERT] = "A",
+	[LOG_CRIT] = "C",
+	[LOG_ERR] = "E",
+	[LOG_WARNING] = "W",
+	[LOG_NOTICE] = "N",
+	[LOG_INFO] = "I",
+	[LOG_DEBUG] = "D",
+};
 
-	static const char *priority2str[] = {
-		[LOG_EMERG] = "X",
-		[LOG_ALERT] = "A",
-		[LOG_CRIT] = "C",
-		[LOG_ERR] = "E",
-		[LOG_WARNING] = "W",
-		[LOG_NOTICE] = "N",
-		[LOG_INFO] = "I",
-		[LOG_DEBUG] = "D",
-	};
+static void vlog(int priority, const char *format, va_list ap) {
 
 	int oldstate;
 
@@ -70,26 +81,41 @@ static void vlog(int priority, const char *format, va_list ap) {
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate);
 
 	if (_syslog) {
+
 		va_list ap_syslog;
 		va_copy(ap_syslog, ap);
 		vsyslog(priority, format, ap_syslog);
 		va_end(ap_syslog);
-	}
 
-	flockfile(stderr);
+	}
+	else {
 
-	if (_ident != NULL)
-		fprintf(stderr, "%s: ", _ident);
-	if (_time) {
+#if DEBUG_TIME
 		struct timespec ts;
 		gettimestamp(&ts);
+		timespecsub(&ts, &_ts0, &ts);
+#endif
+
+		flockfile(stderr);
+
+		if (_ident != NULL)
+			fprintf(stderr, "%s: ", _ident);
+
+#if DEBUG_TIME
 		fprintf(stderr, "%lu.%.9lu: ", (long int)ts.tv_sec, ts.tv_nsec);
-	}
-	fprintf(stderr, "%s: ", priority2str[priority]);
-	vfprintf(stderr, format, ap);
-	fputs("\n", stderr);
+#endif
 
-	funlockfile(stderr);
+#if DEBUG && HAVE_GETTID
+		fprintf(stderr, "[%d] ", gettid());
+#endif
+
+		fprintf(stderr, "%s: ", priority2str[priority]);
+		vfprintf(stderr, format, ap);
+		fputs("\n", stderr);
+
+		funlockfile(stderr);
+
+	}
 
 	pthread_setcancelstate(oldstate, NULL);
 
diff --git a/src/shared/log.h b/src/shared/log.h
index d547393..a8fe550 100644
--- a/src/shared/log.h
+++ b/src/shared/log.h
@@ -22,13 +22,7 @@
 
 #include "shared/defs.h"
 
-#if DEBUG_TIME
-# define BLUEALSA_LOGTIME true
-#else
-# define BLUEALSA_LOGTIME false
-#endif
-
-void log_open(const char *ident, bool syslog, bool time);
+void log_open(const char *ident, bool syslog);
 void log_message(int priority, const char *format, ...) __attribute__ ((format(printf, 2, 3)));
 
 #if DEBUG
diff --git a/src/shared/rt.c b/src/shared/rt.c
index c21b60d..05ec6fd 100644
--- a/src/shared/rt.c
+++ b/src/shared/rt.c
@@ -11,6 +11,7 @@
 #include "shared/rt.h"
 
 #include <stdlib.h>
+#include <sys/time.h>
 
 /**
  * Synchronize time with the sampling rate.
diff --git a/src/shared/rt.h b/src/shared/rt.h
index 24deb81..c70cd5f 100644
--- a/src/shared/rt.h
+++ b/src/shared/rt.h
@@ -17,11 +17,10 @@
 #endif
 
 #include <stdint.h>
-#include <sys/time.h>
 #include <time.h>
 
 #if HAVE_LIBBSD
-# include <bsd/sys/time.h>
+# include <bsd/sys/time.h> /* IWYU pragma: keep */
 #else
 # define timespecadd(ts_a, ts_b, dest) do { \
 		(dest)->tv_sec = (ts_a)->tv_sec + (ts_b)->tv_sec; \
@@ -56,7 +55,7 @@ struct asrsync {
 
 	/* time-stamp from the previous sync */
 	struct timespec ts;
-	/* transfered frames since ts0 */
+	/* transferred frames since ts0 */
 	uint32_t frames;
 
 	/* time spent outside of the sync function */
diff --git a/src/storage.c b/src/storage.c
new file mode 100644
index 0000000..b604e31
--- /dev/null
+++ b/src/storage.c
@@ -0,0 +1,270 @@
+/*
+ * BlueALSA - storage.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include "storage.h"
+
+#include <errno.h>
+#include <pthread.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include <bluetooth/bluetooth.h>
+
+#include <glib.h>
+
+#include "utils.h"
+#include "shared/log.h"
+
+#define BA_STORAGE_KEY_SOFT_VOLUME "SoftVolume"
+#define BA_STORAGE_KEY_VOLUME      "Volume"
+#define BA_STORAGE_KEY_MUTE        "Mute"
+
+struct storage {
+	/* remote BT device address */
+	bdaddr_t addr;
+	/* associated storage file */
+	GKeyFile *keyfile;
+};
+
+static char storage_root_dir[128];
+static pthread_mutex_t storage_mutex = PTHREAD_MUTEX_INITIALIZER;
+static GHashTable *storage_map = NULL;
+
+static struct storage *storage_lookup(const bdaddr_t *addr) {
+	return g_hash_table_lookup(storage_map, addr);
+}
+
+static struct storage *storage_new(const bdaddr_t *addr) {
+
+	struct storage *st;
+	/* return existing storage if it exists */
+	if ((st = storage_lookup(addr)) != NULL)
+		goto final;
+
+	if ((st = malloc(sizeof(*st))) == NULL)
+		goto final;
+
+	bacpy(&st->addr, addr);
+	st->keyfile = g_key_file_new();
+
+	/* Insert a new storage into the map. Please, note that the key is a pointer
+	 * to memory stored in the value structure. This is fine as long as the value
+	 * is not replaced during key insertion. In such case the old (dangling) key
+	 * pointer might/will be used to access the new value! */
+	g_hash_table_insert(storage_map, &st->addr, st);
+
+final:
+	return st;
+}
+
+static void storage_free(struct storage *st) {
+	if (st == NULL)
+		return;
+	g_key_file_free(st->keyfile);
+	free(st);
+}
+
+/**
+ * Initialize BlueALSA persistent storage.
+ *
+ * @param root The root directory for the persistent storage.
+ * @return On success this function returns 0. Otherwise -1 is returned. */
+int storage_init(const char *root) {
+
+	debug("Initializing persistent storage: %s", root);
+	strncpy(storage_root_dir, root, sizeof(storage_root_dir) - 1);
+	if (mkdir(storage_root_dir, S_IRWXU) == -1 && errno != EEXIST)
+		warn("Couldn't create storage directory: %s", strerror(errno));
+
+	if (storage_map == NULL)
+		storage_map = g_hash_table_new_full(g_bdaddr_hash, g_bdaddr_equal,
+				NULL, (GDestroyNotify)storage_free);
+
+	return 0;
+}
+
+/**
+ * Cleanup resources allocated by the persistent storage. */
+void storage_destroy(void) {
+	if (storage_map == NULL)
+		return;
+	g_hash_table_destroy(storage_map);
+	storage_map = NULL;
+}
+
+/**
+ * Load persistent storage file for the given BT device. */
+int storage_device_load(const struct ba_device *d) {
+
+	char addrstr[18];
+	char path[sizeof(storage_root_dir) + sizeof(addrstr)];
+	ba2str(&d->addr, addrstr);
+	snprintf(path, sizeof(path), "%s/%s", storage_root_dir, addrstr);
+	int rv = -1;
+
+	pthread_mutex_lock(&storage_mutex);
+
+	debug("Loading storage: %s", path);
+
+	struct storage *st;
+	if ((st = storage_new(&d->addr)) == NULL)
+		goto final;
+
+	GError *err = NULL;
+	if (!g_key_file_load_from_file(st->keyfile, path, G_KEY_FILE_NONE, &err)) {
+		if (err->code != G_FILE_ERROR_NOENT)
+			warn("Couldn't load storage: %s", err->message);
+		g_error_free(err);
+		goto final;
+	}
+
+	rv = 0;
+
+final:
+	pthread_mutex_unlock(&storage_mutex);
+	return rv;
+}
+
+/**
+ * Save persistent storage file for the given BT device. */
+int storage_device_save(const struct ba_device *d) {
+
+	char addrstr[18];
+	char path[sizeof(storage_root_dir) + sizeof(addrstr)];
+	ba2str(&d->addr, addrstr);
+	snprintf(path, sizeof(path), "%s/%s", storage_root_dir, addrstr);
+	int rv = -1;
+
+	pthread_mutex_lock(&storage_mutex);
+
+	struct storage *st;
+	if ((st = storage_lookup(&d->addr)) == NULL)
+		goto final;
+
+	debug("Saving storage: %s", path);
+
+	GError *err = NULL;
+	if (!g_key_file_save_to_file(st->keyfile, path, &err)) {
+		error("Couldn't save storage: %s", err->message);
+		g_error_free(err);
+		goto final;
+	}
+
+	/* remove the storage from the map */
+	g_hash_table_remove(storage_map, &d->addr);
+
+	rv = 0;
+
+final:
+	pthread_mutex_unlock(&storage_mutex);
+	return rv;
+}
+
+/**
+ * Synchronize PCM with persistent storage.
+ *
+ * @param pcm The PCM structure to synchronize.
+ * @return This function returns 1 or 0 respectively if the storage data was
+ *   synchronized or not. On error -1 is returned. */
+int storage_pcm_data_sync(struct ba_transport_pcm *pcm) {
+
+	const struct ba_transport *t = pcm->t;
+	const struct ba_device *d = t->d;
+	int rv = 0;
+
+	pthread_mutex_lock(&storage_mutex);
+
+	struct storage *st;
+	if ((st = storage_lookup(&d->addr)) == NULL)
+		goto final;
+
+	GKeyFile *keyfile = st->keyfile;
+	const char *group = pcm->ba_dbus_path;
+
+	if (!g_key_file_has_group(keyfile, group))
+		goto final;
+
+	if (g_key_file_has_key(keyfile, group, BA_STORAGE_KEY_SOFT_VOLUME, NULL)) {
+		pcm->soft_volume = g_key_file_get_boolean(keyfile, group,
+				BA_STORAGE_KEY_SOFT_VOLUME, NULL);
+		rv = 1;
+	}
+
+	if (g_key_file_has_key(keyfile, group, BA_STORAGE_KEY_VOLUME, NULL)) {
+		int *list;
+		gsize len = 0;
+		if ((list = g_key_file_get_integer_list(keyfile, group,
+						BA_STORAGE_KEY_VOLUME, &len, NULL)) != NULL &&
+				len == 2) {
+			ba_transport_pcm_volume_set(&pcm->volume[0], &list[0], NULL, NULL);
+			ba_transport_pcm_volume_set(&pcm->volume[1], &list[1], NULL, NULL);
+		}
+		g_free(list);
+		rv = 1;
+	}
+
+	if (g_key_file_has_key(keyfile, group, BA_STORAGE_KEY_MUTE, NULL)) {
+		gboolean *list;
+		gsize len = 0;
+		if ((list = g_key_file_get_boolean_list(keyfile, group,
+						BA_STORAGE_KEY_MUTE, &len, NULL)) != NULL &&
+				len == 2) {
+			const bool mute[2] = { list[0], list[1] };
+			ba_transport_pcm_volume_set(&pcm->volume[0], NULL, &mute[0], NULL);
+			ba_transport_pcm_volume_set(&pcm->volume[1], NULL, &mute[1], NULL);
+		}
+		g_free(list);
+		rv = 1;
+	}
+
+final:
+	pthread_mutex_unlock(&storage_mutex);
+	return rv;
+}
+
+/**
+ * Update persistent storage with PCM data.
+ *
+ * @param pcm The PCM structure for which to update the storage.
+ * @return On success this function returns 0. Otherwise -1 is returned. */
+int storage_pcm_data_update(const struct ba_transport_pcm *pcm) {
+
+	const struct ba_transport *t = pcm->t;
+	const struct ba_device *d = t->d;
+	int rv = -1;
+
+	pthread_mutex_lock(&storage_mutex);
+
+	struct storage *st;
+	if ((st = storage_lookup(&d->addr)) == NULL)
+		if ((st = storage_new(&d->addr)) == NULL)
+			goto final;
+
+	GKeyFile *keyfile = st->keyfile;
+	const char *group = pcm->ba_dbus_path;
+
+	g_key_file_set_boolean(keyfile, group, BA_STORAGE_KEY_SOFT_VOLUME,
+			pcm->soft_volume);
+
+	int volume[2] = { pcm->volume[0].level, pcm->volume[1].level };
+	g_key_file_set_integer_list(keyfile, group, BA_STORAGE_KEY_VOLUME, volume, 2);
+
+	gboolean mute[2] = { pcm->volume[0].soft_mute, pcm->volume[1].soft_mute };
+	g_key_file_set_boolean_list(keyfile, group, BA_STORAGE_KEY_MUTE, mute, 2);
+
+	rv = 0;
+
+final:
+	pthread_mutex_unlock(&storage_mutex);
+	return rv;
+}
diff --git a/src/storage.h b/src/storage.h
new file mode 100644
index 0000000..d4e4ef5
--- /dev/null
+++ b/src/storage.h
@@ -0,0 +1,31 @@
+/*
+ * BlueALSA - storage.h
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#pragma once
+#ifndef BLUEALSA_STORAGE_H_
+#define BLUEALSA_STORAGE_H_
+
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
+#include "ba-device.h"
+#include "ba-transport.h"
+
+int storage_init(const char *root);
+void storage_destroy(void);
+
+int storage_device_load(const struct ba_device *d);
+int storage_device_save(const struct ba_device *d);
+
+int storage_pcm_data_sync(struct ba_transport_pcm *pcm);
+int storage_pcm_data_update(const struct ba_transport_pcm *pcm);
+
+#endif
diff --git a/src/upower.c b/src/upower.c
index 520563e..ebbe6f2 100644
--- a/src/upower.c
+++ b/src/upower.c
@@ -15,7 +15,7 @@
 #include <stdbool.h>
 #include <string.h>
 
-#include <bluetooth/bluetooth.h>
+#include <bluetooth/bluetooth.h> /* IWYU pragma: keep */
 #include <bluetooth/hci.h>
 
 #include <gio/gio.h>
@@ -82,7 +82,7 @@ static void upower_signal_display_device_changed(GDBusConnection *conn, const ch
 				pthread_mutex_lock(&d->transports_mutex);
 				g_hash_table_iter_init(&iter_t, d->transports);
 				while (g_hash_table_iter_next(&iter_t, NULL, (gpointer)&t))
-					if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO &&
+					if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO &&
 							t->sco.rfcomm != NULL)
 						ba_rfcomm_send_signal(t->sco.rfcomm, BA_RFCOMM_SIGNAL_UPDATE_BATTERY);
 				pthread_mutex_unlock(&d->transports_mutex);
diff --git a/src/utils.c b/src/utils.c
index 5557ef6..85335ba 100644
--- a/src/utils.c
+++ b/src/utils.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - utils.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include "utils.h"
+/* IWYU pragma: no_include "config.h" */
 
 #include <ctype.h>
 #include <stdbool.h>
@@ -22,8 +23,6 @@
 # include "ldacBT.h"
 #endif
 
-#include "hfp.h"
-#include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/log.h"
 
@@ -64,97 +63,6 @@ bdaddr_t *g_dbus_bluez_object_path_to_bdaddr(const char *path, bdaddr_t *addr) {
 	return addr;
 }
 
-/**
- * Get BlueZ D-Bus object path for given transport type.
- *
- * @param type Transport type structure.
- * @return This function returns BlueZ D-Bus object path. */
-const char *g_dbus_transport_type_to_bluez_object_path(struct ba_transport_type type) {
-	switch (type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		switch (type.codec) {
-		case A2DP_CODEC_SBC:
-			return "/A2DP/SBC/source";
-#if ENABLE_MPEG
-		case A2DP_CODEC_MPEG12:
-			return "/A2DP/MPEG/source";
-#endif
-#if ENABLE_AAC
-		case A2DP_CODEC_MPEG24:
-			return "/A2DP/AAC/source";
-#endif
-#if ENABLE_APTX
-		case A2DP_CODEC_VENDOR_APTX:
-			return "/A2DP/aptX/source";
-#endif
-#if ENABLE_APTX_HD
-		case A2DP_CODEC_VENDOR_APTX_HD:
-			return "/A2DP/aptXHD/source";
-#endif
-#if ENABLE_FASTSTREAM
-		case A2DP_CODEC_VENDOR_FASTSTREAM:
-			return "/A2DP/FastStream/source";
-#endif
-#if ENABLE_LC3PLUS
-		case A2DP_CODEC_VENDOR_LC3PLUS:
-			return "/A2DP/LC3plus/source";
-#endif
-#if ENABLE_LDAC
-		case A2DP_CODEC_VENDOR_LDAC:
-			return "/A2DP/LDAC/source";
-#endif
-		default:
-			error("Unsupported A2DP codec: %#x", type.codec);
-			g_assert_not_reached();
-		}
-	case BA_TRANSPORT_PROFILE_A2DP_SINK:
-		switch (type.codec) {
-		case A2DP_CODEC_SBC:
-			return "/A2DP/SBC/sink";
-#if ENABLE_MPEG
-		case A2DP_CODEC_MPEG12:
-			return "/A2DP/MPEG/sink";
-#endif
-#if ENABLE_AAC
-		case A2DP_CODEC_MPEG24:
-			return "/A2DP/AAC/sink";
-#endif
-#if ENABLE_APTX
-		case A2DP_CODEC_VENDOR_APTX:
-			return "/A2DP/aptX/sink";
-#endif
-#if ENABLE_APTX_HD
-		case A2DP_CODEC_VENDOR_APTX_HD:
-			return "/A2DP/aptXHD/sink";
-#endif
-#if ENABLE_FASTSTREAM
-		case A2DP_CODEC_VENDOR_FASTSTREAM:
-			return "/A2DP/FastStream/sink";
-#endif
-#if ENABLE_LC3PLUS
-		case A2DP_CODEC_VENDOR_LC3PLUS:
-			return "/A2DP/LC3plus/sink";
-#endif
-#if ENABLE_LDAC
-		case A2DP_CODEC_VENDOR_LDAC:
-			return "/A2DP/LDAC/sink";
-#endif
-		default:
-			error("Unsupported A2DP codec: %#x", type.codec);
-			g_assert_not_reached();
-		}
-	case BA_TRANSPORT_PROFILE_HFP_HF:
-		return "/HFP/HandsFree";
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-		return "/HFP/AudioGateway";
-	case BA_TRANSPORT_PROFILE_HSP_HS:
-		return "/HSP/Headset";
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		return "/HSP/AudioGateway";
-	}
-	return "/";
-}
-
 /**
  * Sanitize D-Bus object path.
  *
@@ -207,110 +115,6 @@ gboolean g_bdaddr_equal(const void *v1, const void *v2) {
 	return bacmp(v1, v2) == 0;
 }
 
-/**
- * Convert BlueALSA transport type into a human-readable string.
- *
- * @param type Transport type structure.
- * @return Human-readable string. */
-const char *ba_transport_type_to_string(struct ba_transport_type type) {
-	switch (type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		switch (type.codec) {
-		case A2DP_CODEC_SBC:
-			return "A2DP Source (SBC)";
-#if ENABLE_MPEG
-		case A2DP_CODEC_MPEG12:
-			return "A2DP Source (MP3)";
-#endif
-#if ENABLE_AAC
-		case A2DP_CODEC_MPEG24:
-			return "A2DP Source (AAC)";
-#endif
-#if ENABLE_APTX
-		case A2DP_CODEC_VENDOR_APTX:
-			return "A2DP Source (aptX)";
-#endif
-#if ENABLE_APTX_HD
-		case A2DP_CODEC_VENDOR_APTX_HD:
-			return "A2DP Source (aptX HD)";
-#endif
-#if ENABLE_FASTSTREAM
-		case A2DP_CODEC_VENDOR_FASTSTREAM:
-			return "A2DP Source (FastStream)";
-#endif
-#if ENABLE_LC3PLUS
-		case A2DP_CODEC_VENDOR_LC3PLUS:
-			return "A2DP Source (LC3plus)";
-#endif
-#if ENABLE_LDAC
-		case A2DP_CODEC_VENDOR_LDAC:
-			return "A2DP Source (LDAC)";
-#endif
-		default:
-			return "A2DP Source";
-		}
-	case BA_TRANSPORT_PROFILE_A2DP_SINK:
-		switch (type.codec) {
-		case A2DP_CODEC_SBC:
-			return "A2DP Sink (SBC)";
-#if ENABLE_MPEG
-		case A2DP_CODEC_MPEG12:
-			return "A2DP Sink (MP3)";
-#endif
-#if ENABLE_AAC
-		case A2DP_CODEC_MPEG24:
-			return "A2DP Sink (AAC)";
-#endif
-#if ENABLE_APTX
-		case A2DP_CODEC_VENDOR_APTX:
-			return "A2DP Sink (aptX)";
-#endif
-#if ENABLE_APTX_HD
-		case A2DP_CODEC_VENDOR_APTX_HD:
-			return "A2DP Sink (aptX HD)";
-#endif
-#if ENABLE_FASTSTREAM
-		case A2DP_CODEC_VENDOR_FASTSTREAM:
-			return "A2DP Sink (FastStream)";
-#endif
-#if ENABLE_LC3PLUS
-		case A2DP_CODEC_VENDOR_LC3PLUS:
-			return "A2DP Sink (LC3plus)";
-#endif
-#if ENABLE_LDAC
-		case A2DP_CODEC_VENDOR_LDAC:
-			return "A2DP Sink (LDAC)";
-#endif
-		default:
-			return "A2DP Sink";
-		}
-	case BA_TRANSPORT_PROFILE_HFP_HF:
-		switch (type.codec) {
-		case HFP_CODEC_CVSD:
-			return "HFP Hands-Free (CVSD)";
-		case HFP_CODEC_MSBC:
-			return "HFP Hands-Free (mSBC)";
-		default:
-			return "HFP Hands-Free";
-		}
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-		switch (type.codec) {
-		case HFP_CODEC_CVSD:
-			return "HFP Audio Gateway (CVSD)";
-		case HFP_CODEC_MSBC:
-			return "HFP Audio Gateway (mSBC)";
-		default:
-			return "HFP Audio Gateway";
-		}
-	case BA_TRANSPORT_PROFILE_HSP_HS:
-		return "HSP Headset";
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		return "HSP Audio Gateway";
-	}
-	debug("Unknown transport type: %#x %#x", type.profile, type.codec);
-	return "N/A";
-}
-
 #if ENABLE_MP3LAME
 /**
  * Get maximum possible bit-rate for the given bit-rate mask.
diff --git a/src/utils.h b/src/utils.h
index cf1a705..f36a2f2 100644
--- a/src/utils.h
+++ b/src/utils.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - utils.h
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -8,6 +8,7 @@
  *
  */
 
+#pragma once
 #ifndef BLUEALSA_UTILS_H_
 #define BLUEALSA_UTILS_H_
 
@@ -22,11 +23,8 @@
 
 #include <glib.h>
 
-#include "ba-transport.h"
-
 int g_dbus_bluez_object_path_to_hci_dev_id(const char *path);
 bdaddr_t *g_dbus_bluez_object_path_to_bdaddr(const char *path, bdaddr_t *addr);
-const char *g_dbus_transport_type_to_bluez_object_path(struct ba_transport_type type);
 
 char *g_variant_sanitize_object_path(char *path);
 bool g_variant_validate_value(GVariant *value, const GVariantType *type,
@@ -35,8 +33,6 @@ bool g_variant_validate_value(GVariant *value, const GVariantType *type,
 unsigned int g_bdaddr_hash(const void *v);
 gboolean g_bdaddr_equal(const void *v1, const void *v2);
 
-const char *ba_transport_type_to_string(struct ba_transport_type type);
-
 #if ENABLE_MP3LAME
 int a2dp_mpeg1_mp3_get_max_bitrate(uint16_t mask);
 const char *lame_encode_strerror(int err);
diff --git a/test/Makefile.am b/test/Makefile.am
index cc39644..83eef53 100644
--- a/test/Makefile.am
+++ b/test/Makefile.am
@@ -1,5 +1,7 @@
 # BlueALSA - Makefile.am
-# Copyright (c) 2016-2022 Arkadiusz Bokowy
+# Copyright (c) 2016-2023 Arkadiusz Bokowy
+
+SUBDIRS = mock
 
 TESTS = \
 	test-a2dp \
@@ -14,7 +16,6 @@ TESTS = \
 	test-utils
 
 check_PROGRAMS = \
-	bluealsa-mock \
 	test-a2dp \
 	test-alsa-ctl \
 	test-alsa-pcm \
@@ -31,6 +32,16 @@ TESTS += test-msbc
 check_PROGRAMS += test-msbc
 endif
 
+if ENABLE_APLAY
+TESTS += test-utils-aplay
+check_PROGRAMS += test-utils-aplay
+endif
+
+if ENABLE_CLI
+TESTS += test-utils-cli
+check_PROGRAMS += test-utils-cli
+endif
+
 check_LTLIBRARIES = \
 	aloader.la
 aloader_la_LDFLAGS = \
@@ -38,33 +49,6 @@ aloader_la_LDFLAGS = \
 	-avoid-version \
 	-shared -module
 
-bluealsa_mock_SOURCES = \
-	../src/shared/a2dp-codecs.c \
-	../src/shared/ffb.c \
-	../src/shared/log.c \
-	../src/shared/rt.c \
-	../src/a2dp.c \
-	../src/a2dp-sbc.c \
-	../src/at.c \
-	../src/audio.c \
-	../src/ba-adapter.c \
-	../src/ba-device.c \
-	../src/ba-rfcomm.c \
-	../src/ba-transport.c \
-	../src/bluealsa-config.c \
-	../src/bluealsa-dbus.c \
-	../src/bluealsa-iface.c \
-	../src/bluealsa-skeleton.c \
-	../src/codec-sbc.c \
-	../src/dbus.c \
-	../src/hci.c \
-	../src/hfp.c \
-	../src/io.c \
-	../src/rtp.c \
-	../src/sco.c \
-	../src/utils.c \
-	bluealsa-mock.c
-
 test_a2dp_SOURCES = \
 	../src/shared/a2dp-codecs.c \
 	../src/shared/ffb.c \
@@ -100,14 +84,19 @@ test_audio_SOURCES = \
 	test-audio.c
 
 test_ba_SOURCES = \
+	../src/shared/ffb.c \
 	../src/shared/log.c \
 	../src/shared/rt.c \
 	../src/audio.c \
 	../src/ba-adapter.c \
 	../src/ba-device.c \
 	../src/bluealsa-config.c \
+	../src/codec-sbc.c \
 	../src/dbus.c \
 	../src/hci.c \
+	../src/io.c \
+	../src/sco.c \
+	../src/storage.c \
 	../src/utils.c \
 	test-ba.c
 
@@ -116,6 +105,7 @@ test_io_SOURCES = \
 	../src/shared/ffb.c \
 	../src/shared/log.c \
 	../src/shared/rt.c \
+	../src/a2dp-sbc.c \
 	../src/audio.c \
 	../src/ba-adapter.c \
 	../src/ba-device.c \
@@ -151,6 +141,7 @@ test_rfcomm_SOURCES = \
 	../src/dbus.c \
 	../src/hci.c \
 	../src/hfp.c \
+	../src/io.c \
 	../src/utils.c \
 	test-rfcomm.c
 
@@ -170,51 +161,53 @@ test_utils_SOURCES = \
 	test-utils.c
 
 if ENABLE_AAC
-bluealsa_mock_SOURCES += ../src/a2dp-aac.c
 test_a2dp_SOURCES += ../src/a2dp-aac.c
+test_io_SOURCES += ../src/a2dp-aac.c
 endif
 
 if ENABLE_APTX
-bluealsa_mock_SOURCES += ../src/a2dp-aptx.c
 test_a2dp_SOURCES += ../src/a2dp-aptx.c
+test_io_SOURCES += ../src/a2dp-aptx.c
 endif
 
 if ENABLE_APTX_HD
-bluealsa_mock_SOURCES += ../src/a2dp-aptx-hd.c
 test_a2dp_SOURCES += ../src/a2dp-aptx-hd.c
+test_io_SOURCES += ../src/a2dp-aptx-hd.c
 endif
 
 if ENABLE_APTX_OR_APTX_HD
-bluealsa_mock_SOURCES += ../src/codec-aptx.c
 test_a2dp_SOURCES += ../src/codec-aptx.c
 test_io_SOURCES += ../src/codec-aptx.c
 endif
 
 if ENABLE_FASTSTREAM
-bluealsa_mock_SOURCES += ../src/a2dp-faststream.c
 test_a2dp_SOURCES += ../src/a2dp-faststream.c
+test_io_SOURCES += ../src/a2dp-faststream.c
 endif
 
 if ENABLE_LC3PLUS
-bluealsa_mock_SOURCES += ../src/a2dp-lc3plus.c
 test_a2dp_SOURCES += ../src/a2dp-lc3plus.c
+test_io_SOURCES += ../src/a2dp-lc3plus.c
 endif
 
 if ENABLE_LDAC
-bluealsa_mock_SOURCES += ../src/a2dp-ldac.c
 test_a2dp_SOURCES += ../src/a2dp-ldac.c
+test_io_SOURCES += ../src/a2dp-ldac.c
 endif
 
 if ENABLE_MPEG
-bluealsa_mock_SOURCES += ../src/a2dp-mpeg.c
 test_a2dp_SOURCES += ../src/a2dp-mpeg.c
+test_io_SOURCES += ../src/a2dp-mpeg.c
 endif
 
 if ENABLE_MSBC
-bluealsa_mock_SOURCES += ../src/codec-msbc.c
+test_ba_SOURCES += ../src/codec-msbc.c
 test_io_SOURCES += ../src/codec-msbc.c
 endif
 
+AM_TESTS_ENVIRONMENT = \
+	export G_DEBUG=fatal-warnings;
+
 AM_CFLAGS = \
 	-I$(top_srcdir)/src \
 	@AAC_CFLAGS@ \
diff --git a/test/aloader.c b/test/aloader.c
index c671abc..1b9f1fd 100644
--- a/test/aloader.c
+++ b/test/aloader.c
@@ -1,6 +1,6 @@
 /*
  * aloader.c
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -15,33 +15,115 @@
 #include <dlfcn.h>
 #include <errno.h>
 #include <libgen.h>
+#include <limits.h>
 #include <stdlib.h>
 #include <string.h>
+#include <stdio.h>
 
-typedef void *(*dlopen_t)(const char *filename, int flags);
+#include <alsa/asoundlib.h>
 
+typedef void *(*dlopen_t)(const char *, int);
 static dlopen_t dlopen_orig = NULL;
-static char program_invocation_path[1024];
 
-void *dlopen(const char *filename, int flags) {
+typedef int (*snd_ctl_open_t)(snd_ctl_t **, const char *, int);
+static snd_ctl_open_t snd_ctl_open_orig = NULL;
+
+typedef int (*snd_pcm_open_t)(snd_pcm_t **, const char *, snd_pcm_stream_t, int);
+static snd_pcm_open_t snd_pcm_open_orig = NULL;
+
+__attribute__ ((constructor))
+static void init(void) {
+	*(void **)(&dlopen_orig) = dlsym(RTLD_NEXT, "dlopen");
+	*(void **)(&snd_ctl_open_orig) = dlsym(RTLD_NEXT, "snd_ctl_open");
+	*(void **)(&snd_pcm_open_orig) = dlsym(RTLD_NEXT, "snd_pcm_open");
+}
+
+/**
+ * Get build root directory. */
+static const char *buildrootdir() {
 
-	if (dlopen_orig == NULL) {
+	static char buffer[1024] = "";
 
-		*(void **)(&dlopen_orig) = dlsym(RTLD_NEXT, __func__);
+	if (buffer[0] == '\0') {
 
 		char *tmp = strdup(program_invocation_name);
-		strcpy(program_invocation_path, dirname(tmp));
+		snprintf(buffer, sizeof(buffer), "%s/..", dirname(tmp));
 		free(tmp);
 
+		if (strstr(buffer, "../utils/aplay") != NULL ||
+				strstr(buffer, "../utils/cli") != NULL)
+			strcat(buffer, "/..");
+
 	}
 
-	char buffer[1024 + 128];
-	strcpy(buffer, program_invocation_path);
+	return buffer;
+}
+
+void *dlopen(const char *filename, int flags) {
+
+	char tmp[PATH_MAX];
+	snprintf(tmp, sizeof(tmp), "%s", buildrootdir());
 
 	if (strstr(filename, "libasound_module_ctl_bluealsa.so") != NULL)
-		filename = strcat(buffer, "/../src/asound/.libs/libasound_module_ctl_bluealsa.so");
+		filename = strcat(tmp, "/src/asound/.libs/libasound_module_ctl_bluealsa.so");
 	if (strstr(filename, "libasound_module_pcm_bluealsa.so") != NULL)
-		filename = strcat(buffer, "/../src/asound/.libs/libasound_module_pcm_bluealsa.so");
+		filename = strcat(tmp, "/src/asound/.libs/libasound_module_pcm_bluealsa.so");
 
 	return dlopen_orig(filename, flags);
 }
+
+int snd_ctl_open(snd_ctl_t **ctl, const char *name, int mode) {
+
+	if (strstr(name, "bluealsa") == NULL)
+		return snd_ctl_open_orig(ctl, name, mode);
+
+	char tmp[PATH_MAX];
+	snprintf(tmp, sizeof(tmp), "%s/src/asound/20-bluealsa.conf", buildrootdir());
+
+	snd_config_t *top = NULL;
+	snd_input_t *input = NULL;
+	int err;
+
+	if ((err = snd_config_update_ref(&top)) < 0)
+		goto fail;
+	if ((err = snd_input_stdio_open(&input, tmp, "r")) != 0)
+		goto fail;
+	if ((err = snd_config_load(top, input)) != 0)
+		goto fail;
+	err = snd_ctl_open_lconf(ctl, name, mode, top);
+
+fail:
+	if (top != NULL)
+		snd_config_unref(top);
+	if (input != NULL)
+		snd_input_close(input);
+	return err;
+}
+
+int snd_pcm_open(snd_pcm_t **pcm, const char *name, snd_pcm_stream_t stream, int mode) {
+
+	if (strstr(name, "bluealsa") == NULL)
+		return snd_pcm_open_orig(pcm, name, stream, mode);
+
+	char tmp[PATH_MAX];
+	snprintf(tmp, sizeof(tmp), "%s/src/asound/20-bluealsa.conf", buildrootdir());
+
+	snd_config_t *top = NULL;
+	snd_input_t *input = NULL;
+	int err;
+
+	if ((err = snd_config_update_ref(&top)) < 0)
+		goto fail;
+	if ((err = snd_input_stdio_open(&input, tmp, "r")) != 0)
+		goto fail;
+	if ((err = snd_config_load(top, input)) != 0)
+		goto fail;
+	err = snd_pcm_open_lconf(pcm, name, stream, mode, top);
+
+fail:
+	if (top != NULL)
+		snd_config_unref(top);
+	if (input != NULL)
+		snd_input_close(input);
+	return err;
+}
diff --git a/test/bluealsa-mock.c b/test/bluealsa-mock.c
deleted file mode 100644
index bdd13fd..0000000
--- a/test/bluealsa-mock.c
+++ /dev/null
@@ -1,557 +0,0 @@
-/*
- * bluealsa-mock.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
- *
- * This file is a part of bluez-alsa.
- *
- * This project is licensed under the terms of the MIT license.
- *
- * This program might be used to debug or check the functionality of ALSA
- * plug-ins. It should work exactly the same as the BlueALSA server.
- *
- */
-
-#if HAVE_CONFIG_H
-# include <config.h>
-#endif
-
-#include <assert.h>
-#include <errno.h>
-#include <getopt.h>
-#include <poll.h>
-#include <pthread.h>
-#include <signal.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/socket.h>
-#include <unistd.h>
-
-#include <gio/gio.h>
-#include <glib-unix.h>
-#include <glib.h>
-
-#include "a2dp.h"
-#include "a2dp-aac.h"
-#include "a2dp-aptx.h"
-#include "a2dp-aptx-hd.h"
-#include "a2dp-faststream.h"
-#include "a2dp-sbc.h"
-#include "ba-adapter.h"
-#include "ba-device.h"
-#include "ba-transport.h"
-#include "bluealsa-config.h"
-#include "bluealsa-dbus.h"
-#include "bluealsa-iface.h"
-#include "bluez.h"
-#include "codec-sbc.h"
-#include "hfp.h"
-#include "io.h"
-#include "utils.h"
-#include "shared/a2dp-codecs.h"
-#include "shared/defs.h"
-#include "shared/log.h"
-#include "shared/rt.h"
-
-#include "inc/dbus.inc"
-#include "inc/sine.inc"
-
-/**
- * Fuzzing sleep duration in milliseconds. */
-#define FUZZING_SLEEP_MS 250
-
-static const a2dp_sbc_t config_sbc_44100_stereo = {
-	.frequency = SBC_SAMPLING_FREQ_44100,
-	.channel_mode = SBC_CHANNEL_MODE_JOINT_STEREO,
-	.block_length = SBC_BLOCK_LENGTH_16,
-	.subbands = SBC_SUBBANDS_8,
-	.allocation_method = SBC_ALLOCATION_LOUDNESS,
-	.min_bitpool = SBC_MIN_BITPOOL,
-	.max_bitpool = SBC_MAX_BITPOOL,
-};
-
-#if ENABLE_APTX
-static const a2dp_aptx_t config_aptx_44100_stereo = {
-	.info = A2DP_SET_VENDOR_ID_CODEC_ID(APTX_VENDOR_ID, APTX_CODEC_ID),
-	.channel_mode = APTX_CHANNEL_MODE_STEREO,
-	.frequency = APTX_SAMPLING_FREQ_44100,
-};
-#endif
-
-#if ENABLE_APTX_HD
-static const a2dp_aptx_hd_t config_aptx_hd_48000_stereo = {
-	.aptx.info = A2DP_SET_VENDOR_ID_CODEC_ID(APTX_HD_VENDOR_ID, APTX_HD_CODEC_ID),
-	.aptx.channel_mode = APTX_CHANNEL_MODE_STEREO,
-	.aptx.frequency = APTX_SAMPLING_FREQ_48000,
-};
-#endif
-
-#if ENABLE_FASTSTREAM
-static const a2dp_faststream_t config_faststream_44100_16000 = {
-	.info = A2DP_SET_VENDOR_ID_CODEC_ID(FASTSTREAM_VENDOR_ID, FASTSTREAM_CODEC_ID),
-	.direction = FASTSTREAM_DIRECTION_MUSIC | FASTSTREAM_DIRECTION_VOICE,
-	.frequency_music = FASTSTREAM_SAMPLING_FREQ_MUSIC_44100,
-	.frequency_voice = FASTSTREAM_SAMPLING_FREQ_VOICE_16000,
-};
-#endif
-
-static struct ba_adapter *a = NULL;
-static char service[32] = BLUEALSA_SERVICE;
-static GMutex timeout_mutex = { 0 };
-static GCond timeout_cond = { 0 };
-static int timeout = 5;
-static bool a2dp_extra_codecs = false;
-static bool a2dp_source = false;
-static bool a2dp_sink = false;
-static bool sco_hfp = false;
-static bool sco_hsp = false;
-static bool dump_output = false;
-static bool fuzzing = false;
-
-static gboolean main_loop_exit_handler(void *userdata) {
-	g_main_loop_quit((GMainLoop *)userdata);
-	return G_SOURCE_REMOVE;
-}
-
-static gboolean main_loop_timeout_handler(void *userdata) {
-	g_mutex_lock(&timeout_mutex);
-	*((int *)userdata) = -1;
-	g_cond_signal(&timeout_cond);
-	g_mutex_unlock(&timeout_mutex);
-	return G_SOURCE_REMOVE;
-}
-
-static volatile sig_atomic_t sigusr1_count = 0;
-static volatile sig_atomic_t sigusr2_count = 0;
-static void mock_sigusr_handler(int sig) {
-	switch (sig) {
-	case SIGUSR1:
-		sigusr1_count++;
-		debug("Dispatching SIGUSR1: %d", sigusr1_count);
-		break;
-	case SIGUSR2:
-		sigusr2_count++;
-		debug("Dispatching SIGUSR2: %d", sigusr2_count);
-		break;
-	default:
-		error("Unsupported signal: %d", sig);
-	}
-}
-
-bool bluez_a2dp_set_configuration(const char *current_dbus_sep_path,
-		const struct a2dp_sep *sep, GError **error) {
-	debug("%s: %s", __func__, current_dbus_sep_path); (void)sep;
-	*error = g_error_new(G_DBUS_ERROR, G_DBUS_ERROR_NOT_SUPPORTED, "Not supported");
-	return false;
-}
-
-void bluez_battery_provider_update(struct ba_device *device) {
-	debug("%s: %p", __func__, device);
-}
-
-static void *mock_a2dp_dec(struct ba_transport_thread *th) {
-
-	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
-
-	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *t_a2dp_pcm = &t->a2dp.pcm;
-
-	/* use back-channel PCM for bidirectional codecs */
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE)
-		t_a2dp_pcm = &t->a2dp.pcm_bc;
-
-	const unsigned int channels = t_a2dp_pcm->channels;
-	const unsigned int samplerate = t_a2dp_pcm->sampling;
-	struct pollfd fds[1] = {{ th->pipe[0], POLLIN, 0 }};
-	struct asrsync asrs = { .frames = 0 };
-	int16_t buffer[1024 * 2];
-	int x = 0;
-
-	debug_transport_thread_loop(th, "START");
-	ba_transport_thread_set_state_running(th);
-
-	while (sigusr1_count == 0) {
-
-		int timout = 0;
-		if (!ba_transport_pcm_is_active(t_a2dp_pcm))
-			timout = -1;
-
-		if (poll(fds, ARRAYSIZE(fds), timout) == 1 &&
-				fds[0].revents & POLLIN) {
-			/* dispatch incoming event */
-			enum ba_transport_thread_signal signal;
-			ba_transport_thread_signal_recv(th, &signal);
-			switch (signal) {
-			case BA_TRANSPORT_THREAD_SIGNAL_PCM_OPEN:
-			case BA_TRANSPORT_THREAD_SIGNAL_PCM_RESUME:
-				asrs.frames = 0;
-				continue;
-			default:
-				continue;
-			}
-		}
-
-		fprintf(stderr, ".");
-
-		if (asrs.frames == 0)
-			asrsync_init(&asrs, samplerate);
-
-		const size_t samples = ARRAYSIZE(buffer);
-		const size_t frames = samples / channels;
-		x = snd_pcm_sine_s16_2le(buffer, frames, channels, x, 1.0 / 128);
-
-		io_pcm_scale(t_a2dp_pcm, buffer, samples);
-		if (io_pcm_write(t_a2dp_pcm, buffer, samples) == -1)
-			error("FIFO write error: %s", strerror(errno));
-
-		/* maintain constant speed */
-		asrsync_sync(&asrs, frames);
-
-	}
-
-	ba_transport_thread_set_state_stopping(th);
-	pthread_cleanup_pop(1);
-	return NULL;
-}
-
-static void *mock_bt_dump_thread(void *userdata) {
-
-	int bt_fd = GPOINTER_TO_INT(userdata);
-	FILE *f_output = NULL;
-	uint8_t buffer[1024];
-	ssize_t len;
-
-	if (dump_output)
-		f_output = fopen("bluealsa-mock.dump", "w");
-
-	while ((len = read(bt_fd, buffer, sizeof(buffer))) > 0) {
-
-		if (!dump_output)
-			continue;
-
-		for (ssize_t i = 0; i < len;i++)
-			fprintf(f_output, "%02x", buffer[i]);
-		fprintf(f_output, "\n");
-
-	}
-
-	if (f_output != NULL)
-		fclose(f_output);
-	close(bt_fd);
-	return NULL;
-}
-
-static void mock_transport_start(struct ba_transport *t, int bt_fd) {
-
-	if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SOURCE) {
-		g_thread_unref(g_thread_new(NULL, mock_bt_dump_thread, GINT_TO_POINTER(bt_fd)));
-		assert(ba_transport_start(t) == 0);
-	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_A2DP_SINK) {
-		switch (t->type.codec) {
-		case A2DP_CODEC_SBC:
-			assert(ba_transport_thread_create(&t->thread_dec, mock_a2dp_dec, "ba-a2dp-sbc", true) == 0);
-			break;
-#if ENABLE_APTX
-		case A2DP_CODEC_VENDOR_APTX:
-			assert(ba_transport_thread_create(&t->thread_dec, mock_a2dp_dec, "ba-a2dp-aptx", true) == 0);
-			break;
-#endif
-#if ENABLE_APTX_HD
-		case A2DP_CODEC_VENDOR_APTX_HD:
-			assert(ba_transport_thread_create(&t->thread_dec, mock_a2dp_dec, "ba-a2dp-aptx-hd", true) == 0);
-			break;
-#endif
-		}
-	}
-	else if (t->type.profile & BA_TRANSPORT_PROFILE_MASK_SCO) {
-		assert(ba_transport_start(t) == 0);
-	}
-
-}
-
-static int mock_transport_acquire(struct ba_transport *t) {
-
-	int bt_fds[2];
-	assert(socketpair(AF_UNIX, SOCK_SEQPACKET, 0, bt_fds) == 0);
-
-	t->bt_fd = bt_fds[0];
-	t->mtu_read = 256;
-	t->mtu_write = 256;
-
-	debug("New transport: %d (MTU: R:%zu W:%zu)", t->bt_fd, t->mtu_read, t->mtu_write);
-
-	pthread_mutex_unlock(&t->bt_fd_mtx);
-	mock_transport_start(t, bt_fds[1]);
-	pthread_mutex_lock(&t->bt_fd_mtx);
-
-	return 0;
-}
-
-static struct ba_device *mock_device_new(struct ba_adapter *a, const char *btmac) {
-	struct ba_device *d;
-	bdaddr_t addr;
-	str2ba(btmac, &addr);
-	if ((d = ba_device_lookup(a, &addr)) == NULL)
-		d = ba_device_new(a, &addr);
-	return d;
-}
-
-static struct ba_transport *mock_transport_new_a2dp(const char *device_btmac,
-		uint16_t profile, const struct a2dp_codec *codec, const void *configuration) {
-	if (fuzzing)
-		usleep(FUZZING_SLEEP_MS * 1000);
-	struct ba_device *d = mock_device_new(a, device_btmac);
-	struct ba_transport_type type = { profile, codec->codec_id };
-	const char *path = g_dbus_transport_type_to_bluez_object_path(type);
-	struct ba_transport *t = ba_transport_new_a2dp(d, type, ":test", path, codec, configuration);
-	fprintf(stderr, "BLUEALSA_PCM_READY=A2DP:%s:%s\n",
-			device_btmac, a2dp_codecs_codec_id_to_string(t->type.codec));
-	t->acquire = mock_transport_acquire;
-	if (type.profile == BA_TRANSPORT_PROFILE_A2DP_SINK)
-		assert(ba_transport_acquire(t) == 0);
-	ba_device_unref(d);
-	return t;
-}
-
-static struct ba_transport *mock_transport_new_sco(const char *device_btmac,
-		uint16_t profile, uint16_t codec) {
-	if (fuzzing)
-		usleep(FUZZING_SLEEP_MS * 1000);
-	struct ba_device *d = mock_device_new(a, device_btmac);
-	struct ba_transport_type type = { profile, codec };
-	const char *path = g_dbus_transport_type_to_bluez_object_path(type);
-	struct ba_transport *t = ba_transport_new_sco(d, type, ":test", path, -1);
-	fprintf(stderr, "BLUEALSA_PCM_READY=SCO:%s:%s\n",
-			device_btmac, hfp_codec_id_to_string(t->type.codec));
-	t->acquire = mock_transport_acquire;
-	ba_device_unref(d);
-	return t;
-}
-
-void *mock_service_thread(void *userdata) {
-
-	GMainLoop *loop = userdata;
-	GPtrArray *tt = g_ptr_array_new();
-	size_t i;
-
-	if (a2dp_source) {
-
-		g_ptr_array_add(tt, mock_transport_new_a2dp("12:34:56:78:9A:BC",
-					BA_TRANSPORT_PROFILE_A2DP_SOURCE, &a2dp_sbc_source,
-					&config_sbc_44100_stereo));
-
-		g_ptr_array_add(tt, mock_transport_new_a2dp("23:45:67:89:AB:CD",
-					BA_TRANSPORT_PROFILE_A2DP_SOURCE, &a2dp_sbc_source,
-					&config_sbc_44100_stereo));
-
-		if (a2dp_extra_codecs) {
-
-#if ENABLE_APTX
-			g_ptr_array_add(tt, mock_transport_new_a2dp("AA:BB:CC:DD:00:00",
-						BA_TRANSPORT_PROFILE_A2DP_SOURCE, &a2dp_aptx_source,
-						&config_aptx_44100_stereo));
-#endif
-
-#if ENABLE_APTX_HD
-			g_ptr_array_add(tt, mock_transport_new_a2dp("AA:BB:CC:DD:88:DD",
-						BA_TRANSPORT_PROFILE_A2DP_SOURCE, &a2dp_aptx_hd_source,
-						&config_aptx_hd_48000_stereo));
-#endif
-
-#if ENABLE_FASTSTREAM
-			g_ptr_array_add(tt, mock_transport_new_a2dp("FF:AA:55:77:00:00",
-						BA_TRANSPORT_PROFILE_A2DP_SOURCE, &a2dp_faststream_source,
-						&config_faststream_44100_16000));
-#endif
-
-		}
-
-	}
-
-	if (a2dp_sink) {
-
-		g_ptr_array_add(tt, mock_transport_new_a2dp("12:34:56:78:9A:BC",
-						BA_TRANSPORT_PROFILE_A2DP_SINK, &a2dp_sbc_sink,
-						&config_sbc_44100_stereo));
-
-		g_ptr_array_add(tt, mock_transport_new_a2dp("23:45:67:89:AB:CD",
-						BA_TRANSPORT_PROFILE_A2DP_SINK, &a2dp_sbc_sink,
-						&config_sbc_44100_stereo));
-
-		if (a2dp_extra_codecs) {
-
-#if ENABLE_APTX
-			g_ptr_array_add(tt, mock_transport_new_a2dp("AA:BB:CC:DD:00:00",
-						BA_TRANSPORT_PROFILE_A2DP_SINK, &a2dp_aptx_sink,
-						&config_aptx_44100_stereo));
-#endif
-
-#if ENABLE_APTX_HD
-			g_ptr_array_add(tt, mock_transport_new_a2dp("AA:BB:CC:DD:88:DD",
-						BA_TRANSPORT_PROFILE_A2DP_SINK, &a2dp_aptx_hd_sink,
-						&config_aptx_hd_48000_stereo));
-#endif
-
-		}
-
-	}
-
-	if (sco_hfp) {
-
-		struct ba_transport *t;
-		g_ptr_array_add(tt, t = mock_transport_new_sco("12:34:56:78:9A:BC",
-					BA_TRANSPORT_PROFILE_HFP_AG, HFP_CODEC_UNDEFINED));
-
-		if (fuzzing) {
-			t->type.codec = HFP_CODEC_CVSD;
-			bluealsa_dbus_pcm_update(&t->sco.spk_pcm,
-					BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
-			bluealsa_dbus_pcm_update(&t->sco.mic_pcm,
-					BA_DBUS_PCM_UPDATE_SAMPLING | BA_DBUS_PCM_UPDATE_CODEC);
-		}
-
-	}
-
-	if (sco_hsp) {
-		g_ptr_array_add(tt, mock_transport_new_sco("23:45:67:89:AB:CD",
-					BA_TRANSPORT_PROFILE_HSP_AG, HFP_CODEC_UNDEFINED));
-	}
-
-	g_mutex_lock(&timeout_mutex);
-	while (timeout > 0)
-		g_cond_wait(&timeout_cond, &timeout_mutex);
-	g_mutex_unlock(&timeout_mutex);
-
-	for (i = 0; i < tt->len; i++) {
-		ba_transport_destroy(tt->pdata[i]);
-		if (fuzzing && i % 2 == 0)
-			usleep(FUZZING_SLEEP_MS * 1000);
-	}
-
-	if (fuzzing)
-		usleep(FUZZING_SLEEP_MS * 1000);
-
-	g_ptr_array_free(tt, TRUE);
-	g_main_loop_quit(loop);
-	return NULL;
-}
-
-static void dbus_name_acquired(GDBusConnection *conn, const char *name, void *userdata) {
-	(void)conn;
-	GMainLoop *loop = userdata;
-
-	fprintf(stderr, "BLUEALSA_DBUS_SERVICE_NAME=%s\n", name);
-
-	/* do not generate lots of data */
-	config.sbc_quality = SBC_QUALITY_LOW;
-
-	/* initialize codec capabilities */
-	a2dp_codecs_init();
-
-	/* emulate dummy test HCI device */
-	assert((a = ba_adapter_new(0)) != NULL);
-
-	/* run actual BlueALSA mock thread */
-	g_thread_new(NULL, mock_service_thread, loop);
-
-}
-
-int main(int argc, char *argv[]) {
-
-	int opt;
-	const char *opts = "hb:t:F";
-	struct option longopts[] = {
-		{ "help", no_argument, NULL, 'h' },
-		{ "dbus", required_argument, NULL, 'B' },
-		{ "timeout", required_argument, NULL, 't' },
-		{ "a2dp-extra-codecs", no_argument, NULL, 1 },
-		{ "a2dp-source", no_argument, NULL, 2 },
-		{ "a2dp-sink", no_argument, NULL, 3 },
-		{ "sco-hfp", no_argument, NULL, 4 },
-		{ "sco-hsp", no_argument, NULL, 5 },
-		{ "dump-output", no_argument, NULL, 6 },
-		{ "fuzzing", no_argument, NULL, 7 },
-		{ 0, 0, 0, 0 },
-	};
-
-	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
-		switch (opt) {
-		case 'h':
-			printf("Usage:\n"
-					"  %s [OPTION]...\n"
-					"\nOptions:\n"
-					"  -h, --help\t\tprint this help and exit\n"
-					"  -B, --dbus=NAME\tBlueALSA service name suffix\n"
-					"  -t, --timeout=SEC\tmock server exit timeout\n"
-					"  --a2dp-extra-codecs\tregister non-mandatory A2DP codecs\n"
-					"  --a2dp-source\t\tregister source A2DP endpoints\n"
-					"  --a2dp-sink\t\tregister sink A2DP endpoints\n"
-					"  --sco-hfp\t\tregister HFP endpoints\n"
-					"  --sco-hsp\t\tregister HSP endpoints\n"
-					"  --dump-output\t\tdump Bluetooth transport data\n"
-					"  --fuzzing\t\tmock human actions with timings\n",
-					argv[0]);
-			return EXIT_SUCCESS;
-		case 'B' /* --dbus=NAME */ :
-			snprintf(service, sizeof(service), BLUEALSA_SERVICE ".%s", optarg);
-			break;
-		case 't' /* --timeout=SEC */ :
-			timeout = atoi(optarg);
-			break;
-		case 1 /* --a2dp-extra-codecs */ :
-			a2dp_extra_codecs = true;
-			break;
-		case 2 /* -a2dp-source */ :
-			a2dp_source = true;
-			break;
-		case 3 /* -a2dp-sink */ :
-			a2dp_sink = true;
-			break;
-		case 4 /* --sco-hfp */ :
-			sco_hfp = true;
-			break;
-		case 5 /* --sco-hsp */ :
-			sco_hsp = true;
-			break;
-		case 6 /* --dump-output */ :
-			dump_output = true;
-			break;
-		case 7 /* --fuzzing */ :
-			fuzzing = true;
-			break;
-		default:
-			fprintf(stderr, "Try '%s --help' for more information.\n", argv[0]);
-			return EXIT_FAILURE;
-		}
-
-	log_open(argv[0], false, true);
-	assert(bluealsa_config_init() == 0);
-	assert((config.dbus = g_test_dbus_connection_new_sync(NULL)) != NULL);
-
-	/* receive EPIPE error code */
-	struct sigaction sigact = { .sa_handler = SIG_IGN };
-	sigaction(SIGPIPE, &sigact, NULL);
-
-	/* register USR signals handler */
-	sigact.sa_handler = mock_sigusr_handler;
-	sigaction(SIGUSR1, &sigact, NULL);
-	sigaction(SIGUSR2, &sigact, NULL);
-
-	/* main loop with graceful termination handlers */
-	GMainLoop *loop = g_main_loop_new(NULL, FALSE);
-	g_timeout_add_seconds(timeout, main_loop_timeout_handler, &timeout);
-	g_unix_signal_add(SIGINT, main_loop_exit_handler, loop);
-	g_unix_signal_add(SIGTERM, main_loop_exit_handler, loop);
-
-	bluealsa_dbus_register();
-	assert(g_bus_own_name_on_connection(config.dbus, service,
-				G_BUS_NAME_OWNER_FLAGS_NONE, dbus_name_acquired, NULL, loop, NULL) != 0);
-
-	g_main_loop_run(loop);
-
-	ba_adapter_destroy(a);
-	return EXIT_SUCCESS;
-}
diff --git a/test/inc/btd.inc b/test/inc/btd.inc
index 9af618a..141fa96 100644
--- a/test/inc/btd.inc
+++ b/test/inc/btd.inc
@@ -25,6 +25,7 @@
 
 #include "a2dp.h"
 #include "ba-transport.h"
+#include "hfp.h"
 #include "io.h"
 #include "utils.h"
 #include "shared/a2dp-codecs.h"
@@ -89,7 +90,7 @@ void bt_dump_close(struct bt_dump *btd) {
  * Create BT dump file. */
 struct bt_dump *bt_dump_create(
 		const char *path,
-		const struct ba_transport *t) {
+		struct ba_transport *t) {
 
 	struct bt_dump *btd;
 	if ((btd = calloc(1, sizeof(*btd))) == NULL)
@@ -102,7 +103,9 @@ struct bt_dump *bt_dump_create(
 	const void *a2dp_configuration = NULL;
 	size_t a2dp_configuration_size = 0;
 
-	switch (t->type.profile) {
+	switch (t->profile) {
+	case BA_TRANSPORT_PROFILE_NONE:
+		break;
 	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
 		a2dp_configuration = &t->a2dp.configuration;
 		a2dp_configuration_size = t->a2dp.codec->capabilities_size;
@@ -124,8 +127,8 @@ struct bt_dump *bt_dump_create(
 	unsigned int mode_ = mode;
 	fprintf(btd->file, "BA.dump-%1x.%1x.", BT_DUMP_CURRENT_VERSION, mode_);
 
-	btd->transport_codec_id = t->type.codec;
-	uint16_t id = htobe16(t->type.codec);
+	btd->transport_codec_id = ba_transport_get_codec(t);
+	uint16_t id = htobe16(btd->transport_codec_id);
 	if (bt_dump_write(btd, &id, sizeof(id)) == -1)
 		goto fail;
 
@@ -193,27 +196,37 @@ fail:
 	return NULL;
 }
 
-static const char *transport_type_to_fname(struct ba_transport_type type) {
+static const char *transport_to_fname(struct ba_transport *t) {
 
-	static char buffer[64];
-	char prev = '-';
-	size_t i = 0;
-
-	const char *ptr = ba_transport_type_to_string(type);
-	for (; *ptr != '\0'; ptr++) {
-		char c = *ptr;
-		if (!isalnum(c))
-			c = '-';
-		buffer[i++] = c;
-		if (c == '-' && prev == '-')
-			i--;
-		prev = c;
+	const char *profile = NULL;
+	const char *codec = NULL;
+	switch (t->profile) {
+	case BA_TRANSPORT_PROFILE_NONE:
+		return "none";
+	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
+		profile = "a2dp-source";
+		codec = a2dp_codecs_codec_id_to_string(ba_transport_get_codec(t));
+		break;
+	case BA_TRANSPORT_PROFILE_A2DP_SINK:
+		profile = "a2dp-sink";
+		codec = a2dp_codecs_codec_id_to_string(ba_transport_get_codec(t));
+		break;
+	case BA_TRANSPORT_PROFILE_HFP_AG:
+		profile = "hfp-ag";
+		codec = hfp_codec_id_to_string(ba_transport_get_codec(t));
+		break;
+	case BA_TRANSPORT_PROFILE_HFP_HF:
+		profile = "hfp-hf";
+		codec = hfp_codec_id_to_string(ba_transport_get_codec(t));
+		break;
+	case BA_TRANSPORT_PROFILE_HSP_AG:
+		return "hsp-ag-cvsd";
+	case BA_TRANSPORT_PROFILE_HSP_HS:
+		return "hsp-hs-cvsd";
 	}
 
-	if (prev == '-')
-		i--;
-	buffer[i] = '\0';
-
+	static char buffer[64];
+	snprintf(buffer, sizeof(buffer), "%s-%s", profile, codec);
 	return buffer;
 }
 
@@ -229,10 +242,10 @@ void *bt_dump_io_thread(struct ba_transport_thread *th) {
 	ffb_t bt = { 0 };
 
 	struct bt_dump *btd = NULL;
-	char fname[128];
+	char fname[256];
 
 	snprintf(fname, sizeof(fname), "/tmp/bluealsa-%s.btd",
-			transport_type_to_fname(t->type));
+			transport_to_fname(t));
 
 	debug("Creating BT dump file: %s", fname);
 	if ((btd = bt_dump_create(fname, t)) == NULL) {
@@ -249,7 +262,7 @@ void *bt_dump_io_thread(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	for (ba_transport_thread_set_state_running(th);;) {
+	for (ba_transport_thread_state_set_running(th);;) {
 
 		ssize_t len = ffb_blen_in(&bt);
 		if ((len = io_poll_and_read_bt(&io, th, bt.data, len)) <= 0) {
@@ -265,8 +278,6 @@ void *bt_dump_io_thread(struct ba_transport_thread *th) {
 
 fail:
 	debug_transport_thread_loop(th, "EXIT");
-	ba_transport_thread_set_state_stopping(th);
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 fail_ffb:
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
diff --git a/test/inc/check.inc b/test/inc/check.inc
new file mode 100644
index 0000000..ed2b331
--- /dev/null
+++ b/test/inc/check.inc
@@ -0,0 +1,28 @@
+/*
+ * check.inc
+ * vim: ft=c
+ *
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#pragma once
+
+#include <stdio.h>
+
+#include <check.h>
+
+#include "shared/defs.h"
+
+/**
+ * Wrapper for START_TEST() macro with additional print with test name. */
+#define CK_START_TEST(name) START_TEST(name) { \
+	fprintf(stderr, "\nTEST: " __FILE__ ":" STRINGIZE(__LINE__) ": " STRINGIZE(name) "\n");
+
+/**
+ * Wrapper for END_TEST macro. */
+#define CK_END_TEST } END_TEST
diff --git a/test/inc/dbus.inc b/test/inc/dbus.inc
deleted file mode 100644
index 332f0a5..0000000
--- a/test/inc/dbus.inc
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * dbus.inc
- * vim: ft=c
- *
- * Copyright (c) 2016-2019 Arkadiusz Bokowy
- *
- * This file is a part of bluez-alsa.
- *
- * This project is licensed under the terms of the MIT license.
- *
- */
-
-#include <stdio.h>
-#include <gio/gio.h>
-#include <glib.h>
-
-/**
- * Open new testing D-Bus connection. */
-GDBusConnection *g_test_dbus_connection_new_sync(GError **error) {
-	GTestDBus *dbus;
-	g_test_dbus_up(dbus = g_test_dbus_new(G_TEST_DBUS_NONE));
-	fprintf(stderr, "DBUS_SYSTEM_BUS_ADDRESS=%s\n", g_test_dbus_get_bus_address(dbus));
-	return g_dbus_connection_new_for_address_sync(
-			g_test_dbus_get_bus_address(dbus),
-			G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
-			G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION,
-			NULL, NULL, error);
-}
diff --git a/test/inc/server.inc b/test/inc/mock.inc
similarity index 59%
rename from test/inc/server.inc
rename to test/inc/mock.inc
index a95c2ad..fe2dcd5 100644
--- a/test/inc/server.inc
+++ b/test/inc/mock.inc
@@ -1,8 +1,8 @@
 /*
- * server.inc
+ * mock.inc
  * vim: ft=c
  *
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -10,14 +10,21 @@
  *
  */
 
+#pragma once
+#ifndef BLUEALSA_TEST_INC_MOCK_H_
+#define BLUEALSA_TEST_INC_MOCK_H_
+
 #include <ctype.h>
 #include <pthread.h>
+#include <stdarg.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
 
+#include "spawn.inc"
+
 struct spawn_bluealsa_data {
 
 	/* stderr from the BlueALSA server */
@@ -45,7 +52,7 @@ static char *strtrim(char *str) {
 	return str;
 }
 
-static void *spawn_bluealsa_server_stderr_proxy(void *userdata) {
+static void *spawn_bluealsa_mock_stderr_proxy(void *userdata) {
 
 	struct spawn_bluealsa_data *data = userdata;
 	char buffer[512];
@@ -54,122 +61,125 @@ static void *spawn_bluealsa_server_stderr_proxy(void *userdata) {
 	while (fgets(buffer, sizeof(buffer), data->f_stderr) != NULL) {
 		fputs(buffer, stderr);
 
+		bool updated = false;
+
 		pthread_mutex_lock(&data->data_mtx);
 
 		if ((tmp = strstr(buffer, "DBUS_SYSTEM_BUS_ADDRESS=")) != NULL) {
 			data->dbus_bus_address = strtrim(strdup(tmp));
-			pthread_cond_signal(&data->data_updated);
+			updated = true;
 		}
 		else if ((tmp = strstr(buffer, "BLUEALSA_DBUS_SERVICE_NAME=")) != NULL) {
 			data->acquired_service_name = strtrim(strdup(&tmp[27]));
-			pthread_cond_signal(&data->data_updated);
+			updated = true;
 		}
 		else if (strstr(buffer, "BLUEALSA_PCM_READY=A2DP:") != NULL) {
 			data->ready_count_a2dp++;
-			pthread_cond_signal(&data->data_updated);
+			updated = true;
 		}
 		else if (strstr(buffer, "BLUEALSA_PCM_READY=SCO:") != NULL) {
 			data->ready_count_sco++;
-			pthread_cond_signal(&data->data_updated);
+			updated = true;
 		}
 
 		pthread_mutex_unlock(&data->data_mtx);
 
+		if (updated)
+			pthread_cond_signal(&data->data_updated);
+
 	}
 
 	pthread_mutex_destroy(&data->data_mtx);
 	pthread_cond_destroy(&data->data_updated);
 	free(data->dbus_bus_address);
+	free(data->acquired_service_name);
 	fclose(data->f_stderr);
 	free(data);
 	return NULL;
 }
 
-/* path with the bluealsa-mock binary */
-char *bluealsa_mock_path = ".";
+/**
+ * Full path to the bluealsa-mock executable. */
+char bluealsa_mock_path[256] = "bluealsa-mock";
 
 /**
- * Spawn bluealsa server mock.
+ * Spawn BlueALSA mock service.
  *
+ * @param process Pointer to the structure which will be filled with spawned
+ *   process information, i.e. PID, stdout and stderr file descriptors.
  * @param service BlueALSA D-Bus service name.
- * @param timeout Timeout passed to the bluealsa-mock.
  * @param wait_for_ready Block until PCMs are ready.
- * @param fuzzing Enable fuzzing - delayed startup.
- * @param a2dp_source Start A2DP source.
- * @param a2dp_sink Start A2DP sink.
- * @param sco_hfp Start HFP audio gateway.
- * @param sco_hsp Start HSP audio gateway.
- * @return PID of the bluealsa server mock. */
-pid_t spawn_bluealsa_server(const char *service, unsigned int timeout,
-		bool wait_for_ready, bool fuzzing, bool a2dp_source, bool a2dp_sink,
-		bool sco_hfp, bool sco_hsp) {
+ * @param ... Additional arguments to be passed to the bluealsa-mock. The list
+ *   shall be terminated by NULL.
+ * @return On success this function returns 0. Otherwise -1 is returned and
+ *  errno is set appropriately. */
+int spawn_bluealsa_mock(struct spawn_process *sp, const char *service,
+		bool wait_for_ready, ...) {
+
+	/* bus address of D-Bus mock server */
+	static char dbus_bus_address[256];
+
+	unsigned int count_a2dp = 0;
+	unsigned int count_sco = 0;
 
 	char arg_service[32] = "";
 	if (service != NULL)
 		sprintf(arg_service, "--dbus=%s", service);
 
-	char arg_timeout[16];
-	sprintf(arg_timeout, "--timeout=%d", timeout);
-
-	char *argv[] = {
-		"bluealsa-mock",
+	size_t n = 2;
+	char * argv[32] = {
+		bluealsa_mock_path,
 		arg_service,
-		arg_timeout,
-		a2dp_source ? "--a2dp-source" : "",
-		a2dp_sink ? "--a2dp-sink" : "",
-		sco_hfp ? "--sco-hfp" : "",
-		sco_hsp ? "--sco-hsp" : "",
-		fuzzing ? "--fuzzing" : "",
-		NULL,
 	};
 
-	char path[256];
-	sprintf(path, "%s/bluealsa-mock", bluealsa_mock_path);
+	va_list ap;
+	va_start(ap, wait_for_ready);
 
-	int fds[2];
-	if (pipe(fds) == -1)
-		return -1;
+	char *arg;
+	while ((arg = va_arg(ap, char *)) != NULL) {
 
-	pid_t pid;
-	if ((pid = fork()) == 0) {
-		dup2(fds[1], 2);
-		close(fds[0]);
-		close(fds[1]);
-		execv(path, argv);
-	}
+		argv[n++] = arg;
+		argv[n] = NULL;
 
-	close(fds[1]);
+		if (strcmp(arg, "--profile=a2dp-source") == 0)
+			count_a2dp += 2;
+		if (strcmp(arg, "--profile=a2dp-sink") == 0)
+			count_a2dp += 2;
+		if (strcmp(arg, "--profile=hfp-ag") == 0)
+			count_sco += 1;
+		if (strcmp(arg, "--profile=hsp-ag") == 0)
+			count_sco += 1;
 
-	struct spawn_bluealsa_data *data;
-	unsigned int count_a2dp = 0;
-	unsigned int count_sco = 0;
+	}
 
-	if (a2dp_source)
-		count_a2dp += 2;
-	if (a2dp_sink)
-		count_a2dp += 2;
-	if (sco_hfp)
-		count_sco += 1;
-	if (sco_hsp)
-		count_sco += 1;
+	va_end(ap);
 
+	if (spawn(sp, argv, NULL, SPAWN_FLAG_REDIRECT_STDERR) == -1)
+		return -1;
+
+	struct spawn_bluealsa_data *data;
 	if ((data = calloc(1, sizeof(*data))) == NULL)
 		return -1;
 
 	pthread_mutex_init(&data->data_mtx, NULL);
 	pthread_cond_init(&data->data_updated, NULL);
-	if ((data->f_stderr = fdopen(fds[0], "r")) == NULL)
-		return -1;
+
+	data->f_stderr = sp->f_stderr;
+	sp->f_stderr = NULL;
 
 	pthread_t tid;
-	pthread_create(&tid, NULL, spawn_bluealsa_server_stderr_proxy, data);
+	pthread_create(&tid, NULL, spawn_bluealsa_mock_stderr_proxy, data);
+	pthread_detach(tid);
 
 	pthread_mutex_lock(&data->data_mtx);
 
 	/* wait for system bus address */
 	while (data->dbus_bus_address == NULL)
 		pthread_cond_wait(&data->data_updated, &data->data_mtx);
-	putenv(data->dbus_bus_address);
+
+	strncpy(dbus_bus_address, data->dbus_bus_address,
+			sizeof(dbus_bus_address) - 1);
+	putenv(dbus_bus_address);
 
 	/* wait for service name acquisition */
 	while (data->acquired_service_name == NULL)
@@ -182,5 +192,7 @@ pid_t spawn_bluealsa_server(const char *service, unsigned int timeout,
 
 	pthread_mutex_unlock(&data->data_mtx);
 
-	return pid;
+	return 0;
 }
+
+#endif
diff --git a/test/inc/preload.inc b/test/inc/preload.inc
index 6e63caf..24d8e20 100644
--- a/test/inc/preload.inc
+++ b/test/inc/preload.inc
@@ -2,7 +2,7 @@
  * preload.inc
  * vim: ft=c
  *
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -16,25 +16,41 @@
 #include <string.h>
 #include <unistd.h>
 
-#define LDPRELOAD "LD_PRELOAD"
-int preload(int argc, char *argv[], const char *filename) {
+#include "shared/defs.h"
+
+#define LD_PRELOAD           "LD_PRELOAD"
+#define LD_PRELOAD_SANITIZER "LD_PRELOAD_SANITIZER"
+
+int preload(int argc, char * const argv[], char * const envp[], const char *filename) {
 	(void)argc;
 
-	char *tmp;
-	if ((tmp = getenv(LDPRELOAD)) != NULL &&
-			strstr(tmp, filename) != NULL)
+	const char *env_preload;
+	if ((env_preload = getenv(LD_PRELOAD)) == NULL)
+		env_preload = "";
+
+	const char *env_preload_sanitizer;
+	if ((env_preload_sanitizer = getenv(LD_PRELOAD_SANITIZER)) == NULL)
+		env_preload_sanitizer = "";
+
+	/* if required library is already preloaded, do nothing */
+	if (strstr(env_preload, filename) != NULL)
 		return 0;
 
-	char preload[1024];
-	char *envp[] = { preload, NULL };
+	fprintf(stderr, "EXECV PRELOAD: %s\n", filename);
 
 	char app[1024];
+	char preload[1024];
+	char *envp2[128] = { preload, NULL };
+
 	char *dir = dirname(strncpy(app, argv[0], sizeof(app) - 1));
+	snprintf(preload, sizeof(preload), "%s=%s:%s/%s:%s",
+			LD_PRELOAD, env_preload_sanitizer, dir, filename, env_preload);
 
-	sprintf(preload, "%s=%s/%s:", LDPRELOAD, dir, filename);
-	if (tmp != NULL)
-		strcat(preload, tmp);
+	size_t i = 1, j = 0;
+	while (i < ARRAYSIZE(envp2) - 1 && envp[j] != NULL)
+		envp2[i++] = envp[j++];
+	if (i == ARRAYSIZE(envp2) - 1 && envp[j] != NULL)
+		fprintf(stderr, "WARNING: Couldn't forward ENV variables\n");
 
-	fprintf(stderr, "EXECV PRELOAD: %s\n", filename);
-	return execve(argv[0], argv, envp);
+	return execve(argv[0], argv, envp2);
 }
diff --git a/test/inc/spawn.inc b/test/inc/spawn.inc
new file mode 100644
index 0000000..7849afc
--- /dev/null
+++ b/test/inc/spawn.inc
@@ -0,0 +1,154 @@
+/*
+ * spawn.inc
+ * vim: ft=c
+ *
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#pragma once
+#ifndef BLUEALSA_TEST_INC_SPAWN_H_
+#define BLUEALSA_TEST_INC_SPAWN_H_
+
+#include <signal.h>
+#include <stdio.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#define SPAWN_FLAG_NONE 0
+#define SPAWN_FLAG_REDIRECT_STDOUT (1 << 0)
+#define SPAWN_FLAG_REDIRECT_STDERR (1 << 1)
+
+struct spawn_process {
+
+	/* PID of newly spawned process */
+	pid_t pid;
+
+	/* stdout from the process */
+	FILE *f_stdout;
+	/* stderr from the process */
+	FILE *f_stderr;
+
+	/* async termination */
+	unsigned int term_delay_msec;
+	pthread_t term_thread;
+
+};
+
+/**
+ * Spawn new process using fork() and exec().
+ *
+ * @param sp Pointer to the structure which will be filled with spawned process
+ *   information, i.e. PID, stdout and stderr file descriptors.
+ * @param argv List of arguments to be passed to the process. The list shall be
+ *   terminated by NULL. The first argument is the name of the executable.
+ * @param f_stdin FILE stream to be used as stdin for the process. If NULL,
+ *   then the stdin from the parent process will be used.
+ * @param flags Bitwise OR of the SPAWN_FLAG_* flags.
+ * @return On success this function returns 0. Otherwise -1 is returned and
+ *   errno is set appropriately. */
+int spawn(struct spawn_process *sp, char *argv[], FILE *f_stdin, int flags) {
+
+	int pipe_stdout[2] = { -1, -1 };
+	int pipe_stderr[2] = { -1, -1 };
+
+	sp->pid = -1;
+	sp->f_stderr = NULL;
+	sp->f_stdout = NULL;
+	sp->term_delay_msec = 0;
+
+	if (flags & SPAWN_FLAG_REDIRECT_STDOUT) {
+		if (pipe(pipe_stdout) == -1)
+			goto fail;
+		if ((sp->f_stdout = fdopen(pipe_stdout[0], "r")) == NULL)
+			goto fail;
+	}
+
+	if (flags & SPAWN_FLAG_REDIRECT_STDERR) {
+		if (pipe(pipe_stderr) == -1)
+			goto fail;
+		if ((sp->f_stderr = fdopen(pipe_stderr[0], "r")) == NULL)
+			goto fail;
+	}
+
+	if ((sp->pid = fork()) == 0) {
+
+		if (f_stdin != NULL)
+			dup2(fileno(f_stdin), 0);
+
+		if (flags & SPAWN_FLAG_REDIRECT_STDOUT) {
+			dup2(pipe_stdout[1], 1);
+			fclose(sp->f_stdout);
+			close(pipe_stdout[1]);
+		}
+
+		if (flags & SPAWN_FLAG_REDIRECT_STDERR) {
+			dup2(pipe_stderr[1], 2);
+			fclose(sp->f_stderr);
+			close(pipe_stderr[1]);
+		}
+
+		execv(argv[0], argv);
+		return -1;
+	}
+
+	close(pipe_stdout[1]);
+	close(pipe_stderr[1]);
+	return 0;
+
+fail:
+
+	if (sp->f_stdout != NULL)
+		fclose(sp->f_stdout);
+	else if (pipe_stdout[0] != -1)
+		close(pipe_stdout[0]);
+	if (pipe_stdout[1] != -1)
+		close(pipe_stdout[1]);
+
+	if (sp->f_stderr != NULL)
+		fclose(sp->f_stderr);
+	else if (pipe_stderr[0] != -1)
+		close(pipe_stderr[0]);
+	if (pipe_stderr[1] != -1)
+		close(pipe_stderr[1]);
+
+	return -1;
+}
+
+static void *spawn_timeout_thread(void *arg) {
+	struct spawn_process *sp = arg;
+
+	usleep(sp->term_delay_msec * 1000);
+	kill(sp->pid, SIGTERM);
+
+	return NULL;
+}
+
+int spawn_terminate(struct spawn_process *sp, unsigned int delay_msec) {
+
+	if (delay_msec == 0)
+		return kill(sp->pid, SIGTERM);
+
+	sp->term_delay_msec = delay_msec;
+	if (pthread_create(&sp->term_thread, NULL, spawn_timeout_thread, sp) != 0)
+		return -1;
+
+	return 0;
+}
+
+void spawn_close(struct spawn_process *sp, int *wstatus) {
+	if (sp->term_delay_msec != 0)
+		pthread_join(sp->term_thread, NULL);
+	if (sp->pid != -1)
+		waitpid(sp->pid, wstatus, 0);
+	if (sp->f_stdout != NULL)
+		fclose(sp->f_stdout);
+	if (sp->f_stderr != NULL)
+		fclose(sp->f_stderr);
+}
+
+#endif
diff --git a/test/integration/test-e2e-latency.py b/test/integration/test-e2e-latency.py
new file mode 100755
index 0000000..205cb23
--- /dev/null
+++ b/test/integration/test-e2e-latency.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
+#
+# This file is a part of bluez-alsa.
+#
+# This project is licensed under the terms of the MIT license.
+
+import argparse
+import signal
+import subprocess
+import sys
+import time
+from csv import DictWriter
+from math import ceil, floor
+from random import randint
+from struct import Struct
+
+# Format mapping between BlueALSA
+# and Python struct module
+FORMATS = {
+    'U8': ('<', 'B'),
+    'S16_LE': ('<', 'h'),
+    'S24_3LE': ('<', None),  # not supported
+    'S24_LE': ('<', 'i'),
+    'S32_LE': ('<', 'i'),
+}
+
+# Value ranges for different formats
+LIMITS = {
+    'U8': (0, 255),
+    'S16_LE': (-32768, 32767),
+    'S24_LE': (-8388608, 8388607),
+    'S32_LE': (-2147483648, 2147483647),
+}
+
+
+def samplerate_sync(t0: float, frames: int, sampling: int):
+    delta = frames / sampling - time.monotonic() + t0
+    if (delta > 0):
+        print(f"Rate sync: {delta:.6f}")
+        time.sleep(delta)
+    else:
+        print(f"Rate sync overdue: {-delta:.6f}")
+
+
+def test_pcm_write(pcm, pcm_format, pcm_channels, pcm_sampling, interval):
+    """Write PCM test signal.
+
+    This function generates test signal when real time modulo interval is zero
+    (within sampling rate resolution capabilities). Providing that both devices
+    are in sync with NTP, this should be a reliable way to detect end-to-end
+    latency.
+    """
+
+    fmt = FORMATS[pcm_format]
+    # Structure for single PCM frame
+    struct = Struct(fmt[0] + fmt[1] * pcm_channels)
+
+    # Time quantum in seconds
+    t_quantum = 1.0 / pcm_sampling
+
+    # Noise PCM value range
+    v_noise_min = int(LIMITS[pcm_format][0] * 0.05)
+    v_noise_max = int(LIMITS[pcm_format][1] * 0.05)
+
+    # Signal PCM value range
+    v_signal_min = int(LIMITS[pcm_format][1] * 0.8)
+    v_signal_max = int(LIMITS[pcm_format][1] * 1.0)
+
+    signal_frames = int(0.1 * pcm_sampling)
+    print(f"Signal frames: {signal_frames}")
+
+    frames = 0
+    t0 = time.monotonic()
+
+    while True:
+
+        # Time until next signal
+        t = time.time()
+        t_delta = ceil(t / interval) * interval - t - t_quantum
+        print(f"Next signal at: {t:.6f} + {t_delta:.6f} -> {t + t_delta:.6f}")
+
+        # Write random data to keep encoder busy
+        noise_frames = int(t_delta * pcm_sampling)
+        print(f"Noise frames: {noise_frames}")
+        pcm.writelines(
+            struct.pack(*[randint(v_noise_min, v_noise_max)] * pcm_channels)
+            for _ in range(noise_frames))
+        pcm.flush()
+
+        frames += noise_frames
+        samplerate_sync(t0, frames, pcm_sampling)
+
+        # Write signal data
+        pcm.writelines(
+            struct.pack(*[randint(v_signal_min, v_signal_max)] * pcm_channels)
+            for _ in range(signal_frames))
+        pcm.flush()
+
+        frames += signal_frames
+        samplerate_sync(t0, frames, pcm_sampling)
+
+
+def test_pcm_read(pcm, pcm_format, pcm_channels, pcm_sampling, interval):
+    """Read PCM test signal."""
+
+    fmt = FORMATS[pcm_format]
+    # Structure for single PCM frame
+    struct = Struct(fmt[0] + fmt[1] * pcm_channels)
+
+    # Minimal value for received PCM signal
+    v_signal_min = int(LIMITS[pcm_format][1] * 0.51)
+
+    csv = DictWriter(sys.stdout, fieldnames=[
+        'time', 'expected', 'latency', 'duration'])
+    csv.writeheader()
+
+    t_signal = 0
+    while True:
+
+        pcm_frame = struct.unpack(pcm.read(struct.size))
+        if pcm_frame[0] < v_signal_min:
+            if t_signal > 0:
+                t = time.time()
+                t_expected = floor(t / interval) * interval
+                csv.writerow({'time': t,
+                              'expected': float(t_expected),
+                              'latency': t - t_expected,
+                              'duration': t - t_signal})
+                t_signal = 0
+            continue
+
+        if t_signal == 0:
+            t_signal = time.time()
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument('-B', '--dbus', type=str, metavar='NAME',
+                    help='BlueALSA service name suffix')
+parser.add_argument('-i', '--interval', type=int, metavar='SEC', default=2,
+                    help='signal interval in seconds; default: 2')
+parser.add_argument('-t', '--timeout', type=int, metavar='SEC', default=60,
+                    help='test timeout in seconds; default: 60')
+parser.add_argument('PCM_PATH', type=str,
+                    help='D-Bus path of the BlueALSA PCM device')
+
+args = parser.parse_args()
+signal.alarm(args.timeout)
+
+options = ["--verbose"]
+if args.dbus:
+    options.append(f'--dbus={args.dbus}')
+
+try:  # Get info for given BlueALSA PCM device
+    cmd = ['bluealsa-cli', *options, 'info', args.PCM_PATH]
+    output = subprocess.check_output(cmd, text=True)
+except subprocess.CalledProcessError:
+    sys.exit(1)
+info = {key.lower(): value.strip()
+        for key, value in (line.split(':', 1)
+                           for line in output.splitlines())}
+channels = int(info['channels'])
+sampling = int(info['sampling'].split()[0])
+
+print(f"Bluetooth: {info['transport']} {info['selected codec']}")
+print(f"PCM: {info['format']} {channels} channels {sampling} Hz")
+print("==========")
+
+cmd = ['bluealsa-cli', *options, 'open', args.PCM_PATH]
+client = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+
+# Wait for BlueALSA to open the PCM device
+time.sleep(1)
+
+if info['mode'] == 'sink':
+    test_pcm_write(client.stdin, info['format'], channels, sampling,
+                   args.interval)
+
+if info['mode'] == 'source':
+    test_pcm_read(client.stdout, info['format'], channels, sampling,
+                  args.interval)
diff --git a/test/integration/test-pcm-open.sh b/test/integration/test-pcm-open.sh
index 5ad052b..66530cf 100755
--- a/test/integration/test-pcm-open.sh
+++ b/test/integration/test-pcm-open.sh
@@ -1,14 +1,21 @@
 #!/bin/bash
-# usage: test-pcm-open.sh <pcm-path>
+#
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
+#
+# This file is a part of bluez-alsa.
+#
+# This project is licensed under the terms of the MIT license.
 
-# check whether it is possible to open
-# a BlueALSA PCM right after it was closed
+if [[ $# -ne 1 ]]; then
+	echo "usage: $0 <pcm-path>"
+	exit 1
+fi
 
 # open PCM and close it right away
-: |bluealsa-cli open $1
-bluealsa-cli open $1
+: |bluealsa-cli open "$1" || exit
 
-if [ $? -ne 0 ]; then
+# check if open is possible right after close
+if ! dd status=none if=/dev/zero count=10 |bluealsa-cli open "$1" ; then
 	echo "error: Couldn't open BlueALSA PCM"
 	exit 1
 fi
diff --git a/test/integration/test-select-codec.sh b/test/integration/test-select-codec.sh
index 2ac7c9d..fb64054 100755
--- a/test/integration/test-select-codec.sh
+++ b/test/integration/test-select-codec.sh
@@ -1,34 +1,44 @@
 #!/bin/bash
-# usage: test-select-codec.sh <pcm-path>
+#
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
+#
+# This file is a part of bluez-alsa.
+#
+# This project is licensed under the terms of the MIT license.
 
-CODECS=($(bluealsa-cli codec $1 |grep Available |cut -d: -f2))
-if [[ ! ${#CODECS[@]} > 1 ]]; then
+if [[ $# -ne 1 ]]; then
+	echo "usage: $0 <pcm-path>"
+	exit 1
+fi
+
+read -r -a CODECS < <(bluealsa-cli codec "$1" |grep Available |cut -d: -f2)
+if [[ ! ${#CODECS[@]} -gt 1 ]]; then
 	echo "error: This test requires PCM with at least two codecs"
 	exit 1
 fi
 
 # try to select the last codec from the codec list
 echo "Select codec: ${CODECS[-1]}"
-bluealsa-cli codec $1 ${CODECS[-1]}
+bluealsa-cli codec "$1" "${CODECS[-1]}"
 sleep 1
 
 # check whether codec was selected correctly
-SELECTED=($(bluealsa-cli codec $1 |grep Selected |cut -d: -f2))
-if [[ ${SELECTED[0]} != ${CODECS[-1]} ]]; then
+read -r -a SELECTED < <(bluealsa-cli codec "$1" |grep Selected |cut -d: -f2)
+if [[ "${SELECTED[0]}" != "${CODECS[-1]}" ]]; then
 	echo "error: Codec selection mismatch: ${SELECTED[0]} != ${CODECS[-1]}"
 	exit 1
 fi
 
 # check for race condition in codec selection
 for CODEC in "${CODECS[@]}"; do
-	echo "Select codec: $CODEC"
-	bluealsa-cli codec $1 $CODEC &
+	echo "Select codec: ${CODEC}"
+	bluealsa-cli codec "$1" "${CODEC}" &
 done
 wait
 
 # check whether selected codec is the last from the array
-SELECTED=($(bluealsa-cli codec $1 |grep Selected |cut -d: -f2))
-if [[ ${SELECTED[0]} != ${CODECS[-1]} ]]; then
+read -r -a SELECTED < <(bluealsa-cli codec "$1" |grep Selected |cut -d: -f2)
+if [[ "${SELECTED[0]}" != "${CODECS[-1]}" ]]; then
 	echo "error: Codec selection mismatch: ${SELECTED[0]} != ${CODECS[-1]}"
 	exit 1
 fi
diff --git a/test/mock/Makefile.am b/test/mock/Makefile.am
new file mode 100644
index 0000000..3c76fcf
--- /dev/null
+++ b/test/mock/Makefile.am
@@ -0,0 +1,101 @@
+# BlueALSA - Makefile.am
+# Copyright (c) 2016-2023 Arkadiusz Bokowy
+
+check_PROGRAMS = \
+	bluealsa-mock
+
+bluealsa_mock_SOURCES = \
+	../../src/shared/a2dp-codecs.c \
+	../../src/shared/ffb.c \
+	../../src/shared/log.c \
+	../../src/shared/rt.c \
+	../../src/a2dp.c \
+	../../src/a2dp-sbc.c \
+	../../src/at.c \
+	../../src/audio.c \
+	../../src/ba-adapter.c \
+	../../src/ba-device.c \
+	../../src/ba-rfcomm.c \
+	../../src/ba-transport.c \
+	../../src/bluealsa-config.c \
+	../../src/bluealsa-dbus.c \
+	../../src/bluealsa-iface.c \
+	../../src/bluealsa-skeleton.c \
+	../../src/codec-sbc.c \
+	../../src/dbus.c \
+	../../src/hci.c \
+	../../src/hfp.c \
+	../../src/io.c \
+	../../src/rtp.c \
+	../../src/sco.c \
+	../../src/storage.c \
+	../../src/utils.c \
+	mock-bluealsa.c \
+	mock-bluez.c \
+	mock.c
+
+bluealsa_mock_CFLAGS = \
+	-I$(top_srcdir)/src \
+	-I$(top_srcdir)/test \
+	@BLUEZ_CFLAGS@ \
+	@GIO2_CFLAGS@ \
+	@GLIB2_CFLAGS@ \
+	@LIBBSD_CFLAGS@ \
+	@LIBUNWIND_CFLAGS@ \
+	@SBC_CFLAGS@ \
+	@SPANDSP_CFLAGS@
+
+bluealsa_mock_LDADD = \
+	@BLUEZ_LIBS@ \
+	@GIO2_LIBS@ \
+	@GLIB2_LIBS@ \
+	@LIBUNWIND_LIBS@ \
+	@SBC_LIBS@ \
+	@SPANDSP_LIBS@
+
+if ENABLE_AAC
+bluealsa_mock_SOURCES += ../../src/a2dp-aac.c
+bluealsa_mock_CFLAGS += @AAC_CFLAGS@
+bluealsa_mock_LDADD += @AAC_LIBS@
+endif
+
+if ENABLE_APTX
+bluealsa_mock_SOURCES += ../../src/a2dp-aptx.c
+bluealsa_mock_CFLAGS += @APTX_CFLAGS@
+bluealsa_mock_LDADD += @APTX_LIBS@
+endif
+
+if ENABLE_APTX_HD
+bluealsa_mock_SOURCES += ../../src/a2dp-aptx-hd.c
+bluealsa_mock_CFLAGS += @APTX_HD_CFLAGS@
+bluealsa_mock_LDADD += @APTX_HD_LIBS@
+endif
+
+if ENABLE_APTX_OR_APTX_HD
+bluealsa_mock_SOURCES += ../../src/codec-aptx.c
+endif
+
+if ENABLE_FASTSTREAM
+bluealsa_mock_SOURCES += ../../src/a2dp-faststream.c
+endif
+
+if ENABLE_LC3PLUS
+bluealsa_mock_SOURCES += ../../src/a2dp-lc3plus.c
+bluealsa_mock_LDADD += @LC3PLUS_LIBS@
+endif
+
+if ENABLE_LDAC
+bluealsa_mock_SOURCES += ../../src/a2dp-ldac.c
+bluealsa_mock_CFLAGS += @LDAC_ABR_CFLAGS@ @LDAC_DEC_CFLAGS@ @LDAC_ENC_CFLAGS@
+bluealsa_mock_LDADD += @LDAC_ABR_LIBS@ @LDAC_DEC_LIBS@ @LDAC_ENC_LIBS@
+endif
+
+if ENABLE_MPEG
+bluealsa_mock_SOURCES += ../../src/a2dp-mpeg.c
+bluealsa_mock_CFLAGS += @MPG123_CFLAGS@
+bluealsa_mock_LDADD += @MP3LAME_LIBS@ @MPG123_LIBS@
+endif
+
+if ENABLE_MSBC
+bluealsa_mock_SOURCES += ../../src/codec-msbc.c
+endif
diff --git a/test/mock/mock-bluealsa.c b/test/mock/mock-bluealsa.c
new file mode 100644
index 0000000..7cc90e4
--- /dev/null
+++ b/test/mock/mock-bluealsa.c
@@ -0,0 +1,461 @@
+/*
+ * mock-bluealsa.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include "mock.h"
+/* IWYU pragma: no_include "config.h" */
+
+#include <assert.h>
+#include <errno.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <bluetooth/bluetooth.h>
+#include <bluetooth/hci.h>
+
+#include <gio/gio.h>
+#include <glib.h>
+
+#include "a2dp.h"
+#if ENABLE_APTX
+# include "a2dp-aptx.h"
+#endif
+#if ENABLE_APTX_HD
+# include "a2dp-aptx-hd.h"
+#endif
+#if ENABLE_FASTSTREAM
+# include "a2dp-faststream.h"
+#endif
+#include "a2dp-sbc.h"
+#include "ba-adapter.h"
+#include "ba-device.h"
+#include "ba-rfcomm.h"
+#include "ba-transport.h"
+#include "bluealsa-config.h"
+#include "bluez.h"
+#include "codec-sbc.h"
+#include "hfp.h"
+#include "io.h"
+#include "shared/a2dp-codecs.h"
+#include "shared/defs.h"
+#include "shared/log.h"
+#include "shared/rt.h"
+
+#include "inc/sine.inc"
+
+#define TEST_BLUEALSA_STORAGE_DIR "/tmp/bluealsa-mock-storage"
+
+static const a2dp_sbc_t config_sbc_44100_stereo = {
+	.frequency = SBC_SAMPLING_FREQ_44100,
+	.channel_mode = SBC_CHANNEL_MODE_JOINT_STEREO,
+	.block_length = SBC_BLOCK_LENGTH_16,
+	.subbands = SBC_SUBBANDS_8,
+	.allocation_method = SBC_ALLOCATION_LOUDNESS,
+	.min_bitpool = SBC_MIN_BITPOOL,
+	.max_bitpool = SBC_MAX_BITPOOL,
+};
+
+#if ENABLE_APTX
+static const a2dp_aptx_t config_aptx_44100_stereo = {
+	.info = A2DP_SET_VENDOR_ID_CODEC_ID(APTX_VENDOR_ID, APTX_CODEC_ID),
+	.channel_mode = APTX_CHANNEL_MODE_STEREO,
+	.frequency = APTX_SAMPLING_FREQ_44100,
+};
+#endif
+
+#if ENABLE_APTX_HD
+static const a2dp_aptx_hd_t config_aptx_hd_48000_stereo = {
+	.aptx.info = A2DP_SET_VENDOR_ID_CODEC_ID(APTX_HD_VENDOR_ID, APTX_HD_CODEC_ID),
+	.aptx.channel_mode = APTX_CHANNEL_MODE_STEREO,
+	.aptx.frequency = APTX_SAMPLING_FREQ_48000,
+};
+#endif
+
+#if ENABLE_FASTSTREAM
+static const a2dp_faststream_t config_faststream_44100_16000 = {
+	.info = A2DP_SET_VENDOR_ID_CODEC_ID(FASTSTREAM_VENDOR_ID, FASTSTREAM_CODEC_ID),
+	.direction = FASTSTREAM_DIRECTION_MUSIC | FASTSTREAM_DIRECTION_VOICE,
+	.frequency_music = FASTSTREAM_SAMPLING_FREQ_MUSIC_44100,
+	.frequency_voice = FASTSTREAM_SAMPLING_FREQ_VOICE_16000,
+};
+#endif
+
+bool bluez_a2dp_set_configuration(const char *current_dbus_sep_path,
+		const struct a2dp_sep *sep, GError **error) {
+	debug("%s: %s", __func__, current_dbus_sep_path);
+	(void)current_dbus_sep_path; (void)sep;
+	*error = g_error_new(G_DBUS_ERROR, G_DBUS_ERROR_NOT_SUPPORTED, "Not supported");
+	return false;
+}
+
+void bluez_battery_provider_update(struct ba_device *device) {
+	debug("%s: %p", __func__, device);
+	(void)device;
+}
+
+int ofono_call_volume_update(struct ba_transport *transport) {
+	debug("%s: %p", __func__, transport);
+	(void)transport;
+	return 0;
+}
+
+static void *mock_dec(struct ba_transport_thread *th) {
+
+	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
+
+	struct ba_transport_pcm *t_pcm = th->pcm;
+
+	const unsigned int channels = t_pcm->channels;
+	const unsigned int samplerate = t_pcm->sampling;
+	struct pollfd fds[1] = {{ th->pipe[0], POLLIN, 0 }};
+	struct asrsync asrs = { .frames = 0 };
+	int16_t buffer[1024 * 2];
+	int x = 0;
+
+	debug_transport_thread_loop(th, "START");
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		int timeout = 0;
+		if (!ba_transport_pcm_is_active(t_pcm))
+			timeout = -1;
+
+		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+		int poll_rv = poll(fds, ARRAYSIZE(fds), timeout);
+		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+
+		if (poll_rv == 1 && fds[0].revents & POLLIN) {
+			/* dispatch incoming event */
+			enum ba_transport_thread_signal signal;
+			ba_transport_thread_signal_recv(th, &signal);
+			switch (signal) {
+			case BA_TRANSPORT_THREAD_SIGNAL_PCM_OPEN:
+			case BA_TRANSPORT_THREAD_SIGNAL_PCM_RESUME:
+				asrs.frames = 0;
+				continue;
+			default:
+				continue;
+			}
+		}
+
+		fprintf(stderr, ".");
+
+		if (asrs.frames == 0)
+			asrsync_init(&asrs, samplerate);
+
+		const size_t samples = ARRAYSIZE(buffer);
+		const size_t frames = samples / channels;
+		x = snd_pcm_sine_s16_2le(buffer, frames, channels, x, 146.83 / samplerate);
+
+		io_pcm_scale(t_pcm, buffer, samples);
+		if (io_pcm_write(t_pcm, buffer, samples) == -1)
+			error("FIFO write error: %s", strerror(errno));
+
+		/* maintain constant speed */
+		asrsync_sync(&asrs, frames);
+
+	}
+
+	pthread_cleanup_pop(1);
+	return NULL;
+}
+
+void *a2dp_sbc_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *a2dp_aac_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *a2dp_aptx_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *a2dp_faststream_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+void *sco_dec_thread(struct ba_transport_thread *th) { return mock_dec(th); }
+
+static void *mock_bt_dump_thread(void *userdata) {
+
+	int bt_fd = GPOINTER_TO_INT(userdata);
+	FILE *f_output = NULL;
+	uint8_t buffer[1024];
+	ssize_t len;
+
+	if (mock_dump_output)
+		f_output = fopen("bluealsa-mock.dump", "w");
+
+	debug("IO loop: START: %s", __func__);
+	while ((len = read(bt_fd, buffer, sizeof(buffer))) > 0) {
+		fprintf(stderr, "#");
+
+		if (!mock_dump_output)
+			continue;
+
+		for (ssize_t i = 0; i < len; i++)
+			fprintf(f_output, "%02x", buffer[i]);
+		fprintf(f_output, "\n");
+
+	}
+
+	debug("IO loop: EXIT: %s", __func__);
+	if (f_output != NULL)
+		fclose(f_output);
+	close(bt_fd);
+	return NULL;
+}
+
+static int mock_transport_set_a2dp_state_active(struct ba_transport *t) {
+	ba_transport_set_a2dp_state(t, BLUEZ_A2DP_TRANSPORT_STATE_ACTIVE);
+	return G_SOURCE_REMOVE;
+}
+
+static int mock_transport_acquire_bt(struct ba_transport *t) {
+
+	int bt_fds[2];
+	assert(socketpair(AF_UNIX, SOCK_SEQPACKET, 0, bt_fds) == 0);
+
+	t->bt_fd = bt_fds[0];
+	t->mtu_read = 256;
+	t->mtu_write = 256;
+
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_SCO)
+		t->mtu_read = t->mtu_write = 48;
+
+	debug("New transport: %d (MTU: R:%zu W:%zu)", t->bt_fd, t->mtu_read, t->mtu_write);
+
+	g_thread_unref(g_thread_new(NULL, mock_bt_dump_thread, GINT_TO_POINTER(bt_fds[1])));
+
+	if (t->profile & BA_TRANSPORT_PROFILE_MASK_A2DP)
+		/* Emulate asynchronous transport activation by BlueZ. */
+		g_timeout_add(10, G_SOURCE_FUNC(mock_transport_set_a2dp_state_active), t);
+
+	return bt_fds[0];
+}
+
+static struct ba_device *mock_device_new(struct ba_adapter *a, const char *btmac) {
+
+	bdaddr_t addr;
+	str2ba(btmac, &addr);
+
+	struct ba_device *d;
+	if ((d = ba_device_lookup(a, &addr)) == NULL) {
+		d = ba_device_new(a, &addr);
+		d->battery.charge = 75;
+	}
+
+	return d;
+}
+
+static struct ba_transport *mock_transport_new_a2dp(const char *device_btmac,
+		uint16_t profile, const char *dbus_path, const struct a2dp_codec *codec,
+		const void *configuration) {
+
+	usleep(mock_fuzzing_ms * 1000);
+
+	struct ba_device *d = mock_device_new(mock_adapter, device_btmac);
+	const char *dbus_owner = g_dbus_connection_get_unique_name(config.dbus);
+	struct ba_transport *t = ba_transport_new_a2dp(d, profile, dbus_owner, dbus_path,
+			codec, configuration);
+	t->acquire = mock_transport_acquire_bt;
+
+	fprintf(stderr, "BLUEALSA_PCM_READY=A2DP:%s:%s\n", device_btmac,
+			a2dp_codecs_codec_id_to_string(ba_transport_get_codec(t)));
+
+	ba_transport_set_a2dp_state(t, BLUEZ_A2DP_TRANSPORT_STATE_PENDING);
+
+	ba_device_unref(d);
+	return t;
+}
+
+static void *mock_transport_rfcomm_thread(void *userdata) {
+
+	static const struct {
+		const char *command;
+		const char *response;
+	} responses[] = {
+		/* accept HFP codec selection */
+		{ "\r\n+BCS:1\r\n", "AT+BCS=1\r" },
+		{ "\r\n+BCS:2\r\n", "AT+BCS=2\r" },
+	};
+
+	int rfcomm_fd = GPOINTER_TO_INT(userdata);
+	char buffer[1024];
+	ssize_t len;
+
+	while ((len = read(rfcomm_fd, buffer, sizeof(buffer))) > 0) {
+		hexdump("RFCOMM", buffer, len, true);
+
+		for (size_t i = 0; i < ARRAYSIZE(responses); i++) {
+			if (strncmp(buffer, responses[i].command, len) != 0)
+				continue;
+			len = strlen(responses[i].response);
+			if (write(rfcomm_fd, responses[i].response, len) != len)
+				warn("Couldn't write RFCOMM response: %s", strerror(errno));
+			break;
+		}
+
+	}
+
+	close(rfcomm_fd);
+	return NULL;
+}
+
+static struct ba_transport *mock_transport_new_sco(const char *device_btmac,
+		uint16_t profile, const char *dbus_path) {
+
+	usleep(mock_fuzzing_ms * 1000);
+
+	struct ba_device *d = mock_device_new(mock_adapter, device_btmac);
+	const char *dbus_owner = g_dbus_connection_get_unique_name(config.dbus);
+
+	int fds[2];
+	socketpair(AF_UNIX, SOCK_STREAM, 0, fds);
+	g_thread_unref(g_thread_new(NULL, mock_transport_rfcomm_thread, GINT_TO_POINTER(fds[1])));
+
+	struct ba_transport *t = ba_transport_new_sco(d, profile, dbus_owner, dbus_path, fds[0]);
+	t->sco.rfcomm->state = HFP_SLC_CONNECTED;
+	t->sco.rfcomm->ag_codecs.cvsd = true;
+	t->sco.rfcomm->hf_codecs.cvsd = true;
+#if ENABLE_MSBC
+	t->sco.rfcomm->ag_features |= HFP_AG_FEAT_CODEC | HFP_AG_FEAT_ESCO;
+	t->sco.rfcomm->hf_features |= HFP_HF_FEAT_CODEC | HFP_HF_FEAT_ESCO;
+	t->sco.rfcomm->ag_codecs.msbc = true;
+	t->sco.rfcomm->hf_codecs.msbc = true;
+#endif
+	t->acquire = mock_transport_acquire_bt;
+
+	fprintf(stderr, "BLUEALSA_PCM_READY=SCO:%s:%s\n", device_btmac,
+			hfp_codec_id_to_string(ba_transport_get_codec(t)));
+
+	ba_device_unref(d);
+	return t;
+}
+
+static void *mock_bluealsa_service_thread(void *userdata) {
+	(void)userdata;
+
+	GPtrArray *tt = g_ptr_array_new();
+	size_t i;
+
+	if (config.profile.a2dp_source) {
+
+		if (a2dp_sbc_source.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_1,
+						BA_TRANSPORT_PROFILE_A2DP_SOURCE, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1,
+						&a2dp_sbc_source, &config_sbc_44100_stereo));
+
+#if ENABLE_APTX
+		if (a2dp_aptx_source.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_2,
+						BA_TRANSPORT_PROFILE_A2DP_SOURCE, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+						&a2dp_aptx_source, &config_aptx_44100_stereo));
+		else
+#endif
+#if ENABLE_APTX_HD
+		if (a2dp_aptx_hd_source.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_2,
+						BA_TRANSPORT_PROFILE_A2DP_SOURCE, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+						&a2dp_aptx_hd_source, &config_aptx_hd_48000_stereo));
+		else
+#endif
+#if ENABLE_FASTSTREAM
+		if (a2dp_faststream_source.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_2,
+						BA_TRANSPORT_PROFILE_A2DP_SOURCE, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+						&a2dp_faststream_source, &config_faststream_44100_16000));
+		else
+#endif
+		if (a2dp_sbc_source.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_2,
+						BA_TRANSPORT_PROFILE_A2DP_SOURCE, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+						&a2dp_sbc_source, &config_sbc_44100_stereo));
+
+	}
+
+	if (config.profile.a2dp_sink) {
+
+#if ENABLE_APTX_HD
+		if (a2dp_aptx_hd_sink.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_1,
+						BA_TRANSPORT_PROFILE_A2DP_SINK, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1,
+						&a2dp_aptx_hd_sink, &config_aptx_hd_48000_stereo));
+		else
+#endif
+#if ENABLE_APTX
+		if (a2dp_aptx_sink.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_1,
+						BA_TRANSPORT_PROFILE_A2DP_SINK, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1,
+						&a2dp_aptx_sink, &config_aptx_44100_stereo));
+		else
+#endif
+		if (a2dp_sbc_sink.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_1,
+						BA_TRANSPORT_PROFILE_A2DP_SINK, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1,
+						&a2dp_sbc_sink, &config_sbc_44100_stereo));
+
+		if (a2dp_sbc_sink.enabled)
+			g_ptr_array_add(tt, mock_transport_new_a2dp(MOCK_DEVICE_2,
+						BA_TRANSPORT_PROFILE_A2DP_SINK, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+						&a2dp_sbc_sink, &config_sbc_44100_stereo));
+
+	}
+
+	if (config.profile.hfp_ag) {
+
+		struct ba_transport *t;
+		g_ptr_array_add(tt, t = mock_transport_new_sco(MOCK_DEVICE_1,
+					BA_TRANSPORT_PROFILE_HFP_AG, MOCK_BLUEZ_SCO_PATH_1));
+
+		if (mock_fuzzing_ms)
+			ba_transport_set_codec(t, HFP_CODEC_CVSD);
+
+	}
+
+	if (config.profile.hsp_ag) {
+		g_ptr_array_add(tt, mock_transport_new_sco(MOCK_DEVICE_2,
+					BA_TRANSPORT_PROFILE_HSP_AG, MOCK_BLUEZ_SCO_PATH_2));
+	}
+
+	mock_sem_wait(mock_sem_timeout);
+
+	for (i = 0; i < tt->len; i++) {
+		usleep(mock_fuzzing_ms * 1000);
+		ba_transport_destroy(tt->pdata[i]);
+	}
+
+	usleep(mock_fuzzing_ms * 1000);
+
+	g_ptr_array_free(tt, TRUE);
+	mock_sem_signal(mock_sem_quit);
+	return NULL;
+}
+
+void mock_bluealsa_dbus_name_acquired(GDBusConnection *conn, const char *name, void *userdata) {
+	(void)conn;
+	(void)userdata;
+
+	fprintf(stderr, "BLUEALSA_DBUS_SERVICE_NAME=%s\n", name);
+
+	/* do not generate lots of data */
+	config.sbc_quality = SBC_QUALITY_LOW;
+
+	/* initialize codec capabilities */
+	a2dp_codecs_init();
+
+	/* emulate dummy test HCI device */
+	assert((mock_adapter = ba_adapter_new(MOCK_ADAPTER_ID)) != NULL);
+
+	/* make HCI mSBC-ready */
+	mock_adapter->hci.features[2] = LMP_TRSP_SCO;
+	mock_adapter->hci.features[3] = LMP_ESCO;
+
+	/* run actual BlueALSA mock thread */
+	g_thread_unref(g_thread_new(NULL, mock_bluealsa_service_thread, NULL));
+
+}
diff --git a/test/mock/mock-bluez.c b/test/mock/mock-bluez.c
new file mode 100644
index 0000000..bbdadc3
--- /dev/null
+++ b/test/mock/mock-bluez.c
@@ -0,0 +1,165 @@
+/*
+ * mock-bluez.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include "mock.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#include <bluetooth/bluetooth.h>
+
+#include <gio/gio.h>
+#include <glib.h>
+
+#include "bluez-iface.h"
+#include "utils.h"
+#include "shared/defs.h"
+
+/**
+ * Bluetooth device name mappings in form of "MAC:name". */
+static const char * devices[8] = { NULL };
+
+static GDBusPropertyInfo bluez_iface_device_Adapter = {
+	-1, "Adapter", "o", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
+static GDBusPropertyInfo bluez_iface_device_Alias = {
+	-1, "Alias", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
+static GDBusPropertyInfo bluez_iface_device_Class = {
+	-1, "Class", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
+static GDBusPropertyInfo bluez_iface_device_Icon = {
+	-1, "Icon", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
+static GDBusPropertyInfo bluez_iface_device_Connected = {
+	-1, "Connected", "s", G_DBUS_PROPERTY_INFO_FLAGS_READABLE, NULL
+};
+
+static GDBusPropertyInfo *bluez_iface_device_properties[] = {
+	&bluez_iface_device_Adapter,
+	&bluez_iface_device_Alias,
+	&bluez_iface_device_Class,
+	&bluez_iface_device_Icon,
+	&bluez_iface_device_Connected,
+	NULL,
+};
+
+static GDBusInterfaceInfo bluez_iface_device = {
+	-1, BLUEZ_IFACE_DEVICE,
+	NULL,
+	NULL,
+	bluez_iface_device_properties,
+	NULL,
+};
+
+static GDBusMethodInfo bluez_iface_media_transport_Release = {
+	-1, "Release", NULL, NULL, NULL
+};
+
+static GDBusMethodInfo *bluez_iface_media_transport_methods[] = {
+	&bluez_iface_media_transport_Release,
+	NULL,
+};
+
+static GDBusInterfaceInfo bluez_iface_media_transport = {
+	-1, BLUEZ_IFACE_MEDIA_TRANSPORT,
+	bluez_iface_media_transport_methods,
+	NULL,
+	NULL,
+	NULL,
+};
+
+static GVariant *bluez_device_get_property(GDBusConnection *conn,
+		const char *sender, const char *path, const char *iface,
+		const char *property, GError **error, void *userdata) {
+	(void)conn;
+	(void)sender;
+	(void)iface;
+	(void)userdata;
+
+	bdaddr_t addr;
+	char addrstr[18];
+	ba2str(g_dbus_bluez_object_path_to_bdaddr(path, &addr), addrstr);
+
+	if (strcmp(property, "Adapter") == 0)
+		return g_variant_new_object_path(MOCK_BLUEZ_ADAPTER_PATH);
+	if (strcmp(property, "Class") == 0)
+		return g_variant_new_uint32(0x240404);
+	if (strcmp(property, "Icon") == 0)
+		return g_variant_new_string("audio-card");
+	if (strcmp(property, "Connected") == 0)
+		return g_variant_new_boolean(TRUE);
+
+	if (strcmp(property, "Alias") == 0) {
+
+		for (size_t i = 0; i < ARRAYSIZE(devices); i++)
+			if (devices[i] != NULL &&
+					strncmp(devices[i], addrstr, sizeof(addrstr) - 1) == 0)
+				return g_variant_new_string(&devices[i][sizeof(addrstr)]);
+
+		if (error != NULL)
+			*error = g_error_new(G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_PROPERTY,
+					"Device alias/name not available");
+		return NULL;
+	}
+
+	g_assert_not_reached();
+	return NULL;
+}
+
+static const GDBusInterfaceVTable bluez_device_vtable = {
+	.get_property = bluez_device_get_property,
+};
+
+static void bluez_media_transport_method_call(G_GNUC_UNUSED GDBusConnection *conn,
+		G_GNUC_UNUSED const char *sender, G_GNUC_UNUSED const char *path,
+		G_GNUC_UNUSED const char *interface, const char *method, G_GNUC_UNUSED GVariant *params,
+		GDBusMethodInvocation *invocation, G_GNUC_UNUSED void *userdata) {
+
+	if (strcmp(method, "Release") == 0) {
+		g_dbus_method_invocation_return_value(invocation, NULL);
+		return;
+	}
+
+	g_assert_not_reached();
+}
+
+static const GDBusInterfaceVTable bluez_media_transport_vtable = {
+	.method_call = bluez_media_transport_method_call,
+};
+
+int mock_bluez_device_name_mapping_add(const char *mapping) {
+	for (size_t i = 0; i < ARRAYSIZE(devices); i++)
+		if (devices[i] == NULL) {
+			devices[i] = strdup(mapping);
+			return 0;
+		}
+	return -1;
+}
+
+void mock_bluez_dbus_name_acquired(GDBusConnection *conn, const char *name, void *userdata) {
+	(void)name;
+	(void)userdata;
+
+	g_dbus_connection_register_object(conn, MOCK_BLUEZ_DEVICE_PATH_1,
+			&bluez_iface_device, &bluez_device_vtable, NULL, NULL, NULL);
+	g_dbus_connection_register_object(conn, MOCK_BLUEZ_DEVICE_PATH_2,
+			&bluez_iface_device, &bluez_device_vtable, NULL, NULL, NULL);
+
+	g_dbus_connection_register_object(conn, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1,
+			&bluez_iface_media_transport, &bluez_media_transport_vtable, NULL, NULL, NULL);
+	g_dbus_connection_register_object(conn, MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2,
+			&bluez_iface_media_transport, &bluez_media_transport_vtable, NULL, NULL, NULL);
+
+}
diff --git a/test/mock/mock.c b/test/mock/mock.c
new file mode 100644
index 0000000..83a8ab5
--- /dev/null
+++ b/test/mock/mock.c
@@ -0,0 +1,220 @@
+/*
+ * mock.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ * This program might be used to debug or check the functionality of ALSA
+ * plug-ins. It should work exactly the same as the BlueALSA server.
+ *
+ */
+
+#include "mock.h"
+
+#include <assert.h>
+#include <errno.h>
+#include <getopt.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+#include <gio/gio.h>
+#include <glib-unix.h>
+#include <glib.h>
+
+#include "a2dp.h"
+#include "ba-adapter.h"
+#include "bluealsa-config.h"
+#include "bluealsa-dbus.h"
+#include "bluealsa-iface.h"
+#include "bluez-iface.h"
+#include "storage.h"
+#include "shared/a2dp-codecs.h"
+#include "shared/defs.h"
+#include "shared/log.h"
+
+#define TEST_BLUEALSA_STORAGE_DIR "/tmp/bluealsa-mock-storage"
+
+struct ba_adapter *mock_adapter = NULL;
+GAsyncQueue *mock_sem_timeout = NULL;
+GAsyncQueue *mock_sem_quit = NULL;
+bool mock_dump_output = false;
+int mock_fuzzing_ms = 0;
+
+void mock_sem_signal(GAsyncQueue *sem) {
+	g_async_queue_push(sem, GINT_TO_POINTER(1));
+}
+
+void mock_sem_wait(GAsyncQueue *sem) {
+	g_async_queue_pop(sem);
+}
+
+static void *mock_main_loop_run(void *userdata) {
+	g_main_loop_run((GMainLoop *)userdata);
+	return NULL;
+}
+
+static int mock_sem_signal_handler(void *userdata) {
+	mock_sem_signal((GAsyncQueue *)userdata);
+	return G_SOURCE_REMOVE;
+}
+
+int main(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "hB:p:c:t:";
+	struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ "dbus", required_argument, NULL, 'B' },
+		{ "profile", required_argument, NULL, 'p' },
+		{ "codec", required_argument, NULL, 'c' },
+		{ "timeout", required_argument, NULL, 't' },
+		{ "device-name", required_argument, NULL, 2 },
+		{ "dump-output", no_argument, NULL, 6 },
+		{ "fuzzing", required_argument, NULL, 7 },
+		{ 0, 0, 0, 0 },
+	};
+
+	char ba_service[32] = BLUEALSA_SERVICE;
+	int timeout_ms = 5000;
+
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h':
+			printf("Usage:\n"
+					"  %s [OPTION]...\n"
+					"\nOptions:\n"
+					"  -h, --help\t\t\tprint this help and exit\n"
+					"  -B, --dbus=NAME\t\tBlueALSA service name suffix\n"
+					"  -p, --profile=NAME\t\tset enabled BT profiles\n"
+					"  -c, --codec=NAME\t\tset enabled BT audio codecs\n"
+					"  -t, --timeout=MSEC\t\tmock server exit timeout\n"
+					"  --device-name=MAC:NAME\tmock BT device name\n"
+					"  --dump-output\t\t\tdump Bluetooth transport data\n"
+					"  --fuzzing=MSEC\t\tmock human actions with timings\n",
+					argv[0]);
+			return EXIT_SUCCESS;
+		case 'B' /* --dbus=NAME */ :
+			snprintf(ba_service, sizeof(ba_service), BLUEALSA_SERVICE ".%s", optarg);
+			break;
+		case 'p' /* --profile=NAME */ : {
+
+			static const struct {
+				const char *name;
+				bool *ptr;
+			} map[] = {
+				{ "a2dp-source", &config.profile.a2dp_source },
+				{ "a2dp-sink", &config.profile.a2dp_sink },
+				{ "hfp-ag", &config.profile.hfp_ag },
+				{ "hsp-ag", &config.profile.hsp_ag },
+			};
+
+			bool matched = false;
+			for (size_t i = 0; i < ARRAYSIZE(map); i++)
+				if (strcasecmp(optarg, map[i].name) == 0) {
+					*map[i].ptr = true;
+					matched = true;
+					break;
+				}
+
+			if (!matched) {
+				error("Invalid BT profile name: %s", optarg);
+				return EXIT_FAILURE;
+			}
+
+			break;
+		}
+		case 'c' /* --codec=NAME */ : {
+
+			uint16_t codec_id = a2dp_codecs_codec_id_from_string(optarg);
+			bool matched = false;
+
+			struct a2dp_codec * const * cc = a2dp_codecs;
+			for (struct a2dp_codec *c = *cc; c != NULL; c = *++cc)
+				if (c->codec_id == codec_id) {
+					c->enabled = true;
+					matched = true;
+				}
+
+			if (!matched) {
+				error("Invalid BT codec name: %s", optarg);
+				return EXIT_FAILURE;
+			}
+
+			break;
+		}
+		case 't' /* --timeout=MSEC */ :
+			timeout_ms = atoi(optarg);
+			break;
+		case 2 /* --device-name=MAC:NAME */ :
+			mock_bluez_device_name_mapping_add(optarg);
+			break;
+		case 6 /* --dump-output */ :
+			mock_dump_output = true;
+			break;
+		case 7 /* --fuzzing=MSEC */ :
+			mock_fuzzing_ms = atoi(optarg);
+			break;
+		default:
+			fprintf(stderr, "Try '%s --help' for more information.\n", argv[0]);
+			return EXIT_FAILURE;
+		}
+
+	log_open(basename(argv[0]), false);
+	assert(bluealsa_config_init() == 0);
+
+	assert(mkdir(TEST_BLUEALSA_STORAGE_DIR, 0755) == 0 || errno == EEXIST);
+	assert(storage_init(TEST_BLUEALSA_STORAGE_DIR) == 0);
+
+	GTestDBus *dbus = g_test_dbus_new(G_TEST_DBUS_NONE);
+	g_test_dbus_up(dbus);
+
+	fprintf(stderr, "DBUS_SYSTEM_BUS_ADDRESS=%s\n", g_test_dbus_get_bus_address(dbus));
+	assert((config.dbus = g_dbus_connection_new_for_address_sync(
+					g_test_dbus_get_bus_address(dbus),
+					G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
+					G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION,
+					NULL, NULL, NULL)) != NULL);
+
+	/* receive EPIPE error code */
+	struct sigaction sigact = { .sa_handler = SIG_IGN };
+	sigaction(SIGPIPE, &sigact, NULL);
+
+	/* thread synchronization queues (semaphores) */
+	mock_sem_timeout = g_async_queue_new();
+	mock_sem_quit = g_async_queue_new();
+
+	/* main loop with graceful termination handlers */
+	GMainLoop *loop = g_main_loop_new(NULL, FALSE);
+	GThread *loop_th = g_thread_new(NULL, mock_main_loop_run, loop);
+	g_timeout_add(timeout_ms, mock_sem_signal_handler, mock_sem_timeout);
+	g_unix_signal_add(SIGINT, mock_sem_signal_handler, mock_sem_quit);
+	g_unix_signal_add(SIGTERM, mock_sem_signal_handler, mock_sem_quit);
+
+	bluealsa_dbus_register();
+
+	assert(g_bus_own_name_on_connection(config.dbus, ba_service,
+				G_BUS_NAME_OWNER_FLAGS_NONE, mock_bluealsa_dbus_name_acquired, NULL,
+				NULL, NULL) != 0);
+	assert(g_bus_own_name_on_connection(config.dbus, BLUEZ_SERVICE,
+				G_BUS_NAME_OWNER_FLAGS_NONE, mock_bluez_dbus_name_acquired, NULL,
+				NULL, NULL) != 0);
+
+	/* run mock until timeout or SIGINT/SIGTERM */
+	mock_sem_wait(mock_sem_quit);
+
+	ba_adapter_destroy(mock_adapter);
+
+	g_main_loop_quit(loop);
+	g_main_loop_unref(loop);
+	g_thread_join(loop_th);
+
+	return EXIT_SUCCESS;
+}
diff --git a/test/mock/mock.h b/test/mock/mock.h
new file mode 100644
index 0000000..4c131a0
--- /dev/null
+++ b/test/mock/mock.h
@@ -0,0 +1,45 @@
+/*
+ * mock.h
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
+#include <stdbool.h>
+
+#include <gio/gio.h>
+#include <glib.h>
+
+#define MOCK_ADAPTER_ID 0
+#define MOCK_DEVICE_1 "12:34:56:78:9A:BC"
+#define MOCK_DEVICE_2 "23:45:67:89:AB:CD"
+
+#define MOCK_BLUEZ_ADAPTER_PATH "/org/bluez/hci0"
+#define MOCK_BLUEZ_DEVICE_PATH_1 MOCK_BLUEZ_ADAPTER_PATH "/dev_12_34_56_78_9A_BC"
+#define MOCK_BLUEZ_DEVICE_PATH_2 MOCK_BLUEZ_ADAPTER_PATH "/dev_23_45_67_89_AB_CD"
+#define MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_1 MOCK_BLUEZ_DEVICE_PATH_1 "/fdX"
+#define MOCK_BLUEZ_MEDIA_TRANSPORT_PATH_2 MOCK_BLUEZ_DEVICE_PATH_2 "/fdX"
+#define MOCK_BLUEZ_SCO_PATH_1 MOCK_BLUEZ_DEVICE_PATH_1 "/sco"
+#define MOCK_BLUEZ_SCO_PATH_2 MOCK_BLUEZ_DEVICE_PATH_2 "/sco"
+
+extern struct ba_adapter *mock_adapter;
+
+extern GAsyncQueue *mock_sem_timeout;
+extern GAsyncQueue *mock_sem_quit;
+
+extern bool mock_dump_output;
+extern int mock_fuzzing_ms;
+
+int mock_bluez_device_name_mapping_add(const char *mapping);
+void mock_bluealsa_dbus_name_acquired(GDBusConnection *conn, const char *name, void *userdata);
+void mock_bluez_dbus_name_acquired(GDBusConnection *conn, const char *name, void *userdata);
+
+void mock_sem_signal(GAsyncQueue *sem);
+void mock_sem_wait(GAsyncQueue *sem);
diff --git a/test/test-a2dp.c b/test/test-a2dp.c
index f33e405..cb0c308 100644
--- a/test/test-a2dp.c
+++ b/test/test-a2dp.c
@@ -1,6 +1,6 @@
 /*
  * test-a2dp.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -8,21 +8,32 @@
  *
  */
 
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
 #include <errno.h>
 #include <stdbool.h>
 #include <stddef.h>
 #include <stdint.h>
+#include <stdlib.h>
 
 #include <check.h>
 #include <glib.h>
 
 #include "a2dp.h"
+#include "a2dp-aac.h"
 #include "a2dp-sbc.h"
+#include "ba-transport.h"
 #include "bluealsa-config.h"
 #include "codec-sbc.h"
 #include "shared/a2dp-codecs.h"
+#include "shared/defs.h"
 #include "shared/log.h"
 
+#include "inc/check.inc"
+
+const char *ba_transport_debug_name(const struct ba_transport *t) { (void)t; return "x"; }
 bool ba_transport_pcm_is_active(struct ba_transport_pcm *pcm) { (void)pcm; return false; }
 int ba_transport_pcm_release(struct ba_transport_pcm *pcm) { (void)pcm; return -1; }
 int ba_transport_stop_if_no_clients(struct ba_transport *t) { (void)t; return -1; }
@@ -30,42 +41,87 @@ int ba_transport_thread_bt_release(struct ba_transport_thread *th) { (void)th; r
 int ba_transport_thread_create(struct ba_transport_thread *th,
 		void *(*routine)(struct ba_transport_thread *), const char *name, bool master) {
 	(void)th; (void)routine; (void)name; (void)master; return -1; }
-int ba_transport_thread_set_state(struct ba_transport_thread *th,
-		enum ba_transport_thread_state state, bool force) {
-	(void)th; (void)state; (void)force; return -1; }
+int ba_transport_thread_state_set(struct ba_transport_thread *th,
+		enum ba_transport_thread_state state) {
+	(void)th; (void)state; return -1; }
 int ba_transport_thread_signal_recv(struct ba_transport_thread *th,
 		enum ba_transport_thread_signal *signal) {
 	(void)th; (void)signal; return -1; }
 void ba_transport_thread_cleanup(struct ba_transport_thread *th) { (void)th; }
 
-START_TEST(test_a2dp_codecs_codec_id_from_string) {
+CK_START_TEST(test_a2dp_codecs_codec_id_from_string) {
 	ck_assert_int_eq(a2dp_codecs_codec_id_from_string("SBC"), A2DP_CODEC_SBC);
 	ck_assert_int_eq(a2dp_codecs_codec_id_from_string("apt-x"), A2DP_CODEC_VENDOR_APTX);
 	ck_assert_int_eq(a2dp_codecs_codec_id_from_string("unknown"), 0xFFFF);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_codecs_codec_id_to_string) {
+CK_START_TEST(test_a2dp_codecs_codec_id_to_string) {
 	ck_assert_str_eq(a2dp_codecs_codec_id_to_string(A2DP_CODEC_SBC), "SBC");
 	ck_assert_str_eq(a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_APTX), "aptX");
 	ck_assert_ptr_eq(a2dp_codecs_codec_id_to_string(0xFFFF), NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_codecs_get_canonical_name) {
+CK_START_TEST(test_a2dp_codecs_get_canonical_name) {
 	ck_assert_str_eq(a2dp_codecs_get_canonical_name("apt-x"), "aptX");
 	ck_assert_str_eq(a2dp_codecs_get_canonical_name("Foo-Bar"), "Foo-Bar");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_dir) {
+CK_START_TEST(test_a2dp_dir) {
 	ck_assert_int_eq(A2DP_SOURCE, !A2DP_SINK);
 	ck_assert_int_eq(!A2DP_SOURCE, A2DP_SINK);
-} END_TEST
-
-START_TEST(test_a2dp_codec_lookup) {
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_codecs_init) {
+	a2dp_codecs_init();
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_codec_cmp) {
+
+	struct a2dp_codec codec1 = { .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_SBC };
+	struct a2dp_codec codec2 = { .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_MPEG24 };
+	struct a2dp_codec codec3 = { .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_VENDOR_APTX };
+	struct a2dp_codec codec4 = { .dir = A2DP_SINK, .codec_id = A2DP_CODEC_SBC };
+	struct a2dp_codec codec5 = { .dir = A2DP_SINK, .codec_id = A2DP_CODEC_VENDOR_APTX };
+	struct a2dp_codec codec6 = { .dir = A2DP_SINK, .codec_id = 0xFFFF };
+
+	struct a2dp_codec * codecs[] = { &codec3, &codec1, &codec6, &codec4, &codec5, &codec2 };
+	qsort(codecs, ARRAYSIZE(codecs), sizeof(*codecs), QSORT_COMPAR(a2dp_codec_ptr_cmp));
+
+	ck_assert_ptr_eq(codecs[0], &codec1);
+	ck_assert_ptr_eq(codecs[1], &codec2);
+	ck_assert_ptr_eq(codecs[2], &codec3);
+	ck_assert_ptr_eq(codecs[3], &codec4);
+	ck_assert_ptr_eq(codecs[4], &codec5);
+	ck_assert_ptr_eq(codecs[5], &codec6);
+
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_sep_cmp) {
+
+	struct a2dp_sep seps[] = {
+		{ .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_VENDOR_APTX },
+		{ .dir = A2DP_SINK, .codec_id = A2DP_CODEC_SBC },
+		{ .dir = A2DP_SINK, .codec_id = A2DP_CODEC_VENDOR_APTX },
+		{ .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_MPEG24 },
+		{ .dir = A2DP_SOURCE, .codec_id = A2DP_CODEC_SBC } };
+	qsort(seps, ARRAYSIZE(seps), sizeof(*seps), QSORT_COMPAR(a2dp_sep_cmp));
+
+	ck_assert_int_eq(seps[0].codec_id, A2DP_CODEC_SBC);
+	ck_assert_int_eq(seps[1].codec_id, A2DP_CODEC_MPEG24);
+	ck_assert_int_eq(seps[2].codec_id, A2DP_CODEC_VENDOR_APTX);
+	ck_assert_int_eq(seps[3].dir, A2DP_SINK);
+	ck_assert_int_eq(seps[3].codec_id, A2DP_CODEC_SBC);
+	ck_assert_int_eq(seps[4].dir, A2DP_SINK);
+	ck_assert_int_eq(seps[4].codec_id, A2DP_CODEC_VENDOR_APTX);
+
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_codec_lookup) {
 	ck_assert_ptr_eq(a2dp_codec_lookup(A2DP_CODEC_SBC, A2DP_SOURCE), &a2dp_sbc_source);
 	ck_assert_ptr_eq(a2dp_codec_lookup(0xFFFF, A2DP_SOURCE), NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_get_vendor_codec_id) {
+CK_START_TEST(test_a2dp_get_vendor_codec_id) {
 
 	uint8_t cfg0[4] = { 0xDE, 0xAD, 0xB0, 0xBE };
 	ck_assert_int_eq(a2dp_get_vendor_codec_id(cfg0, sizeof(cfg0)), 0xFFFF);
@@ -78,9 +134,9 @@ START_TEST(test_a2dp_get_vendor_codec_id) {
 	ck_assert_int_eq(a2dp_get_vendor_codec_id(&cfg2, sizeof(cfg2)), 0xFFFF);
 	ck_assert_int_eq(errno, ENOTSUP);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_check_configuration) {
+CK_START_TEST(test_a2dp_check_configuration) {
 
 	const a2dp_sbc_t cfg_valid = {
 		.frequency = SBC_SAMPLING_FREQ_44100,
@@ -106,9 +162,9 @@ START_TEST(test_a2dp_check_configuration) {
 				&cfg_invalid, sizeof(cfg_invalid)),
 			A2DP_CHECK_ERR_SAMPLING | A2DP_CHECK_ERR_CHANNELS | A2DP_CHECK_ERR_SBC_SUB_BANDS);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_filter_capabilities) {
+CK_START_TEST(test_a2dp_filter_capabilities) {
 
 	a2dp_sbc_t cfg = {
 		.frequency = SBC_SAMPLING_FREQ_44100,
@@ -120,6 +176,9 @@ START_TEST(test_a2dp_filter_capabilities) {
 		.max_bitpool = 255,
 	};
 
+	ck_assert_int_eq(a2dp_filter_capabilities(&a2dp_sbc_source, &cfg, sizeof(cfg) + 1), -1);
+	ck_assert_int_eq(errno, EINVAL);
+
 	hexdump("Capabilities original", &cfg, sizeof(cfg), true);
 	ck_assert_int_eq(a2dp_filter_capabilities(&a2dp_sbc_source, &cfg, sizeof(cfg)), 0);
 
@@ -132,9 +191,9 @@ START_TEST(test_a2dp_filter_capabilities) {
 	ck_assert_int_eq(cfg.min_bitpool, MAX(SBC_MIN_BITPOOL, 42));
 	ck_assert_int_eq(cfg.max_bitpool, MIN(SBC_MAX_BITPOOL, 255));
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_a2dp_select_configuration) {
+CK_START_TEST(test_a2dp_select_configuration) {
 
 	a2dp_sbc_t cfg;
 	const a2dp_sbc_t cfg_ = {
@@ -162,6 +221,12 @@ START_TEST(test_a2dp_select_configuration) {
 	ck_assert_int_eq(cfg.max_bitpool, 250);
 
 	cfg = cfg_;
+	config.a2dp.force_mono = true;
+	ck_assert_int_eq(a2dp_select_configuration(&a2dp_sbc_source, &cfg, sizeof(cfg)), 0);
+	ck_assert_int_eq(cfg.channel_mode, SBC_CHANNEL_MODE_MONO);
+
+	cfg = cfg_;
+	config.a2dp.force_mono = false;
 	config.a2dp.force_44100 = true;
 	config.sbc_quality = SBC_QUALITY_XQ;
 	ck_assert_int_eq(a2dp_select_configuration(&a2dp_sbc_source, &cfg, sizeof(cfg)), 0);
@@ -173,7 +238,16 @@ START_TEST(test_a2dp_select_configuration) {
 	ck_assert_int_eq(cfg.min_bitpool, 42);
 	ck_assert_int_eq(cfg.max_bitpool, 250);
 
-} END_TEST
+#if ENABLE_AAC
+	a2dp_aac_t cfg_aac = {
+		/* FDK-AAC encoder does not support AAC Long Term Prediction */
+		.object_type = AAC_OBJECT_TYPE_MPEG4_AAC_LTP,
+		AAC_INIT_FREQUENCY(AAC_SAMPLING_FREQ_44100 | AAC_SAMPLING_FREQ_96000)
+		.channels = AAC_CHANNELS_1 };
+	ck_assert_int_eq(a2dp_select_configuration(&a2dp_aac_source, &cfg_aac, sizeof(cfg_aac)), -1);
+#endif
+
+} CK_END_TEST
 
 int main(void) {
 
@@ -188,6 +262,9 @@ int main(void) {
 	tcase_add_test(tc, test_a2dp_codecs_get_canonical_name);
 
 	tcase_add_test(tc, test_a2dp_dir);
+	tcase_add_test(tc, test_a2dp_codecs_init);
+	tcase_add_test(tc, test_a2dp_codec_cmp);
+	tcase_add_test(tc, test_a2dp_sep_cmp);
 	tcase_add_test(tc, test_a2dp_codec_lookup);
 	tcase_add_test(tc, test_a2dp_get_vendor_codec_id);
 	tcase_add_test(tc, test_a2dp_check_configuration);
diff --git a/test/test-alsa-ctl.c b/test/test-alsa-ctl.c
index 2c14921..8e42692 100644
--- a/test/test-alsa-ctl.c
+++ b/test/test-alsa-ctl.c
@@ -1,6 +1,6 @@
 /*
  * test-alsa-ctl.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -12,80 +12,49 @@
 # include <config.h>
 #endif
 
+#include <errno.h>
 #include <libgen.h>
-#include <signal.h>
 #include <stdbool.h>
 #include <stdio.h>
+#include <stdlib.h>
 #include <string.h>
-#include <sys/wait.h>
 
 #include <check.h>
 #include <alsa/asoundlib.h>
 
-#include "shared/defs.h"
-
+#include "inc/check.inc"
+#include "inc/mock.inc"
 #include "inc/preload.inc"
-#include "inc/server.inc"
-
-static int snd_ctl_open_bluealsa(
-		snd_ctl_t **ctlp,
-		const char *service,
-		const char *extra_config,
-		int mode) {
-
-	char buffer[256];
-	snd_config_t *conf = NULL;
-	snd_input_t *input = NULL;
-	int err;
-
-	sprintf(buffer,
-			"ctl.bluealsa {\n"
-			"  type bluealsa\n"
-			"  service \"org.bluealsa.%s\"\n"
-			"  battery true\n"
-			"  %s\n"
-			"}\n", service, extra_config);
-
-	if ((err = snd_config_top(&conf)) < 0)
-		goto fail;
-	if ((err = snd_input_buffer_open(&input, buffer, strlen(buffer))) != 0)
-		goto fail;
-	if ((err = snd_config_load(conf, input)) != 0)
-		goto fail;
-	err = snd_ctl_open_lconf(ctlp, "bluealsa", mode, conf);
-
-fail:
-	if (conf != NULL)
-		snd_config_delete(conf);
-	if (input != NULL)
-		snd_input_close(input);
-	return err;
-}
-
-static int test_ctl_open(pid_t *pid, snd_ctl_t **ctl, int mode) {
-	const char *service = "test";
-	if ((*pid = spawn_bluealsa_server(service, 1, true, false, true, true, true, false)) == -1)
+#include "inc/spawn.inc"
+
+static int test_ctl_open(struct spawn_process *sp_ba_mock, snd_ctl_t **ctl, int mode) {
+	if (spawn_bluealsa_mock(sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--profile=a2dp-sink",
+				"--profile=hfp-ag",
+				NULL) == -1)
 		return -1;
-	return snd_ctl_open_bluealsa(ctl, service, "", mode);
+	return snd_ctl_open(ctl, "bluealsa", mode);
 }
 
-static int test_pcm_close(pid_t pid, snd_ctl_t *ctl) {
+static int test_pcm_close(struct spawn_process *sp_ba_mock, snd_ctl_t *ctl) {
 	int rv = 0;
 	if (ctl != NULL)
 		rv = snd_ctl_close(ctl);
-	if (pid != -1) {
-		kill(pid, SIGTERM);
-		waitpid(pid, NULL, 0);
+	if (sp_ba_mock != NULL) {
+		spawn_terminate(sp_ba_mock, 0);
+		spawn_close(sp_ba_mock, NULL);
 	}
 	return rv;
 }
 
-START_TEST(test_controls) {
+CK_START_TEST(test_controls) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	ck_assert_int_eq(test_ctl_open(&pid, &ctl, 0), 0);
+	ck_assert_int_eq(test_ctl_open(&sp_ba_mock, &ctl, 0), 0);
 
 	snd_ctl_elem_list_t *elems;
 	snd_ctl_elem_list_alloca(&elems);
@@ -97,36 +66,220 @@ START_TEST(test_controls) {
 
 	ck_assert_int_eq(snd_ctl_elem_list_get_used(elems), 12);
 
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 0), "12:34:56:78:9A:BC - A2DP Capture Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 1), "12:34:56:78:9A:BC - A2DP Capture Volume");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 2), "12:34:56:78:9A:BC - A2DP Playback Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "12:34:56:78:9A:BC - A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 0), "12:34:56:78:9A:BC A2DP Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 1), "12:34:56:78:9A:BC A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 2), "12:34:56:78:9A:BC A2DP Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "12:34:56:78:9A:BC A2DP Capture Volume");
+
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 4), "12:34:56:78:9A:BC SCO Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 5), "12:34:56:78:9A:BC SCO Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 6), "12:34:56:78:9A:BC SCO Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 7), "12:34:56:78:9A:BC SCO Capture Volume");
+
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 8), "23:45:67:89:AB:CD A2DP Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 9), "23:45:67:89:AB:CD A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 10), "23:45:67:89:AB:CD A2DP Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 11), "23:45:67:89:AB:CD A2DP Capture Volume");
+
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_controls_battery) {
+
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_ctl_open(&ctl, "bluealsa:BAT=yes", 0), 0);
+
+	snd_ctl_elem_list_t *elems;
+	snd_ctl_elem_list_alloca(&elems);
+
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 5);
+	ck_assert_int_eq(snd_ctl_elem_list_alloc_space(elems, 5), 0);
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+
+	ck_assert_int_eq(snd_ctl_elem_list_get_used(elems), 5);
+
+	/* battery control element shall be last */
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 4), "23:45:67:89:AB:CD | Battery Playback Volume");
+
+	snd_ctl_elem_value_t *elem;
+	snd_ctl_elem_value_alloca(&elem);
+	snd_ctl_elem_value_set_numid(elem, snd_ctl_elem_list_get_numid(elems, 4));
+
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem, 0), 75);
+	/* battery control element is read-only */
+	ck_assert_int_eq(snd_ctl_elem_write(ctl, elem), -EINVAL);
+
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_controls_extended) {
+
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_ctl_open(&ctl, "bluealsa:EXT=yes", 0), 0);
+
+	snd_ctl_elem_list_t *elems;
+	snd_ctl_elem_list_alloca(&elems);
+
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 15);
+	ck_assert_int_eq(snd_ctl_elem_list_alloc_space(elems, 15), 0);
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+
+	/* codec control element shall be after playback/capture elements */
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "12:34:56:78:9A:BC A2DP Codec Enum");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 10), "12:34:56:78:9A:BC SCO Codec Enum");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 14), "23:45:67:89:AB:CD A2DP Codec Enum");
+
+	bool has_msbc = false;
+#if ENABLE_MSBC
+	has_msbc = true;
+#endif
+
+	snd_ctl_elem_info_t *info;
+	snd_ctl_elem_info_alloca(&info);
+
+	/* 12:34:56:78:9A:BC SCO Codec Enum */
+	snd_ctl_elem_info_set_numid(info, snd_ctl_elem_list_get_numid(elems, 10));
+	ck_assert_int_eq(snd_ctl_elem_info(ctl, info), 0);
+	ck_assert_int_eq(snd_ctl_elem_info_get_items(info), has_msbc ? 2 : 1);
+	snd_ctl_elem_info_set_item(info, 0);
+	ck_assert_int_eq(snd_ctl_elem_info(ctl, info), 0);
+	ck_assert_str_eq(snd_ctl_elem_info_get_item_name(info), "CVSD");
+#if ENABLE_MSBC
+	snd_ctl_elem_info_set_item(info, 1);
+	ck_assert_int_eq(snd_ctl_elem_info(ctl, info), 0);
+	ck_assert_str_eq(snd_ctl_elem_info_get_item_name(info), "mSBC");
+#endif
+
+	snd_ctl_elem_value_t *elem;
+	snd_ctl_elem_value_alloca(&elem);
+
+	/* 12:34:56:78:9A:BC A2DP Codec Enum */
+	snd_ctl_elem_value_set_numid(elem, snd_ctl_elem_list_get_numid(elems, 3));
+	/* get currently selected A2DP codec */
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_enumerated(elem, 0), 0);
+	/* select A2DP SBC codec */
+	snd_ctl_elem_value_set_enumerated(elem, 0, 0);
+	/* write reports 0 because we are setting currently selected codec */
+	ck_assert_int_eq(snd_ctl_elem_write(ctl, elem), 0);
+
+	/* 12:34:56:78:9A:BC SCO Codec Enum */
+	snd_ctl_elem_value_set_numid(elem, snd_ctl_elem_list_get_numid(elems, 10));
+	/* get currently selected SCO codec */
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_enumerated(elem, 0), has_msbc ? 1 : 0);
+#if ENABLE_MSBC
+	/* select SCO CVSD codec */
+	snd_ctl_elem_value_set_enumerated(elem, 0, 0);
+	ck_assert_int_eq(snd_ctl_elem_write(ctl, elem), 1);
+#endif
+
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_bidirectional_a2dp) {
+#if ENABLE_FASTSTREAM
+
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--profile=a2dp-sink",
+				"--codec=FastStream",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_ctl_open(&ctl, "bluealsa:BTT=yes", 0), 0);
+
+	snd_ctl_elem_list_t *elems;
+	snd_ctl_elem_list_alloca(&elems);
+
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 10);
+	ck_assert_int_eq(snd_ctl_elem_list_alloc_space(elems, 10), 0);
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 4), "23:45:67:89:AB:C A2DP-SRC Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 5), "23:45:67:89:AB:C A2DP-SRC Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 6), "23:45:67:89:AB:C A2DP-SRC Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 7), "23:45:67:89:AB:C A2DP-SRC Capture Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 8), "23:45:67:89:AB:C A2DP-SNK Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 9), "23:45:67:89:AB:C A2DP-SNK Capture Volume");
+
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+#endif
+} CK_END_TEST
+
+CK_START_TEST(test_device_name_duplicates) {
 
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 4), "12:34:56:78:9A:BC - SCO Capture Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 5), "12:34:56:78:9A:BC - SCO Capture Volume");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 6), "12:34:56:78:9A:BC - SCO Playback Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 7), "12:34:56:78:9A:BC - SCO Playback Volume");
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
 
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 8), "23:45:67:89:AB:CD - A2DP Capture Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 9), "23:45:67:89:AB:CD - A2DP Capture Volume");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 10), "23:45:67:89:AB:CD - A2DP Playback Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 11), "23:45:67:89:AB:CD - A2DP Playback Volume");
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--device-name=12:34:56:78:9A:BC:Long Bluetooth Device Name",
+				"--device-name=23:45:67:89:AB:CD:Long Bluetooth Device Name",
+				NULL), -1);
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(snd_ctl_open(&ctl, "bluealsa", 0), 0);
 
-} END_TEST
+	snd_ctl_elem_list_t *elems;
+	snd_ctl_elem_list_alloca(&elems);
 
-START_TEST(test_mute_and_volume) {
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 4);
+	ck_assert_int_eq(snd_ctl_elem_list_alloc_space(elems, 4), 0);
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
 
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 0), "Long Bluetooth Devi #1 A2DP Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 1), "Long Bluetooth Devi #1 A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 2), "Long Bluetooth Devi #2 A2DP Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "Long Bluetooth Devi #2 A2DP Playback Volume");
+
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_mute_and_volume) {
+
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	ck_assert_int_eq(test_ctl_open(&pid, &ctl, 0), 0);
+	ck_assert_int_eq(test_ctl_open(&sp_ba_mock, &ctl, 0), 0);
 
 	snd_ctl_elem_value_t *elem_switch;
 	snd_ctl_elem_value_alloca(&elem_switch);
-	/* 23:45:67:89:AB:CD - A2DP Playback Switch */
-	snd_ctl_elem_value_set_numid(elem_switch, 11);
+	/* 23:45:67:89:AB:CD A2DP Playback Switch */
+	snd_ctl_elem_value_set_numid(elem_switch, 9);
 
 	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_switch), 0);
 	ck_assert_int_eq(snd_ctl_elem_value_get_boolean(elem_switch, 0), 1);
@@ -138,8 +291,8 @@ START_TEST(test_mute_and_volume) {
 
 	snd_ctl_elem_value_t *elem_volume;
 	snd_ctl_elem_value_alloca(&elem_volume);
-	/* 23:45:67:89:AB:CD - A2DP Playback Switch */
-	snd_ctl_elem_value_set_numid(elem_volume, 12);
+	/* 23:45:67:89:AB:CD A2DP Playback Volume */
+	snd_ctl_elem_value_set_numid(elem_volume, 10);
 
 	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_volume), 0);
 	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 0), 127);
@@ -149,42 +302,48 @@ START_TEST(test_mute_and_volume) {
 	snd_ctl_elem_value_set_integer(elem_volume, 1, 42);
 	ck_assert_int_gt(snd_ctl_elem_write(ctl, elem_volume), 0);
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_volume), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 0), 42);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 1), 42);
+
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_volume_db_range) {
+CK_START_TEST(test_volume_db_range) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	ck_assert_int_eq(test_ctl_open(&pid, &ctl, 0), 0);
+	ck_assert_int_eq(test_ctl_open(&sp_ba_mock, &ctl, 0), 0);
 
 	snd_ctl_elem_id_t *elem;
 	snd_ctl_elem_id_alloca(&elem);
-	/* 12:34:56:78:9A:BC - A2DP Playback Volume */
-	snd_ctl_elem_id_set_numid(elem, 4);
+	/* 12:34:56:78:9A:BC A2DP Playback Volume */
+	snd_ctl_elem_id_set_numid(elem, 2);
 
 	long min, max;
 	ck_assert_int_eq(snd_ctl_get_dB_range(ctl, elem, &min, &max), 0);
 	ck_assert_int_eq(min, -9600);
 	ck_assert_int_eq(max, 0);
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_single_device) {
+CK_START_TEST(test_single_device) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	const char *service = "test";
-	ck_assert_int_ne(pid = spawn_bluealsa_server(service, 1,
-				true, false, true, true, false, false), -1);
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "test", true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--profile=a2dp-sink",
+				NULL), -1);
 
-	ck_assert_int_eq(snd_ctl_open_bluealsa(&ctl, service,
-				"device \"00:00:00:00:00:00\"", 0), 0);
+	ck_assert_int_eq(snd_ctl_open(&ctl,
+				"bluealsa:DEV=00:00:00:00:00:00,SRV=org.bluealsa.test", 0), 0);
 
 	snd_ctl_card_info_t *info;
 	snd_ctl_card_info_alloca(&info);
@@ -200,86 +359,194 @@ START_TEST(test_single_device) {
 	ck_assert_int_eq(snd_ctl_elem_list_alloc_space(elems, 4), 0);
 	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
 
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 0), "A2DP Capture Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 1), "A2DP Capture Volume");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 2), "A2DP Playback Switch");
-	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 0), "A2DP Playback Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 1), "A2DP Playback Volume");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 2), "A2DP Capture Switch");
+	ck_assert_str_eq(snd_ctl_elem_list_get_name(elems, 3), "A2DP Capture Volume");
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	snd_ctl_elem_list_free_space(elems);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_single_device_not_connected) {
+CK_START_TEST(test_single_device_not_connected) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	const char *service = "test";
-	ck_assert_int_ne(pid = spawn_bluealsa_server(service, 1,
-				true, false, false, false, false, false), -1);
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				NULL), -1);
 
-	ck_assert_int_eq(snd_ctl_open_bluealsa(&ctl, service,
-				"device \"00:00:00:00:00:00\"", 0), -ENODEV);
+	ck_assert_int_eq(snd_ctl_open(&ctl,
+				"bluealsa:DEV=00:00:00:00:00:00", 0), -ENODEV);
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_single_device_no_such_device) {
+CK_START_TEST(test_single_device_no_such_device) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	const char *service = "test";
-	ck_assert_int_ne(pid = spawn_bluealsa_server(service, 1,
-				true, false, true, false, false, false), -1);
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				NULL), -1);
 
-	ck_assert_int_eq(snd_ctl_open_bluealsa(&ctl, service,
-				"device \"DE:AD:12:34:56:78\"", 0), -ENODEV);
+	ck_assert_int_eq(snd_ctl_open(&ctl,
+				"bluealsa:DEV=DE:AD:12:34:56:78", 0), -ENODEV);
 
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_notifications) {
+CK_START_TEST(test_single_device_non_dynamic) {
 
+	struct spawn_process sp_ba_mock;
 	snd_ctl_t *ctl = NULL;
-	pid_t pid = -1;
 
-	const char *service = "test";
-	ck_assert_int_ne(pid = spawn_bluealsa_server(service, -1,
-				false, true, true, false, true, false), -1);
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=0",
+				"--profile=a2dp-sink",
+				"--profile=hsp-ag",
+				"--fuzzing=500",
+				NULL), -1);
 
-	ck_assert_int_eq(snd_ctl_open_bluealsa(&ctl, service, "", 0), 0);
+	ck_assert_int_eq(snd_ctl_open(&ctl,
+				"bluealsa:DEV=23:45:67:89:AB:CD,DYN=no", 0), 0);
 	ck_assert_int_eq(snd_ctl_subscribe_events(ctl, 1), 0);
 
+	snd_ctl_elem_list_t *elems;
+	snd_ctl_elem_list_alloca(&elems);
+
 	snd_ctl_event_t *event;
 	snd_ctl_event_malloc(&event);
 
-	size_t events = 0;
-	while (snd_ctl_wait(ctl, 500) == 1) {
-		ck_assert_int_eq(snd_ctl_read(ctl, event), 1);
-		ck_assert_int_eq(snd_ctl_event_get_type(event), SND_CTL_EVENT_ELEM);
-		events++;
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 6);
+
+	snd_ctl_elem_value_t *elem_volume;
+	snd_ctl_elem_value_alloca(&elem_volume);
+	/* A2DP Capture Volume */
+	snd_ctl_elem_value_set_numid(elem_volume, 2);
+
+	snd_ctl_elem_value_set_integer(elem_volume, 0, 42);
+	ck_assert_int_gt(snd_ctl_elem_write(ctl, elem_volume), 0);
+
+	/* check whether element value was updated */
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_volume), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 0), 42);
+
+	/* Process events until we will be notified about A2DP profile disconnection.
+	 * We shall get 2 events from previous value update and 2 events for profile
+	 * disconnection (one event per switch/volume element). */
+	for (size_t events = 0; events < 4;) {
+		ck_assert_int_eq(snd_ctl_wait(ctl, 750), 1);
+		while (snd_ctl_read(ctl, event) == 1)
+			events++;
 	}
 
-#if 0
-	ck_assert_int_eq(events, 8);
+	/* the number of elements shall not change */
+	ck_assert_int_eq(snd_ctl_elem_list(ctl, elems), 0);
+	ck_assert_int_eq(snd_ctl_elem_list_get_count(elems), 6);
+
+	/* element shall be "deactivated" */
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_volume), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 0), 0);
+
+	snd_ctl_elem_value_set_integer(elem_volume, 0, 42);
+	ck_assert_int_gt(snd_ctl_elem_write(ctl, elem_volume), 0);
+
+	ck_assert_int_eq(snd_ctl_elem_read(ctl, elem_volume), 0);
+	ck_assert_int_eq(snd_ctl_elem_value_get_integer(elem_volume, 0), 0);
+
+	snd_ctl_event_free(event);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_notifications) {
+
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, false,
+				"--timeout=10000",
+				"--profile=a2dp-source",
+				"--profile=hfp-ag",
+				"--fuzzing=250",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_ctl_open(&ctl, "bluealsa:BAT=yes", 0), 0);
+
+	snd_ctl_event_t *event;
+	snd_ctl_event_malloc(&event);
+
+	ck_assert_int_eq(snd_ctl_subscribe_events(ctl, 1), 0);
+
+	size_t events = 0;
+	while (snd_ctl_wait(ctl, 500) == 1)
+		while (snd_ctl_read(ctl, event) == 1) {
+			ck_assert_int_eq(snd_ctl_event_get_type(event), SND_CTL_EVENT_ELEM);
+			events++;
+		}
+
+	ck_assert_int_eq(snd_ctl_subscribe_events(ctl, 0), 0);
+
+	size_t events_update_codec = 0;
+#if ENABLE_MSBC
+	events_update_codec += 4;
 #endif
 
+	/* Processed events:
+	 * - 0 removes; 2 new elems (12:34:... A2DP)
+	 * - 2 removes; 4 new elems (12:34:... A2DP, 23:45:... A2DP)
+	 * - 4 removes; 7 new elems (2x A2DP, SCO playback, battery)
+	 * - 7 removes; 9 new elems (2x A2DP, SCO playback/capture, battery)
+	 * - 4 updates (SCO codec update if mSBC is supported)
+	 *
+	 * XXX: It is possible that the battery element (RFCOMM D-Bus path) will not
+	 *      be exported in time. In such case, the number of events will be less
+	 *      by 2 when RFCOMM D-Bus path is not available during the playback SCO
+	 *      addition and less by another 1 when the path is not available during
+	 *      the capture SCO addition. We shall account for this in the test, as
+	 *      it is not an error. */
+	if (events == (35 + events_update_codec - 2) ||
+			events == (35 + events_update_codec - 2 - 1))
+		events = 35 + events_update_codec;
+	ck_assert_int_eq(events, (0 + 2) + (2 + 4) + (4 + 7) + (7 + 9) + events_update_codec);
+
 	snd_ctl_event_free(event);
-	ck_assert_int_eq(test_pcm_close(pid, ctl), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, ctl), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(test_alsa_high_level_control_interface) {
+
+	struct spawn_process sp_ba_mock;
+	snd_ctl_t *ctl = NULL;
+	snd_hctl_t *hctl = NULL;
+
+	ck_assert_int_eq(test_ctl_open(&sp_ba_mock, &ctl, 0), 0);
+	ck_assert_int_eq(snd_hctl_open_ctl(&hctl, ctl), 0);
+
+	ck_assert_int_eq(snd_hctl_load(hctl), 0);
+	ck_assert_int_eq(snd_hctl_get_count(hctl), 12);
+	ck_assert_int_eq(snd_hctl_free(hctl), 0);
 
-} END_TEST
+	ck_assert_int_eq(snd_hctl_close(hctl), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, NULL), 0);
 
-int main(int argc, char *argv[]) {
+} CK_END_TEST
 
-	preload(argc, argv, ".libs/aloader.so");
+int main(int argc, char *argv[], char *envp[]) {
+	preload(argc, argv, envp, ".libs/aloader.so");
 
-	/* test-alsa-ctl and bluealsa-mock shall
-	 * be placed in the same directory */
 	char *argv_0 = strdup(argv[0]);
-	bluealsa_mock_path = dirname(argv_0);
+	snprintf(bluealsa_mock_path, sizeof(bluealsa_mock_path),
+			"%s/mock/bluealsa-mock", dirname(argv_0));
 
 	Suite *s = suite_create(__FILE__);
 	TCase *tc = tcase_create(__FILE__);
@@ -288,12 +555,18 @@ int main(int argc, char *argv[]) {
 	suite_add_tcase(s, tc);
 
 	tcase_add_test(tc, test_controls);
+	tcase_add_test(tc, test_controls_battery);
+	tcase_add_test(tc, test_controls_extended);
+	tcase_add_test(tc, test_bidirectional_a2dp);
+	tcase_add_test(tc, test_device_name_duplicates);
 	tcase_add_test(tc, test_mute_and_volume);
 	tcase_add_test(tc, test_volume_db_range);
 	tcase_add_test(tc, test_single_device);
 	tcase_add_test(tc, test_single_device_not_connected);
 	tcase_add_test(tc, test_single_device_no_such_device);
+	tcase_add_test(tc, test_single_device_non_dynamic);
 	tcase_add_test(tc, test_notifications);
+	tcase_add_test(tc, test_alsa_high_level_control_interface);
 
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
diff --git a/test/test-alsa-pcm.c b/test/test-alsa-pcm.c
index 6e3aad2..775c849 100644
--- a/test/test-alsa-pcm.c
+++ b/test/test-alsa-pcm.c
@@ -1,6 +1,6 @@
 /*
  * test-alsa-pcm.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -17,13 +17,11 @@
 #include <getopt.h>
 #include <libgen.h>
 #include <poll.h>
-#include <signal.h>
 #include <stdbool.h>
 #include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sys/wait.h>
 #include <time.h>
 #include <unistd.h>
 
@@ -34,9 +32,11 @@
 #include "shared/log.h"
 #include "shared/rt.h"
 
+#include "inc/check.inc"
+#include "inc/mock.inc"
 #include "inc/preload.inc"
-#include "inc/server.inc"
 #include "inc/sine.inc"
+#include "inc/spawn.inc"
 
 #define dumprv(fn) fprintf(stderr, #fn " = %d\n", (int)fn)
 
@@ -45,45 +45,7 @@ static unsigned int pcm_channels = 2;
 static unsigned int pcm_sampling = 44100;
 static snd_pcm_format_t pcm_format = SND_PCM_FORMAT_S16_LE;
 /* big enough buffer to keep one period of data */
-static int16_t buffer[1024 * 8];
-
-static int snd_pcm_open_bluealsa(
-		snd_pcm_t **pcmp,
-		const char *service,
-		const char *extra_config,
-		snd_pcm_stream_t stream,
-		int mode) {
-
-	char buffer[256];
-	snd_config_t *conf = NULL;
-	snd_input_t *input = NULL;
-	int err;
-
-	sprintf(buffer,
-			"pcm.bluealsa {\n"
-			"  type bluealsa\n"
-			"  service \"org.bluealsa.%s\"\n"
-			"  device \"12:34:56:78:9A:BC\"\n"
-			"  profile \"a2dp\"\n"
-			"  delay 0\n"
-			"  %s\n"
-			"}\n", service, extra_config);
-
-	if ((err = snd_config_top(&conf)) < 0)
-		goto fail;
-	if ((err = snd_input_buffer_open(&input, buffer, strlen(buffer))) != 0)
-		goto fail;
-	if ((err = snd_config_load(conf, input)) != 0)
-		goto fail;
-	err = snd_pcm_open_lconf(pcmp, "bluealsa", stream, mode, conf);
-
-fail:
-	if (conf != NULL)
-		snd_config_delete(conf);
-	if (input != NULL)
-		snd_input_close(input);
-	return err;
-}
+static int16_t pcm_buffer[1024 * 8];
 
 static int set_hw_params(snd_pcm_t *pcm, snd_pcm_format_t format, int channels,
 		int rate, unsigned int *buffer_time, unsigned int *period_time) {
@@ -159,36 +121,43 @@ static int set_sw_params(snd_pcm_t *pcm, snd_pcm_uframes_t buffer_size, snd_pcm_
 	return 0;
 }
 
-static int test_pcm_open(pid_t *pid, snd_pcm_t **pcm, snd_pcm_stream_t stream) {
+static int test_pcm_open(struct spawn_process *sp_ba_mock, snd_pcm_t **pcm,
+		snd_pcm_stream_t stream) {
 
 	if (pcm_device != NULL)
 		return snd_pcm_open(pcm, pcm_device, stream, 0);
 
-	const char *service = "test";
-	if ((*pid = spawn_bluealsa_server(service, 1, true, false,
-					stream == SND_PCM_STREAM_PLAYBACK,
-					stream == SND_PCM_STREAM_CAPTURE,
-					false, false)) == -1)
+	const char *profile = NULL;
+	if (stream == SND_PCM_STREAM_PLAYBACK)
+		profile = "--profile=a2dp-source";
+	if (stream == SND_PCM_STREAM_CAPTURE)
+		profile = "--profile=a2dp-sink";
+
+	if (spawn_bluealsa_mock(sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				profile,
+				NULL) == -1)
 		return -1;
-	return snd_pcm_open_bluealsa(pcm, service, "", stream, 0);
+
+	return snd_pcm_open(pcm, "bluealsa:DEV=12:34:56:78:9A:BC", stream, 0);
 }
 
-static int test_pcm_close(pid_t pid, snd_pcm_t *pcm) {
+static int test_pcm_close(struct spawn_process *sp_ba_mock, snd_pcm_t *pcm) {
 	int rv = 0;
 	if (pcm != NULL)
 		rv = snd_pcm_close(pcm);
-	if (pid != -1) {
-		kill(pid, SIGTERM);
-		waitpid(pid, NULL, 0);
+	if (sp_ba_mock != NULL) {
+		spawn_terminate(sp_ba_mock, 0);
+		spawn_close(sp_ba_mock, NULL);
 	}
 	return rv;
 }
 
 static int16_t *test_sine_s16le(snd_pcm_uframes_t size) {
 	static size_t x = 0;
-	assert(ARRAYSIZE(buffer) >= size * pcm_channels);
-	x = snd_pcm_sine_s16_2le(buffer, size, pcm_channels, x, 441.0 / pcm_sampling);
-	return buffer;
+	assert(ARRAYSIZE(pcm_buffer) >= size * pcm_channels);
+	x = snd_pcm_sine_s16_2le(pcm_buffer, size, pcm_channels, x, 441.0 / pcm_sampling);
+	return pcm_buffer;
 }
 
 static snd_pcm_state_t snd_pcm_state_runtime(snd_pcm_t *pcm) {
@@ -200,35 +169,34 @@ static snd_pcm_state_t snd_pcm_state_runtime(snd_pcm_t *pcm) {
 	return snd_pcm_status_get_state(status);
 }
 
-START_TEST(dump_capture) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(dump_capture) {
 
 	snd_output_t *output;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
 	ck_assert_int_eq(snd_output_stdio_attach(&output, stdout, 0), 0);
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_CAPTURE), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_CAPTURE), 0);
 
 	ck_assert_int_eq(snd_pcm_dump(pcm, output), 0);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
+	ck_assert_int_eq(snd_output_close(output), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_capture_start) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_capture_start) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
 	snd_pcm_sframes_t delay;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_CAPTURE), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_CAPTURE), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -256,7 +224,7 @@ START_TEST(test_capture_start) {
 
 	/* read few periods from capture PCM */
 	for (i = 0; i < buffer_size / period_size; i++)
-		ck_assert_int_eq(snd_pcm_readi(pcm, buffer, period_size), period_size);
+		ck_assert_int_eq(snd_pcm_readi(pcm, pcm_buffer, period_size), period_size);
 
 	/* after reading there should be no more than one period of data in buffer */
 	snd_pcm_sframes_t avail;
@@ -265,21 +233,20 @@ START_TEST(test_capture_start) {
 	ck_assert_int_eq(snd_pcm_delay(pcm, &delay), 0);
 	ck_assert_int_ge(delay, avail);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_capture_pause) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_capture_pause) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_CAPTURE), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_CAPTURE), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -329,22 +296,21 @@ START_TEST(test_capture_pause) {
 
 	}
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_capture_overrun) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_capture_overrun) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_CAPTURE), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_CAPTURE), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -353,7 +319,7 @@ START_TEST(test_capture_overrun) {
 
 	/* check that PCM is running and we can read from it */
 	ck_assert_int_eq(snd_pcm_state_runtime(pcm), SND_PCM_STATE_RUNNING);
-	ck_assert_int_eq(snd_pcm_readi(pcm, buffer, period_size), period_size);
+	ck_assert_int_eq(snd_pcm_readi(pcm, pcm_buffer, period_size), period_size);
 
 	/* allow overrun to occur */
 	usleep(buffer_time + period_time);
@@ -372,21 +338,20 @@ START_TEST(test_capture_overrun) {
 
 	/* make sure that PCM is indeed readable */
 	for (i = 0; i < buffer_size / period_size; i++)
-		ck_assert_int_eq(snd_pcm_readi(pcm, buffer, period_size), period_size);
+		ck_assert_int_eq(snd_pcm_readi(pcm, pcm_buffer, period_size), period_size);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_capture_poll) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_capture_poll) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_CAPTURE), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_CAPTURE), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 
@@ -394,16 +359,19 @@ START_TEST(test_capture_poll) {
 	unsigned short revents;
 	int count = snd_pcm_poll_descriptors_count(pcm);
 	ck_assert_int_eq(snd_pcm_poll_descriptors(pcm, pfds, ARRAYSIZE(pfds)), count);
+	int rv;
 
 	ck_assert_int_eq(snd_pcm_prepare(pcm), 0);
-	/* for a capture PCM just after prepare, the poll() call shall block
-	 * forever or at least the dispatched event shall be set to 0 */
-	ck_assert_int_ne(poll(pfds, count, 250), -1);
-	snd_pcm_poll_descriptors_revents(pcm, pfds, count, &revents);
-	ck_assert_int_eq(revents, 0);
-
-	/* make sure that further calls to poll() will actually block */
-	ck_assert_int_eq(poll(pfds, count, 250), 0);
+	/* For a capture PCM just after prepare, the poll() call shall block
+	 * forever or at least the dispatched event shall be set to 0. */
+	for (;;) {
+		ck_assert_int_ne(rv = poll(pfds, count, 750), -1);
+		/* make sure that at some point poll() will actually block */
+		if (rv == 0)
+			break;
+		snd_pcm_poll_descriptors_revents(pcm, pfds, count, &revents);
+		ck_assert_int_eq(revents, 0);
+	}
 
 	ck_assert_int_eq(snd_pcm_start(pcm), 0);
 	do { /* started capture PCM shall not block forever */
@@ -413,21 +381,20 @@ START_TEST(test_capture_poll) {
 	/* we should get read event flag set */
 	ck_assert_int_eq(revents & POLLIN, POLLIN);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(dump_playback) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(dump_playback) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_output_t *output;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
 	ck_assert_int_eq(snd_output_stdio_attach(&output, stdout, 0), 0);
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 
 	ck_assert_int_eq(snd_pcm_dump(pcm, output), 0);
 
@@ -445,28 +412,50 @@ START_TEST(dump_playback) {
 	dumprv(snd_pcm_hw_params_can_resume(params));
 	dumprv(snd_pcm_hw_params_can_sync_start(params));
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
+	ck_assert_int_eq(snd_output_close(output), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(ba_test_playback_hw_constraints) {
+CK_START_TEST(ba_test_playback_hw_constraints) {
 
 	if (pcm_device != NULL)
 		return;
 
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
-
 	/* hard-coded values used in the bluealsa-mock */
 	const unsigned int server_channels = 2;
 	const unsigned int server_rate = 44100;
 
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				NULL), -1);
+
+	snd_config_t *top;
+	ck_assert_int_ge(snd_config_top(&top), 0);
+
+	const char *config =
+		"pcm.ba-direct {\n"
+		"  type bluealsa\n"
+		"  device \"12:34:56:78:9A:BC\"\n"
+		"  profile \"a2dp\"\n"
+		"}\n";
+	snd_input_t *input;
+	ck_assert_int_eq(snd_input_buffer_open(&input, config, strlen(config)), 0);
+	ck_assert_int_eq(snd_config_load(top, input), 0);
+
+	ck_assert_int_eq(snd_pcm_open_lconf(&pcm,
+				"ba-direct", SND_PCM_STREAM_PLAYBACK, 0, top), 0);
+
+	snd_config_delete(top);
+	snd_input_close(input);
+
 	snd_pcm_hw_params_t *params;
-	pid_t pid = -1;
 	int d;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
-
 	snd_pcm_hw_params_alloca(&params);
 	snd_pcm_hw_params_any(pcm, params);
 
@@ -520,46 +509,114 @@ START_TEST(ba_test_playback_hw_constraints) {
 	ck_assert_int_eq(time, 11888616);
 	ck_assert_int_eq(d, 1);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(ba_test_playback_extra_setup) {
+CK_START_TEST(ba_test_playback_no_codec_selected) {
 
 	if (pcm_device != NULL)
 		return;
 
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+	struct spawn_process sp_ba_mock;
+	snd_pcm_t *pcm = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	int rv = 0;
+#if ENABLE_MSBC
+	rv = -EAGAIN;
+#endif
+
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,PROFILE=sco",
+				SND_PCM_STREAM_PLAYBACK, 0), rv);
+
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(ba_test_playback_no_such_device) {
 
+	if (pcm_device != NULL)
+		return;
+
+	struct spawn_process sp_ba_mock;
+	snd_pcm_t *pcm = NULL;
+
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "test", true,
+				"--timeout=1000",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=DE:AD:DE:AD:DE:AD,SRV=org.bluealsa.test",
+				SND_PCM_STREAM_PLAYBACK, 0), -ENODEV);
+
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
+
+} CK_END_TEST
+
+CK_START_TEST(ba_test_playback_extra_setup) {
+
+	if (pcm_device != NULL)
+		return;
+
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
-	const char *service = "test";
-	ck_assert_int_ne(pid = spawn_bluealsa_server(service, 1, true, false, true, false, false, false), -1);
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--timeout=1000",
+				"--profile=a2dp-source",
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,CODEC=SBC",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
+
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,CODEC=SBC:ffff0822",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
+
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,PROFILE=sco,CODEC=CVSD",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
 
-	ck_assert_int_eq(snd_pcm_open_bluealsa(&pcm, service, "codec \"sbc\"", SND_PCM_STREAM_PLAYBACK, 0), 0);
-	ck_assert_int_eq(test_pcm_close(-1, pcm), 0);
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,DELAY=10",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
 
-	ck_assert_int_eq(snd_pcm_open_bluealsa(&pcm, service, "volume \"50+\"", SND_PCM_STREAM_PLAYBACK, 0), 0);
-	ck_assert_int_eq(test_pcm_close(-1, pcm), 0);
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,VOL=50+",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
 
-	ck_assert_int_eq(snd_pcm_open_bluealsa(&pcm, service, "softvol true", SND_PCM_STREAM_PLAYBACK, 0), 0);
-	ck_assert_int_eq(test_pcm_close(-1, pcm), 0);
+	ck_assert_int_eq(snd_pcm_open(&pcm,
+				"bluealsa:DEV=12:34:56:78:9A:BC,SOFTVOL=true",
+				SND_PCM_STREAM_PLAYBACK, 0), 0);
+	ck_assert_int_eq(test_pcm_close(NULL, pcm), 0);
 
-	ck_assert_int_eq(test_pcm_close(pid, NULL), 0);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_hw_set_free) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_hw_set_free) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 
 	for (i = 0; i < 5; i++) {
 		int set_hw_param_ret;
@@ -576,23 +633,22 @@ START_TEST(test_playback_hw_set_free) {
 		ck_assert_int_eq(snd_pcm_hw_free(pcm), 0);
 	}
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_start) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_start) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
 	snd_pcm_sframes_t delay;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -618,23 +674,22 @@ START_TEST(test_playback_start) {
 	ck_assert_int_eq(snd_pcm_writei(pcm, test_sine_s16le(period_size), period_size), period_size);
 	ck_assert_int_eq(snd_pcm_state_runtime(pcm), SND_PCM_STATE_RUNNING);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_drain) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_drain) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
 	struct timespec t0, t, diff;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -656,22 +711,21 @@ START_TEST(test_playback_drain) {
 	/* verify whether elapsed time is at least PCM buffer time length */
 	ck_assert_uint_gt(diff.tv_sec * 1000000 + diff.tv_nsec / 1000, buffer_time);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_pause) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_pause) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -721,12 +775,11 @@ START_TEST(test_playback_pause) {
 
 	}
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_reset) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_reset) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
@@ -734,11 +787,11 @@ START_TEST(test_playback_reset) {
 	snd_pcm_uframes_t period_size;
 	snd_pcm_sframes_t frames;
 	snd_pcm_sframes_t delay;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -778,22 +831,21 @@ retry:
 	 * a period of delay, so this test is not as strict as it should be :) */
 	ck_assert_int_le(delay, 3 * period_size / 2);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_playback_underrun) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(test_playback_underrun) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_uframes_t buffer_size;
 	snd_pcm_uframes_t period_size;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 	size_t i;
 
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_get_params(pcm, &buffer_size, &period_size), 0);
@@ -823,9 +875,9 @@ START_TEST(test_playback_underrun) {
 		ck_assert_int_eq(snd_pcm_writei(pcm, test_sine_s16le(period_size), period_size), period_size);
 	ck_assert_int_eq(snd_pcm_state_runtime(pcm), SND_PCM_STATE_RUNNING);
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-} END_TEST
+} CK_END_TEST
 
 /**
  * Make reference test for device unplug.
@@ -845,12 +897,11 @@ START_TEST(test_playback_underrun) {
  * - snd_pcm_resume(pcm) = -38
  * - snd_pcm_avail(pcm) = -19
  * - snd_pcm_avail_update(pcm) = 15081
- * - snd_pcm_writei(pcm, buffer, frames) = -19
+ * - snd_pcm_writei(pcm, pcm_buffer, frames) = -19
  * - snd_pcm_wait(pcm, 10) = -19
  * - snd_pcm_close(pcm) = 0
  */
-START_TEST(reference_playback_device_unplug) {
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
+CK_START_TEST(reference_playback_device_unplug) {
 
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
@@ -890,23 +941,21 @@ START_TEST(reference_playback_device_unplug) {
 	dumprv(snd_pcm_wait(pcm, 10));
 	dumprv(snd_pcm_close(pcm));
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(ba_test_playback_device_unplug) {
+CK_START_TEST(ba_test_playback_device_unplug) {
 
 	if (pcm_device != NULL)
 		return;
 
-	fprintf(stderr, "\nSTART TEST: %s (%s:%d)\n", __func__, __FILE__, __LINE__);
-
 	unsigned int buffer_time = 200000;
 	unsigned int period_time = 25000;
 	snd_pcm_sframes_t frames = 0;
+	struct spawn_process sp_ba_mock;
 	snd_pcm_t *pcm = NULL;
-	pid_t pid = -1;
 
 	ck_assert_ptr_eq(pcm_device, NULL);
-	ck_assert_int_eq(test_pcm_open(&pid, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
+	ck_assert_int_eq(test_pcm_open(&sp_ba_mock, &pcm, SND_PCM_STREAM_PLAYBACK), 0);
 	ck_assert_int_eq(set_hw_params(pcm, pcm_format, pcm_channels, pcm_sampling,
 				&buffer_time, &period_time), 0);
 	ck_assert_int_eq(snd_pcm_prepare(pcm), 0);
@@ -940,13 +989,12 @@ START_TEST(ba_test_playback_device_unplug) {
 	ck_assert_int_eq(snd_pcm_close(pcm), 0);
 #endif
 
-	ck_assert_int_eq(test_pcm_close(pid, pcm), 0);
-
-} END_TEST
+	ck_assert_int_eq(test_pcm_close(&sp_ba_mock, pcm), 0);
 
-int main(int argc, char *argv[]) {
+} CK_END_TEST
 
-	preload(argc, argv, ".libs/aloader.so");
+int main(int argc, char *argv[], char *envp[]) {
+	preload(argc, argv, envp, ".libs/aloader.so");
 
 	int opt;
 	const char *opts = "hD:c:f:r:";
@@ -959,6 +1007,10 @@ int main(int argc, char *argv[]) {
 		{ 0, 0, 0, 0 },
 	};
 
+	bool run_capture = false;
+	bool run_playback = false;
+	bool run_unplug = false;
+
 	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
 		switch (opt) {
 		case 'h' /* --help */ :
@@ -982,51 +1034,61 @@ int main(int argc, char *argv[]) {
 			return 1;
 		}
 
-	/* test-alsa-pcm and bluealsa-mock shall
-	 * be placed in the same directory */
 	char *argv_0 = strdup(argv[0]);
-	bluealsa_mock_path = dirname(argv_0);
-
-	Suite *s = suite_create(__FILE__);
-	SRunner *sr = srunner_create(s);
-
-	TCase *tc_capture = tcase_create("capture");
-	tcase_add_test(tc_capture, dump_capture);
-	tcase_add_test(tc_capture, test_capture_start);
-	tcase_add_test(tc_capture, test_capture_pause);
-	tcase_add_test(tc_capture, test_capture_overrun);
-	tcase_add_test(tc_capture, test_capture_poll);
-
-	TCase *tc_playback = tcase_create("playback");
-	tcase_add_test(tc_playback, dump_playback);
-	tcase_add_test(tc_playback, ba_test_playback_hw_constraints);
-	tcase_add_test(tc_playback, ba_test_playback_extra_setup);
-	tcase_add_test(tc_playback, test_playback_hw_set_free);
-	tcase_add_test(tc_playback, test_playback_start);
-	tcase_add_test(tc_playback, test_playback_drain);
-	tcase_add_test(tc_playback, test_playback_pause);
-	tcase_add_test(tc_playback, test_playback_reset);
-	tcase_add_test(tc_playback, test_playback_underrun);
-	tcase_add_test(tc_playback, ba_test_playback_device_unplug);
-
-	TCase *tc_unplug = tcase_create("unplug");
-	tcase_add_test(tc_unplug, reference_playback_device_unplug);
+	snprintf(bluealsa_mock_path, sizeof(bluealsa_mock_path),
+			"%s/mock/bluealsa-mock", dirname(argv_0));
 
 	if (argc == optind) {
-		suite_add_tcase(s, tc_capture);
-		suite_add_tcase(s, tc_playback);
+		run_capture = true;
+		run_playback = true;
 	}
 	else {
 		for (; optind < argc; optind++) {
 			if (strcmp(argv[optind], "capture") == 0)
-				suite_add_tcase(s, tc_capture);
+				run_capture = true;
 			else if (strcmp(argv[optind], "playback") == 0)
-				suite_add_tcase(s, tc_playback);
+				run_playback = true;
 			else if (strcmp(argv[optind], "unplug") == 0)
-				suite_add_tcase(s, tc_unplug);
+				run_unplug = true;
 		}
 	}
 
+	Suite *s = suite_create(__FILE__);
+	SRunner *sr = srunner_create(s);
+
+	if (run_capture) {
+		TCase *tc = tcase_create("capture");
+		tcase_add_test(tc, dump_capture);
+		tcase_add_test(tc, test_capture_start);
+		tcase_add_test(tc, test_capture_pause);
+		tcase_add_test(tc, test_capture_overrun);
+		tcase_add_test(tc, test_capture_poll);
+		suite_add_tcase(s, tc);
+	}
+
+	if (run_playback) {
+		TCase *tc = tcase_create("playback");
+		tcase_add_test(tc, dump_playback);
+		tcase_add_test(tc, ba_test_playback_hw_constraints);
+		tcase_add_test(tc, ba_test_playback_no_codec_selected);
+		tcase_add_test(tc, ba_test_playback_no_such_device);
+		tcase_add_test(tc, ba_test_playback_extra_setup);
+		tcase_add_test(tc, test_playback_hw_set_free);
+		tcase_add_test(tc, test_playback_start);
+		tcase_add_test(tc, test_playback_drain);
+		tcase_add_test(tc, test_playback_pause);
+		tcase_add_test(tc, test_playback_reset);
+		tcase_add_test(tc, test_playback_underrun);
+		tcase_add_test(tc, ba_test_playback_device_unplug);
+		suite_add_tcase(s, tc);
+	}
+
+	if (run_unplug) {
+		TCase *tc = tcase_create("unplug");
+		tcase_add_test(tc, reference_playback_device_unplug);
+		suite_add_tcase(s, tc);
+	}
+
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
 
diff --git a/test/test-at.c b/test/test-at.c
index cd23deb..14e20ae 100644
--- a/test/test-at.c
+++ b/test/test-at.c
@@ -1,6 +1,6 @@
 /*
  * test-at.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -9,6 +9,7 @@
  */
 
 #include <stdbool.h>
+#include <stdint.h>
 #include <string.h>
 
 #include <check.h>
@@ -16,7 +17,14 @@
 #include "at.h"
 #include "hfp.h"
 
-START_TEST(test_at_build) {
+#include "inc/check.inc"
+
+CK_START_TEST(test_at_type2str) {
+	ck_assert_str_eq(at_type2str(AT_TYPE_RAW), "RAW");
+	ck_assert_str_eq(at_type2str(AT_TYPE_RESP), "RESP");
+} CK_END_TEST
+
+CK_START_TEST(test_at_build) {
 
 	char buffer[256];
 
@@ -33,90 +41,90 @@ START_TEST(test_at_build) {
 	/* build unsolicited result code */
 	ck_assert_str_eq(at_build(buffer, sizeof(buffer), AT_TYPE_RESP, NULL, "OK"), "\r\nOK\r\n");
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_invalid) {
+CK_START_TEST(test_at_parse_invalid) {
 	struct bt_at at;
 	/* invalid AT command lines */
 	ck_assert_ptr_eq(at_parse("ABC\r", &at), NULL);
 	ck_assert_ptr_eq(at_parse("AT+CLCK?", &at), NULL);
 	ck_assert_ptr_eq(at_parse("\r\r", &at), NULL);
 	ck_assert_ptr_eq(at_parse("\r\nOK", &at), NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cmd) {
+CK_START_TEST(test_at_parse_cmd) {
 	struct bt_at at;
 	/* parse AT plain command */
 	ck_assert_ptr_ne(at_parse("AT+CLCC\r", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_CMD);
 	ck_assert_str_eq(at.command, "+CLCC");
 	ck_assert_ptr_eq(at.value, NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cmd_get) {
+CK_START_TEST(test_at_parse_cmd_get) {
 	struct bt_at at;
 	/* parse AT GET command */
 	ck_assert_ptr_ne(at_parse("AT+COPS?\r", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_CMD_GET);
 	ck_assert_str_eq(at.command, "+COPS");
 	ck_assert_ptr_eq(at.value, NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cmd_set) {
+CK_START_TEST(test_at_parse_cmd_set) {
 	struct bt_at at;
 	/* parse AT SET command */
 	ck_assert_ptr_ne(at_parse("AT+CLCK=\"SC\",0,\"1234\"\r", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_CMD_SET);
 	ck_assert_str_eq(at.command, "+CLCK");
 	ck_assert_str_eq(at.value, "\"SC\",0,\"1234\"");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cmd_test) {
+CK_START_TEST(test_at_parse_cmd_test) {
 	struct bt_at at;
 	/* parse AT TEST command */
 	ck_assert_ptr_ne(at_parse("AT+COPS=?\r", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_CMD_TEST);
 	ck_assert_str_eq(at.command, "+COPS");
 	ck_assert_ptr_eq(at.value, NULL);
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_resp) {
+CK_START_TEST(test_at_parse_resp) {
 	struct bt_at at;
 	/* parse response result code */
 	ck_assert_ptr_ne(at_parse("\r\n+CIND:0,0,1,4,0,4,0\r\n", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_RESP);
 	ck_assert_str_eq(at.command, "+CIND");
 	ck_assert_str_eq(at.value, "0,0,1,4,0,4,0");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_resp_empty) {
+CK_START_TEST(test_at_parse_resp_empty) {
 	struct bt_at at;
 	/* parse response result code with empty value */
 	ck_assert_ptr_ne(at_parse("\r\n+CIND:\r\n", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_RESP);
 	ck_assert_str_eq(at.command, "+CIND");
 	ck_assert_str_eq(at.value, "");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_resp_unsolicited) {
+CK_START_TEST(test_at_parse_resp_unsolicited) {
 	struct bt_at at;
 	/* parse unsolicited result code */
 	ck_assert_ptr_ne(at_parse("\r\nRING\r\n", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_RESP);
 	ck_assert_str_eq(at.command, "");
 	ck_assert_str_eq(at.value, "RING");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_case_sensitivity) {
+CK_START_TEST(test_at_parse_case_sensitivity) {
 	struct bt_at at;
 	/* case-insensitive command and case-sensitive value */
 	ck_assert_ptr_ne(at_parse("aT+tEsT=VaLuE\r", &at), NULL);
 	ck_assert_int_eq(at.type, AT_TYPE_CMD_SET);
 	ck_assert_str_eq(at.command, "+TEST");
 	ck_assert_str_eq(at.value, "VaLuE");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_multiple_cmds) {
+CK_START_TEST(test_at_parse_multiple_cmds) {
 	struct bt_at at;
 	/* concatenated commands */
 	const char *cmd = "\r\nOK\r\n\r\n+COPS:1\r\n";
@@ -124,9 +132,9 @@ START_TEST(test_at_parse_multiple_cmds) {
 	ck_assert_int_eq(at.type, AT_TYPE_RESP);
 	ck_assert_str_eq(at.command, "");
 	ck_assert_str_eq(at.value, "OK");
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_bia) {
+CK_START_TEST(test_at_parse_set_bia) {
 
 	const bool state_ok1[__HFP_IND_MAX] = { 0, true, true, true, true, true, true, true };
 	const bool state_ok2[__HFP_IND_MAX] = { 0, true, false, true, true, true, true, false };
@@ -134,73 +142,87 @@ START_TEST(test_at_parse_bia) {
 	const bool state_ok4[__HFP_IND_MAX] = { 0, true, true, false, false, true, true, true };
 	bool state[__HFP_IND_MAX] = { 0 };
 
-	ck_assert_int_eq(at_parse_bia("1,1,1,1,1,1,1", state), 0);
+	ck_assert_int_eq(at_parse_set_bia("1,1,1,1,1,1,1", state), 0);
 	ck_assert_int_eq(memcmp(state, state_ok1, sizeof(state)), 0);
 
-	ck_assert_int_eq(at_parse_bia("1,0,1,1,1,1,0", state), 0);
+	ck_assert_int_eq(at_parse_set_bia("1,0,1,1,1,1,0", state), 0);
 	ck_assert_int_eq(memcmp(state, state_ok2, sizeof(state)), 0);
 
 	/* omitted values shall not be changed */
-	ck_assert_int_eq(at_parse_bia(",,0,0,,,1", state), 0);
+	ck_assert_int_eq(at_parse_set_bia(",,0,0,,,1", state), 0);
 	ck_assert_int_eq(memcmp(state, state_ok3, sizeof(state)), 0);
 
 	/* truncated values shall not be changed */
-	ck_assert_int_eq(at_parse_bia("1,1", state), 0);
+	ck_assert_int_eq(at_parse_set_bia("1,1", state), 0);
 	ck_assert_int_eq(memcmp(state, state_ok4, sizeof(state)), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cind) {
+CK_START_TEST(test_at_parse_get_cind) {
 
 	enum hfp_ind indmap[20];
 	enum hfp_ind indmap_ok[20];
 
 	/* parse +CIND response result code */
-	ck_assert_int_eq(at_parse_cind("(\"call\",(0,1)),(\"xxx\",(0-3)),(\"signal\",(0-5))", indmap), 0);
+	ck_assert_int_eq(at_parse_get_cind("(\"call\",(0,1)),(\"xxx\",(0-3)),(\"signal\",(0-5))", indmap), 0);
 	memset(indmap_ok, HFP_IND_NULL, sizeof(indmap_ok));
 	indmap_ok[0] = HFP_IND_CALL;
 	indmap_ok[2] = HFP_IND_SIGNAL;
 	ck_assert_int_eq(memcmp(indmap, indmap_ok, sizeof(indmap)), 0);
 
 	/* parse +CIND response with white-spaces */
-	ck_assert_int_eq(at_parse_cind(" ( \"call\", ( 0, 1 ) ), ( \"signal\", ( 0-3 ) )", indmap), 0);
+	ck_assert_int_eq(at_parse_get_cind(" ( \"call\", ( 0, 1 ) ), ( \"signal\", ( 0-3 ) )", indmap), 0);
 
 	/* parse +CIND invalid response */
-	ck_assert_int_eq(at_parse_cind("(incorrect,1-2)", indmap), -1);
+	ck_assert_int_eq(at_parse_get_cind("(incorrect,1-2)", indmap), -1);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_parse_cmer) {
+CK_START_TEST(test_at_parse_set_cmer) {
 
 	const unsigned int cmer_ok1[5] = { 3, 0, 0, 1, 0 };
 	const unsigned int cmer_ok2[5] = { 2, 0, 0, 1, 0 };
 	unsigned int cmer[5];
 
 	/* parse +CMER value */
-	ck_assert_int_eq(at_parse_cmer("3,0,0,1,0", cmer), 0);
+	ck_assert_int_eq(at_parse_set_cmer("3,0,0,1,0", cmer), 0);
 	ck_assert_int_eq(memcmp(cmer, cmer_ok1, sizeof(cmer)), 0);
 
 	/* parse +CMER value with white-spaces */
-	ck_assert_int_eq(at_parse_cmer("3, 0, 0 , 1 , 0", cmer), 0);
+	ck_assert_int_eq(at_parse_set_cmer("3, 0, 0 , 1 , 0", cmer), 0);
 	ck_assert_int_eq(memcmp(cmer, cmer_ok1, sizeof(cmer)), 0);
 
 	/* parse +CMER value with less elements */
-	ck_assert_int_eq(at_parse_cmer("2,0", cmer), 0);
+	ck_assert_int_eq(at_parse_set_cmer("2,0", cmer), 0);
 	ck_assert_int_eq(memcmp(cmer, cmer_ok2, sizeof(cmer)), 0);
 
 	/* parse +CMER empty value */
-	ck_assert_int_eq(at_parse_cmer("", cmer), 0);
+	ck_assert_int_eq(at_parse_set_cmer("", cmer), 0);
 	ck_assert_int_eq(memcmp(cmer, cmer_ok2, sizeof(cmer)), 0);
 
 	/* parse +CMER invalid value */
-	ck_assert_int_eq(at_parse_cmer("3,error", cmer), -1);
+	ck_assert_int_eq(at_parse_set_cmer("3,error", cmer), -1);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_at_type2str) {
-	ck_assert_str_eq(at_type2str(AT_TYPE_RAW), "RAW");
-	ck_assert_str_eq(at_type2str(AT_TYPE_RESP), "RESP");
-} END_TEST
+CK_START_TEST(test_at_parse_set_xapl) {
+
+	uint16_t vendor, product, version;
+	uint8_t features;
+
+	ck_assert_int_eq(at_parse_set_xapl("ABCD-1234-0100,10", &vendor, &product, &version, &features), 0);
+	ck_assert_int_eq(vendor, 0xABCD);
+	ck_assert_int_eq(product, 0x1234);
+	ck_assert_int_eq(version, 0x0100);
+	ck_assert_int_eq(features, 10);
+
+	/* parse invalid feature value which shall be a 10-base number */
+	ck_assert_int_eq(at_parse_set_xapl("ABCD-1234-0100,1A", &vendor, &product, &version, &features), -1);
+
+	/* parse invalid number of parameters */
+	ck_assert_int_eq(at_parse_set_xapl("ABCD-1234,10", &vendor, &product, &version, &features), -1);
+
+} CK_END_TEST
 
 int main(void) {
 
@@ -210,6 +232,7 @@ int main(void) {
 
 	suite_add_tcase(s, tc);
 
+	tcase_add_test(tc, test_at_type2str);
 	tcase_add_test(tc, test_at_build);
 	tcase_add_test(tc, test_at_parse_invalid);
 	tcase_add_test(tc, test_at_parse_cmd);
@@ -221,10 +244,10 @@ int main(void) {
 	tcase_add_test(tc, test_at_parse_resp_unsolicited);
 	tcase_add_test(tc, test_at_parse_case_sensitivity);
 	tcase_add_test(tc, test_at_parse_multiple_cmds);
-	tcase_add_test(tc, test_at_parse_bia);
-	tcase_add_test(tc, test_at_parse_cind);
-	tcase_add_test(tc, test_at_parse_cmer);
-	tcase_add_test(tc, test_at_type2str);
+	tcase_add_test(tc, test_at_parse_set_bia);
+	tcase_add_test(tc, test_at_parse_get_cind);
+	tcase_add_test(tc, test_at_parse_set_cmer);
+	tcase_add_test(tc, test_at_parse_set_xapl);
 
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
diff --git a/test/test-audio.c b/test/test-audio.c
index 3e60e4f..4f36cde 100644
--- a/test/test-audio.c
+++ b/test/test-audio.c
@@ -1,6 +1,6 @@
 /*
  * test-audio.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -16,7 +16,9 @@
 #include "audio.h"
 #include "shared/defs.h"
 
-START_TEST(test_audio_interleave_deinterleave_s16_2le) {
+#include "inc/check.inc"
+
+CK_START_TEST(test_audio_interleave_deinterleave_s16_2le) {
 
 	const int16_t ch1[] = { 0x0123, 0x1234, 0x2345, 0x3456 };
 	const int16_t ch2[] = { 0x4567, 0x5678, 0x6789, 0x789A };
@@ -35,9 +37,9 @@ START_TEST(test_audio_interleave_deinterleave_s16_2le) {
 	ck_assert_int_eq(memcmp(dest_ch1, ch1, sizeof(ch1)), 0);
 	ck_assert_int_eq(memcmp(dest_ch2, ch2, sizeof(ch1)), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_audio_interleave_deinterleave_s32_4le) {
+CK_START_TEST(test_audio_interleave_deinterleave_s32_4le) {
 
 	const int32_t ch1[] = { 0x01234567, 0x12345678, 0x23456789, 0x3456789A };
 	const int32_t ch2[] = { 0x456789AB, 0x56789ABC, 0x6789ABCD, 0x789ABCDE };
@@ -56,9 +58,9 @@ START_TEST(test_audio_interleave_deinterleave_s32_4le) {
 	ck_assert_int_eq(memcmp(dest_ch1, ch1, sizeof(ch1)), 0);
 	ck_assert_int_eq(memcmp(dest_ch2, ch2, sizeof(ch1)), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_audio_scale_s16_2le) {
+CK_START_TEST(test_audio_scale_s16_2le) {
 
 	const int16_t mute[] = { 0x0000, 0x0000, 0x0000, 0x0000 };
 	const int16_t mute_l[] = { 0x0000, 0x2345, 0x0000, (int16_t)0xCDEF };
@@ -97,9 +99,9 @@ START_TEST(test_audio_scale_s16_2le) {
 	audio_scale_s16_2le(tmp, ARRAYSIZE(tmp) / 2, 2, 1.0, 0.5);
 	ck_assert_int_eq(memcmp(tmp, half_r, sizeof(half_r)), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_audio_scale_s32_4le) {
+CK_START_TEST(test_audio_scale_s32_4le) {
 
 	const int32_t mute[] = { 0, 0, 0, 0 };
 	const int32_t mute_l[] = { 0, 0x23456789, 0, 0x00ABCDEF };
@@ -124,7 +126,7 @@ START_TEST(test_audio_scale_s32_4le) {
 	audio_scale_s32_4le(tmp, ARRAYSIZE(tmp) / 2, 2, 1.0, 0.5);
 	ck_assert_int_eq(memcmp(tmp, half_r, sizeof(half_r)), 0);
 
-} END_TEST
+} CK_END_TEST
 
 int main(void) {
 
diff --git a/test/test-ba.c b/test/test-ba.c
index bfbbdce..0ba8480 100644
--- a/test/test-ba.c
+++ b/test/test-ba.c
@@ -1,6 +1,6 @@
 /*
  * test-ba.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -12,10 +12,15 @@
 # include <config.h>
 #endif
 
+#include <assert.h>
+#include <errno.h>
+#include <pthread.h>
 #include <stdbool.h>
 #include <stddef.h>
 #include <stdint.h>
-#include <unistd.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
 
 #include <bluetooth/bluetooth.h>
 #include <bluetooth/hci.h>
@@ -23,17 +28,33 @@
 #include <glib.h>
 
 #include "a2dp.h"
+#include "a2dp-aac.h"
+#include "a2dp-aptx-hd.h"
+#include "a2dp-aptx.h"
+#include "a2dp-faststream.h"
+#include "a2dp-lc3plus.h"
+#include "a2dp-ldac.h"
+#include "a2dp-mpeg.h"
+#include "a2dp-sbc.h"
 #include "ba-adapter.h"
 #include "ba-device.h"
 #include "ba-rfcomm.h"
 #include "ba-transport.h"
+#include "bluealsa-config.h"
 #include "bluealsa-dbus.h"
 #include "bluez.h"
-#include "sco.h"
+#include "hfp.h"
+#if ENABLE_OFONO
+# include "ofono.h"
+#endif
+#include "storage.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/log.h"
 
 #include "../src/ba-transport.c"
+#include "inc/check.inc"
+
+#define TEST_BLUEALSA_STORAGE_DIR "/tmp/bluealsa-test-ba-storage"
 
 void a2dp_aac_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_aac_transport_start(struct ba_transport *t) { (void)t; return 0; }
@@ -51,28 +72,29 @@ void a2dp_mpeg_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_mpeg_transport_start(struct ba_transport *t) { (void)t; return 0; }
 void a2dp_sbc_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_sbc_transport_start(struct ba_transport *t) { (void)t; return 0; }
+void *sco_enc_thread(struct ba_transport_thread *th);
 
 void *ba_rfcomm_thread(struct ba_transport *t) { (void)t; return 0; }
-void *sco_enc_thread(struct ba_transport_thread *th) { return sleep(3600), th; }
-void *sco_dec_thread(struct ba_transport_thread *th) { return sleep(3600), th; }
 int bluealsa_dbus_pcm_register(struct ba_transport_pcm *pcm) {
-	debug("%s: %p", __func__, (void *)pcm); return 0; }
+	debug("%s: %p", __func__, (void *)pcm); (void)pcm; return 0; }
 void bluealsa_dbus_pcm_update(struct ba_transport_pcm *pcm, unsigned int mask) {
-	debug("%s: %p %#x", __func__, (void *)pcm, mask); }
+	debug("%s: %p %#x", __func__, (void *)pcm, mask); (void)pcm; (void)mask; }
 void bluealsa_dbus_pcm_unregister(struct ba_transport_pcm *pcm) {
-	debug("%s: %p", __func__, (void *)pcm); }
+	debug("%s: %p", __func__, (void *)pcm); (void)pcm; }
 struct ba_rfcomm *ba_rfcomm_new(struct ba_transport *sco, int fd) {
-	debug("%s: %p", __func__, (void *)sco); (void)fd; return NULL; }
+	debug("%s: %p", __func__, (void *)sco); (void)sco; (void)fd; return NULL; }
 void ba_rfcomm_destroy(struct ba_rfcomm *r) {
-	debug("%s: %p", __func__, (void *)r); }
+	debug("%s: %p", __func__, (void *)r); (void)r; }
 int ba_rfcomm_send_signal(struct ba_rfcomm *r, enum ba_rfcomm_signal sig) {
-	debug("%s: %p: %#x", __func__, (void *)r, sig); return 0; }
+	debug("%s: %p: %#x", __func__, (void *)r, sig); (void)r; (void)sig; return 0; }
 bool bluez_a2dp_set_configuration(const char *current_dbus_sep_path,
 		const struct a2dp_sep *sep, GError **error) {
-	debug("%s: %s", __func__, current_dbus_sep_path); (void)sep;
-	(void)error; return false; }
+	debug("%s: %s: %p", __func__, current_dbus_sep_path, sep);
+	(void)current_dbus_sep_path; (void)sep; (void)error; return false; }
+int ofono_call_volume_update(struct ba_transport *t) {
+	debug("%s: %p", __func__, t); (void)t; return 0; }
 
-START_TEST(test_ba_adapter) {
+CK_START_TEST(test_ba_adapter) {
 
 	struct ba_adapter *a;
 
@@ -85,12 +107,13 @@ START_TEST(test_ba_adapter) {
 	ck_assert_str_eq(a->hci.name, "hci5");
 
 	ck_assert_ptr_eq(ba_adapter_lookup(5), a);
+	ba_adapter_unref(a);
 
 	ba_adapter_unref(a);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_ba_device) {
+CK_START_TEST(test_ba_device) {
 
 	struct ba_adapter *a;
 	struct ba_device *d;
@@ -108,12 +131,13 @@ START_TEST(test_ba_device) {
 	ck_assert_str_eq(d->bluez_dbus_path, "/org/bluez/hci0/dev_AB_90_78_56_34_12");
 
 	ck_assert_ptr_eq(ba_device_lookup(a, &addr), d);
+	ba_device_unref(d);
 
 	ba_device_unref(d);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_ba_transport) {
+CK_START_TEST(test_ba_transport) {
 
 	struct ba_adapter *a;
 	struct ba_device *d;
@@ -129,17 +153,121 @@ START_TEST(test_ba_transport) {
 	ba_device_unref(d);
 
 	ck_assert_ptr_eq(t->d, d);
-	ck_assert_int_eq(t->type.profile, BA_TRANSPORT_PROFILE_NONE);
+	ck_assert_int_eq(t->profile, BA_TRANSPORT_PROFILE_NONE);
 	ck_assert_str_eq(t->bluez_dbus_owner, "/owner");
 	ck_assert_str_eq(t->bluez_dbus_path, "/path");
 
 	ck_assert_ptr_eq(ba_transport_lookup(d, "/path"), t);
+	ba_transport_unref(t);
 
 	ba_transport_unref(t);
 
-} END_TEST
+} CK_END_TEST
+
+CK_START_TEST(test_ba_transport_sco_one_only) {
+
+	struct ba_adapter *a;
+	struct ba_device *d;
+	struct ba_transport *t_sco_hsp;
+	struct ba_transport *t_sco_hfp;
+	bdaddr_t addr = { 0 };
+
+	ck_assert_ptr_ne(a = ba_adapter_new(0), NULL);
+	ck_assert_ptr_ne(d = ba_device_new(a, &addr), NULL);
+
+	t_sco_hsp = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HSP_AG, "/owner", "/path/sco", -1);
+	ck_assert_ptr_ne(t_sco_hsp, NULL);
+
+	t_sco_hfp = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HFP_AG, "/owner", "/path/sco", -1);
+	ck_assert_ptr_eq(t_sco_hfp, NULL);
+	ck_assert_int_eq(errno, EBUSY);
+
+	ba_transport_unref(t_sco_hsp);
+
+	ba_adapter_unref(a);
+	ba_device_unref(d);
+
+} CK_END_TEST
+
+CK_START_TEST(test_ba_transport_sco_default_codec) {
+
+	struct ba_adapter *a;
+	struct ba_device *d;
+	struct ba_transport *t_sco;
+	bdaddr_t addr = { 0 };
+
+	ck_assert_ptr_ne(a = ba_adapter_new(0), NULL);
+	ck_assert_ptr_ne(d = ba_device_new(a, &addr), NULL);
+
+	t_sco = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HSP_AG, "/owner", "/path/sco", -1);
+	ck_assert_int_eq(ba_transport_get_codec(t_sco), HFP_CODEC_CVSD);
+	ba_transport_unref(t_sco);
+
+#if ENABLE_MSBC
+
+	a->hci.features[2] = LMP_TRSP_SCO;
+	a->hci.features[3] = LMP_ESCO;
+
+	config.hfp.codecs.msbc = true;
+	t_sco = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HFP_AG, "/owner", "/path/sco", -1);
+	ck_assert_int_eq(ba_transport_get_codec(t_sco), HFP_CODEC_UNDEFINED);
+	ba_transport_unref(t_sco);
+
+	config.hfp.codecs.msbc = false;
+	t_sco = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HFP_AG, "/owner", "/path/sco", -1);
+	ck_assert_int_eq(ba_transport_get_codec(t_sco), HFP_CODEC_CVSD);
+	ba_transport_unref(t_sco);
+
+#else
+	t_sco = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HFP_AG, "/owner", "/path/sco", -1);
+	ck_assert_int_eq(ba_transport_get_codec(t_sco), HFP_CODEC_CVSD);
+	ba_transport_unref(t_sco);
+#endif
+
+	ba_adapter_unref(a);
+	ba_device_unref(d);
+
+} CK_END_TEST
+
+static void *cleanup_thread(struct ba_transport_thread *th) {
+	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+	ba_transport_thread_cleanup(th);
+	return NULL;
+}
+
+CK_START_TEST(test_ba_transport_threads_sync_termination) {
+
+	struct ba_adapter *a;
+	struct ba_device *d;
+	struct ba_transport *t_sco;
+	bdaddr_t addr = { 0 };
+
+	ck_assert_ptr_ne(a = ba_adapter_new(0), NULL);
+	ck_assert_ptr_ne(d = ba_device_new(a, &addr), NULL);
+
+	t_sco = ba_transport_new_sco(d, BA_TRANSPORT_PROFILE_HSP_AG, "/owner", "/path/sco", -1);
+	ck_assert_ptr_ne(t_sco, NULL);
+
+	t_sco->bt_fd = 0;
+	t_sco->mtu_read = 48;
+	t_sco->mtu_write = 48;
 
-START_TEST(test_ba_transport_pcm_format) {
+	ck_assert_int_eq(ba_transport_thread_create(&t_sco->thread_enc, sco_enc_thread, "enc", true), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_running(&t_sco->thread_enc), 0);
+
+	ck_assert_int_eq(ba_transport_thread_create(&t_sco->thread_dec, cleanup_thread, "dec", false), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_running(&t_sco->thread_dec), -1);
+
+	ck_assert_int_eq(ba_transport_thread_state_wait_terminated(&t_sco->thread_enc), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_terminated(&t_sco->thread_dec), 0);
+
+	ba_transport_unref(t_sco);
+	ba_adapter_unref(a);
+	ba_device_unref(d);
+
+} CK_END_TEST
+
+CK_START_TEST(test_ba_transport_pcm_format) {
 
 	uint16_t format_u8 = BA_TRANSPORT_PCM_FORMAT_U8;
 	uint16_t format_s32_4le = BA_TRANSPORT_PCM_FORMAT_S32_4LE;
@@ -156,9 +284,9 @@ START_TEST(test_ba_transport_pcm_format) {
 	ck_assert_int_eq(BA_TRANSPORT_PCM_FORMAT_BYTES(format_s32_4le), 4);
 	ck_assert_int_eq(BA_TRANSPORT_PCM_FORMAT_ENDIAN(format_s32_4le), 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_ba_transport_pcm_volume) {
+CK_START_TEST(test_ba_transport_pcm_volume) {
 
 	struct ba_adapter *a;
 	struct ba_device *d;
@@ -169,46 +297,40 @@ START_TEST(test_ba_transport_pcm_volume) {
 	ck_assert_ptr_ne(a = ba_adapter_new(0), NULL);
 	ck_assert_ptr_ne(d = ba_device_new(a, &addr), NULL);
 
-	struct ba_transport_type ttype_a2dp = { .profile = BA_TRANSPORT_PROFILE_A2DP_SINK };
 	struct a2dp_codec codec = { .dir = A2DP_SINK, .codec_id = A2DP_CODEC_SBC };
 	a2dp_sbc_t configuration = { .channel_mode = SBC_CHANNEL_MODE_STEREO };
-	ck_assert_ptr_ne(t_a2dp = ba_transport_new_a2dp(d, ttype_a2dp,
-				"/owner", "/path", &codec, &configuration), NULL);
+	ck_assert_ptr_ne(t_a2dp = ba_transport_new_a2dp(d,
+				BA_TRANSPORT_PROFILE_A2DP_SINK, "/owner", "/path/a2dp", &codec,
+				&configuration), NULL);
 
-	struct ba_transport_type ttype_sco = { .profile = BA_TRANSPORT_PROFILE_HFP_AG };
-	ck_assert_ptr_ne(t_sco = ba_transport_new_sco(d, ttype_sco, "/owner", "/path", -1), NULL);
+	ck_assert_ptr_ne(t_sco = ba_transport_new_sco(d,
+				BA_TRANSPORT_PROFILE_HFP_AG, "/owner", "/path/sco", -1), NULL);
 
 	ba_adapter_unref(a);
 	ba_device_unref(d);
 
-	ck_assert_int_eq(t_a2dp->a2dp.pcm.max_bt_volume, 127);
-	ck_assert_int_eq(t_a2dp->a2dp.pcm_bc.max_bt_volume, 127);
-
-	ck_assert_int_eq(t_sco->sco.spk_pcm.max_bt_volume, 15);
-	ck_assert_int_eq(t_sco->sco.mic_pcm.max_bt_volume, 15);
+	ck_assert_int_eq(ba_transport_pcm_volume_range_to_level(0, BLUEZ_A2DP_VOLUME_MAX), -9600);
+	ck_assert_int_eq(ba_transport_pcm_volume_level_to_range(-9600, BLUEZ_A2DP_VOLUME_MAX), 0);
 
-	ck_assert_int_eq(ba_transport_pcm_volume_bt_to_level(&t_a2dp->a2dp.pcm, 0), -9600);
-	ck_assert_int_eq(ba_transport_pcm_volume_level_to_bt(&t_a2dp->a2dp.pcm, -9600), 0);
+	ck_assert_int_eq(ba_transport_pcm_volume_range_to_level(127, BLUEZ_A2DP_VOLUME_MAX), 0);
+	ck_assert_int_eq(ba_transport_pcm_volume_level_to_range(0, BLUEZ_A2DP_VOLUME_MAX), 127);
 
-	ck_assert_int_eq(ba_transport_pcm_volume_bt_to_level(&t_a2dp->a2dp.pcm, 127), 0);
-	ck_assert_int_eq(ba_transport_pcm_volume_level_to_bt(&t_a2dp->a2dp.pcm, 0), 127);
+	ck_assert_int_eq(ba_transport_pcm_volume_range_to_level(0, HFP_VOLUME_GAIN_MAX), -9600);
+	ck_assert_int_eq(ba_transport_pcm_volume_level_to_range(-9600, HFP_VOLUME_GAIN_MAX), 0);
 
-	ck_assert_int_eq(ba_transport_pcm_volume_bt_to_level(&t_sco->sco.spk_pcm, 0), -9600);
-	ck_assert_int_eq(ba_transport_pcm_volume_level_to_bt(&t_sco->sco.spk_pcm, -9600), 0);
-
-	ck_assert_int_eq(ba_transport_pcm_volume_bt_to_level(&t_sco->sco.spk_pcm, 15), 0);
-	ck_assert_int_eq(ba_transport_pcm_volume_level_to_bt(&t_sco->sco.spk_pcm, 0), 15);
+	ck_assert_int_eq(ba_transport_pcm_volume_range_to_level(15, HFP_VOLUME_GAIN_MAX), 0);
+	ck_assert_int_eq(ba_transport_pcm_volume_level_to_range(0, HFP_VOLUME_GAIN_MAX), 15);
 
 	ba_transport_unref(t_a2dp);
 	ba_transport_unref(t_sco);
 
-} END_TEST
+} CK_END_TEST
 
 static int test_cascade_free_transport_unref(struct ba_transport *t) {
 	return ba_transport_unref(t), 0;
 }
 
-START_TEST(test_cascade_free) {
+CK_START_TEST(test_cascade_free) {
 
 	struct ba_adapter *a;
 	struct ba_device *d;
@@ -223,10 +345,80 @@ START_TEST(test_cascade_free) {
 	ba_device_unref(d);
 	ba_adapter_destroy(a);
 
-} END_TEST
+} CK_END_TEST
+
+CK_START_TEST(test_storage) {
+
+	const char *storage_path = TEST_BLUEALSA_STORAGE_DIR "/00:11:22:33:44:55";
+	const char *storage_data =
+		"[/org/bluealsa/hci0/dev_00_11_22_33_44_55/a2dpsnk/source]\n"
+		"SoftVolume=false\n"
+		"Volume=-5600;-4800;\n"
+		"Mute=false;true;\n";
+
+	FILE *f;
+	ck_assert_ptr_ne(f = fopen(storage_path, "w"), NULL);
+	ck_assert_int_eq(fwrite(storage_data, strlen(storage_data), 1, f), 1);
+	ck_assert_int_eq(fclose(f), 0);
+
+	struct ba_adapter *a;
+	struct ba_device *d;
+	struct ba_transport *t;
+
+	bdaddr_t addr;
+	str2ba(&storage_path[sizeof(TEST_BLUEALSA_STORAGE_DIR)], &addr);
+
+	ck_assert_ptr_ne(a = ba_adapter_new(0), NULL);
+	ck_assert_ptr_ne(d = ba_device_new(a, &addr), NULL);
+
+	struct a2dp_codec codec = { .dir = A2DP_SINK, .codec_id = A2DP_CODEC_SBC };
+	a2dp_sbc_t configuration = { .channel_mode = SBC_CHANNEL_MODE_STEREO };
+	ck_assert_ptr_ne(t = ba_transport_new_a2dp(d,
+				BA_TRANSPORT_PROFILE_A2DP_SINK, "/owner", "/path", &codec,
+				&configuration), NULL);
+
+	/* check if persistent storage was loaded */
+	ck_assert_int_eq(t->a2dp.pcm.soft_volume, false);
+	ck_assert_int_eq(t->a2dp.pcm.volume[0].level, -5600);
+	ck_assert_int_eq(t->a2dp.pcm.volume[0].soft_mute, false);
+	ck_assert_int_eq(t->a2dp.pcm.volume[1].level, -4800);
+	ck_assert_int_eq(t->a2dp.pcm.volume[1].soft_mute, true);
+
+	bool muted = true;
+	int level = ba_transport_pcm_volume_range_to_level(100, BLUEZ_A2DP_VOLUME_MAX);
+	ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[0], &level, &muted, NULL);
+	ba_transport_pcm_volume_set(&t->a2dp.pcm.volume[1], &level, &muted, NULL);
+
+	ba_transport_unref(t);
+	ba_adapter_unref(a);
+	ba_device_unref(d);
+
+	char buffer[1024] = { 0 };
+	ck_assert_ptr_ne(f = fopen(storage_path, "r"), NULL);
+	ck_assert_int_eq(fread(buffer, 1, sizeof(buffer), f), 212);
+	ck_assert_int_eq(fclose(f), 0);
+
+	const char *storage_data_new =
+		"[/org/bluealsa/hci0/dev_00_11_22_33_44_55/a2dpsnk/source]\n"
+		"SoftVolume=false\n"
+		"Volume=-344;-344;\n"
+		"Mute=true;true;\n"
+		"\n"
+		"[/org/bluealsa/hci0/dev_00_11_22_33_44_55/a2dpsnk/sink]\n"
+		"SoftVolume=true\n"
+		"Volume=0;0;\n"
+		"Mute=false;false;\n";
+
+	/* check if persistent storage was updated */
+	ck_assert_str_eq(buffer, storage_data_new);
+
+} CK_END_TEST
 
 int main(void) {
 
+	assert(mkdir(TEST_BLUEALSA_STORAGE_DIR, 0755) == 0 || errno == EEXIST);
+	assert(storage_init(TEST_BLUEALSA_STORAGE_DIR) == 0);
+
 	Suite *s = suite_create(__FILE__);
 	TCase *tc = tcase_create(__FILE__);
 	SRunner *sr = srunner_create(s);
@@ -236,9 +428,13 @@ int main(void) {
 	tcase_add_test(tc, test_ba_adapter);
 	tcase_add_test(tc, test_ba_device);
 	tcase_add_test(tc, test_ba_transport);
+	tcase_add_test(tc, test_ba_transport_sco_one_only);
+	tcase_add_test(tc, test_ba_transport_sco_default_codec);
+	tcase_add_test(tc, test_ba_transport_threads_sync_termination);
 	tcase_add_test(tc, test_ba_transport_pcm_format);
 	tcase_add_test(tc, test_ba_transport_pcm_volume);
 	tcase_add_test(tc, test_cascade_free);
+	tcase_add_test(tc, test_storage);
 
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
diff --git a/test/test-io.c b/test/test-io.c
index db7d436..aab5856 100644
--- a/test/test-io.c
+++ b/test/test-io.c
@@ -1,6 +1,6 @@
 /*
  * test-io.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -13,6 +13,7 @@
 #endif
 
 #include <errno.h>
+#include <fcntl.h>
 #include <getopt.h>
 #include <poll.h>
 #include <pthread.h>
@@ -37,6 +38,28 @@
 #endif
 
 #include "a2dp.h"
+#if ENABLE_AAC
+# include "a2dp-aac.h"
+#endif
+#if ENABLE_APTX
+# include "a2dp-aptx.h"
+#endif
+#if ENABLE_APTX_HD
+# include "a2dp-aptx-hd.h"
+#endif
+#if ENABLE_FASTSTREAM
+# include "a2dp-faststream.h"
+#endif
+#if ENABLE_LC3PLUS
+# include "a2dp-lc3plus.h"
+#endif
+#if ENABLE_LDAC
+# include "a2dp-ldac.h"
+#endif
+#if ENABLE_MPEG
+# include "a2dp-mpeg.h"
+#endif
+#include "a2dp-sbc.h"
 #include "ba-adapter.h"
 #include "ba-device.h"
 #include "ba-rfcomm.h"
@@ -46,23 +69,21 @@
 #include "bluez.h"
 #include "hfp.h"
 #include "io.h"
-#include "rtp.h"
-#include "sco.h"
+#if ENABLE_OFONO
+# include "ofono.h"
+#endif
+#if ENABLE_LC3PLUS || ENABLE_LDAC
+# include "rtp.h"
+#endif
+#include "storage.h"
 #include "shared/a2dp-codecs.h"
 #include "shared/defs.h"
 #include "shared/log.h"
 
 #include "../src/a2dp.c"
-#include "../src/a2dp-aac.c"
-#include "../src/a2dp-aptx-hd.c"
-#include "../src/a2dp-aptx.c"
-#include "../src/a2dp-faststream.c"
-#include "../src/a2dp-lc3plus.c"
-#include "../src/a2dp-ldac.c"
-#include "../src/a2dp-mpeg.c"
-#include "../src/a2dp-sbc.c"
 #include "../src/ba-transport.c"
 #include "inc/btd.inc"
+#include "inc/check.inc"
 #include "inc/sine.inc"
 
 #define CHECK_VERSION ( \
@@ -70,22 +91,47 @@
 		(CHECK_MINOR_VERSION << 8 & 0x00ff00) | \
 		(CHECK_MICRO_VERSION << 0 & 0x0000ff))
 
+void *a2dp_aac_dec_thread(struct ba_transport_thread *th);
+void *a2dp_aac_enc_thread(struct ba_transport_thread *th);
+void *a2dp_aptx_dec_thread(struct ba_transport_thread *th);
+void *a2dp_aptx_enc_thread(struct ba_transport_thread *th);
+void *a2dp_aptx_hd_dec_thread(struct ba_transport_thread *th);
+void *a2dp_aptx_hd_enc_thread(struct ba_transport_thread *th);
+void *a2dp_faststream_dec_thread(struct ba_transport_thread *th);
+void *a2dp_faststream_enc_thread(struct ba_transport_thread *th);
+void *a2dp_lc3plus_dec_thread(struct ba_transport_thread *th);
+void *a2dp_lc3plus_enc_thread(struct ba_transport_thread *th);
+void *a2dp_ldac_dec_thread(struct ba_transport_thread *th);
+void *a2dp_ldac_enc_thread(struct ba_transport_thread *th);
+void *a2dp_mp3_enc_thread(struct ba_transport_thread *th);
+void *a2dp_mpeg_dec_thread(struct ba_transport_thread *th);
+void *a2dp_sbc_dec_thread(struct ba_transport_thread *th);
+void *a2dp_sbc_enc_thread(struct ba_transport_thread *th);
+void *sco_dec_thread(struct ba_transport_thread *th);
+void *sco_enc_thread(struct ba_transport_thread *th);
+
 int bluealsa_dbus_pcm_register(struct ba_transport_pcm *pcm) {
-	debug("%s: %p", __func__, (void *)pcm); return 0; }
+	debug("%s: %p", __func__, (void *)pcm); (void)pcm; return 0; }
 void bluealsa_dbus_pcm_update(struct ba_transport_pcm *pcm, unsigned int mask) {
-	debug("%s: %p %#x", __func__, (void *)pcm, mask); }
+	debug("%s: %p %#x", __func__, (void *)pcm, mask); (void)pcm; (void)mask; }
 void bluealsa_dbus_pcm_unregister(struct ba_transport_pcm *pcm) {
-	debug("%s: %p", __func__, (void *)pcm); }
+	debug("%s: %p", __func__, (void *)pcm); (void)pcm; }
 struct ba_rfcomm *ba_rfcomm_new(struct ba_transport *sco, int fd) {
-	debug("%s: %p", __func__, (void *)sco); (void)fd; return NULL; }
+	debug("%s: %p", __func__, (void *)sco); (void)sco; (void)fd; return NULL; }
 void ba_rfcomm_destroy(struct ba_rfcomm *r) {
-	debug("%s: %p", __func__, (void *)r); }
+	debug("%s: %p", __func__, (void *)r); (void)r; }
 int ba_rfcomm_send_signal(struct ba_rfcomm *r, enum ba_rfcomm_signal sig) {
-	debug("%s: %p: %#x", __func__, (void *)r, sig); return 0; }
+	debug("%s: %p: %#x", __func__, (void *)r, sig); (void)r; (void)sig; return 0; }
 bool bluez_a2dp_set_configuration(const char *current_dbus_sep_path,
 		const struct a2dp_sep *sep, GError **error) {
-	debug("%s: %s", __func__, current_dbus_sep_path); (void)sep;
-	(void)error; return false; }
+	debug("%s: %s: %p", __func__, current_dbus_sep_path, sep);
+	(void)current_dbus_sep_path; (void)sep; (void)error; return false; }
+int ofono_call_volume_update(struct ba_transport *t) {
+	debug("%s: %p", __func__, t); (void)t; return 0; }
+int storage_device_load(const struct ba_device *d) { (void)d; return 0; }
+int storage_device_save(const struct ba_device *d) { (void)d; return 0; }
+int storage_pcm_data_sync(struct ba_transport_pcm *pcm) { (void)pcm; return 0; }
+int storage_pcm_data_update(const struct ba_transport_pcm *pcm) { (void)pcm; return 0; }
 
 static const a2dp_sbc_t config_sbc_44100_stereo = {
 	.frequency = SBC_SAMPLING_FREQ_44100,
@@ -160,7 +206,8 @@ static bool enable_vbr_mode = false;
 static bool dump_data = false;
 static bool packet_loss = false;
 
-static struct bt_dump *btd = NULL;
+/* input BT dump file */
+static struct bt_dump *btdin = NULL;
 
 #if HAVE_SNDFILE
 static void *pcm_write_frames_sndfile_async(void *userdata) {
@@ -324,7 +371,7 @@ static void bt_data_write(struct ba_transport *t) {
 
 	if (input_bt_file != NULL) {
 
-		while ((len = bt_dump_read(btd, buffer, sizeof(buffer))) != -1) {
+		while ((len = bt_dump_read(btdin, buffer, sizeof(buffer))) != -1) {
 			if (packet_loss && random() < INT32_MAX / 3 && !first_packet) {
 				debug("Simulating packet loss: Dropping BT packet!");
 				continue;
@@ -379,12 +426,12 @@ static void *test_io_thread_dump_bt(struct ba_transport_thread *th) {
 
 	if (dump_data) {
 		char fname[64];
-		sprintf(fname, "encoded-%s.btd", transport_type_to_fname(t->type));
+		sprintf(fname, "encoded-%s.btd", transport_to_fname(t));
 		ck_assert_ptr_ne(btd = bt_dump_create(fname, t), NULL);
 	}
 
 	debug_transport_thread_loop(th, "START");
-	ba_transport_thread_set_state_running(th);
+	ba_transport_thread_state_set_running(th);
 	while (poll(pfds, ARRAYSIZE(pfds), 500) > 0) {
 
 		if ((len = io_bt_read(th, buffer, sizeof(buffer))) <= 0) {
@@ -416,32 +463,8 @@ static void *test_io_thread_dump_pcm(struct ba_transport_thread *th) {
 
 	pthread_cleanup_push(PTHREAD_CLEANUP(ba_transport_thread_cleanup), th);
 
-	struct ba_transport *t = th->t;
-	struct ba_transport_pcm *t_pcm = NULL;
-
-	switch (t->type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		t_pcm = &t->a2dp.pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_A2DP_SINK:
-		t_pcm = &t->a2dp.pcm_bc;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		t_pcm = &t->sco.spk_pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_HF:
-	case BA_TRANSPORT_PROFILE_HSP_HS:
-		t_pcm = &t->sco.mic_pcm;
-		break;
-	default:
-		g_assert_not_reached();
-	}
-
-	struct pollfd pfds[] = {{ t_pcm->fd, POLLIN, 0 }};
+	struct ba_transport_pcm *t_pcm = th->pcm;
 	size_t decoded_samples_total = 0;
-	uint8_t buffer[2048];
-	ssize_t len;
 
 #if HAVE_SNDFILE
 	SNDFILE *sf = NULL;
@@ -471,7 +494,7 @@ static void *test_io_thread_dump_pcm(struct ba_transport_thread *th) {
 	if (dump_data) {
 #if HAVE_SNDFILE
 		char fname[64];
-		sprintf(fname, "decoded-%s.wav", transport_type_to_fname(t->type));
+		sprintf(fname, "decoded-%s.wav", transport_to_fname(th->t));
 		ck_assert_ptr_ne(sf = sf_open(fname, SFM_WRITE, &sf_info), NULL);
 #else
 		error("Dumping audio files requires sndfile library!");
@@ -479,8 +502,18 @@ static void *test_io_thread_dump_pcm(struct ba_transport_thread *th) {
 	}
 
 	debug_transport_thread_loop(th, "START");
-	ba_transport_thread_set_state_running(th);
-	while (poll(pfds, ARRAYSIZE(pfds), 500) > 0) {
+	for (ba_transport_thread_state_set_running(th);;) {
+
+		struct pollfd pfds[] = {{ -1, POLLIN, 0 }};
+		uint8_t buffer[2048];
+		ssize_t len;
+
+		pthread_mutex_lock(&t_pcm->mutex);
+		pfds[0].fd = t_pcm->fd;
+		pthread_mutex_unlock(&t_pcm->mutex);
+
+		if (poll(pfds, ARRAYSIZE(pfds), 500) <= 0)
+			break;
 
 		if ((len = read(pfds[0].fd, buffer, sizeof(buffer))) == -1) {
 			debug("PCM read error: %s", strerror(errno));
@@ -543,46 +576,8 @@ static void test_io(struct ba_transport *t_src, struct ba_transport *t_snk,
 	if (dec == test_io_thread_dump_bt && input_bt_file != NULL)
 		return;
 
-	struct ba_transport_pcm *t_src_pcm = NULL;
-	struct ba_transport_pcm *t_snk_pcm = NULL;
-
-	switch (t_src->type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		t_src_pcm = &t_src->a2dp.pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_A2DP_SINK:
-		t_src_pcm = &t_src->a2dp.pcm_bc;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		t_src_pcm = &t_src->sco.spk_pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_HF:
-	case BA_TRANSPORT_PROFILE_HSP_HS:
-		t_src_pcm = &t_src->sco.mic_pcm;
-		break;
-	default:
-		g_assert_not_reached();
-	}
-
-	switch (t_snk->type.profile) {
-	case BA_TRANSPORT_PROFILE_A2DP_SOURCE:
-		t_snk_pcm = &t_snk->a2dp.pcm_bc;
-		break;
-	case BA_TRANSPORT_PROFILE_A2DP_SINK:
-		t_snk_pcm = &t_snk->a2dp.pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_AG:
-	case BA_TRANSPORT_PROFILE_HSP_AG:
-		t_snk_pcm = &t_snk->sco.mic_pcm;
-		break;
-	case BA_TRANSPORT_PROFILE_HFP_HF:
-	case BA_TRANSPORT_PROFILE_HSP_HS:
-		t_snk_pcm = &t_snk->sco.spk_pcm;
-		break;
-	default:
-		g_assert_not_reached();
-	}
+	struct ba_transport_pcm *t_src_pcm = t_src->thread_enc.pcm;
+	struct ba_transport_pcm *t_snk_pcm = t_snk->thread_dec.pcm;
 
 	int bt_fds[2];
 	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK, 0, bt_fds), 0);
@@ -621,20 +616,18 @@ static void test_io(struct ba_transport *t_src, struct ba_transport *t_snk,
 	ba_transport_pcm_release(t_src_pcm);
 	pthread_mutex_unlock(&t_src_pcm->mutex);
 
-	transport_thread_cancel_prepare(&t_src->thread_enc);
-	transport_thread_cancel(&t_src->thread_enc);
+	ba_transport_stop(t_src);
 
 	pthread_mutex_lock(&t_snk_pcm->mutex);
 	ba_transport_pcm_release(t_snk_pcm);
 	pthread_mutex_unlock(&t_snk_pcm->mutex);
 
-	transport_thread_cancel_prepare(&t_snk->thread_dec);
-	transport_thread_cancel(&t_snk->thread_dec);
+	ba_transport_stop(t_snk);
 
 }
 
 static int test_transport_acquire(struct ba_transport *t) {
-	debug("Acquire transport: %d", t->bt_fd);
+	debug("Acquire transport: %d", t->bt_fd); (void)t;
 	return 0;
 }
 
@@ -645,15 +638,15 @@ static int test_transport_release_bt_a2dp(struct ba_transport *t) {
 
 static struct ba_transport *test_transport_new_a2dp(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_path,
 		const struct a2dp_codec *codec,
 		const void *configuration) {
 #if DEBUG
 	if (input_bt_file != NULL)
-		configuration = &btd->a2dp_configuration;
+		configuration = &btdin->a2dp_configuration;
 #endif
-	struct ba_transport *t = ba_transport_new_a2dp(device, type, ":test",
+	struct ba_transport *t = ba_transport_new_a2dp(device, profile, ":test",
 			dbus_path, codec, configuration);
 	t->acquire = test_transport_acquire;
 	t->release = test_transport_release_bt_a2dp;
@@ -662,31 +655,28 @@ static struct ba_transport *test_transport_new_a2dp(
 
 static struct ba_transport *test_transport_new_sco(
 		struct ba_device *device,
-		struct ba_transport_type type,
+		enum ba_transport_profile profile,
 		const char *dbus_path) {
-	struct ba_transport *t = ba_transport_new_sco(device, type, ":test",
+	struct ba_transport *t = ba_transport_new_sco(device, profile, ":test",
 			dbus_path, -1);
 	t->acquire = test_transport_acquire;
 	return t;
 }
 
-START_TEST(test_a2dp_sbc) {
+CK_START_TEST(test_a2dp_sbc) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_SBC };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/sbc",
-			&a2dp_sbc_source, &config_sbc_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/sbc",
-			&a2dp_sbc_sink, &config_sbc_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/sbc", &a2dp_sbc_source,
+			&config_sbc_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/sbc", &a2dp_sbc_sink,
+			&config_sbc_44100_stereo);
 
 	if (aging_duration) {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 153 * 3;
 		test_io(t1, t2, a2dp_sbc_enc_thread, a2dp_sbc_dec_thread, 4 * 1024);
 	}
 	else {
-		debug("\n\n*** A2DP codec: SBC ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 153 * 3;
 		test_io(t1, t2, a2dp_sbc_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 		test_io(t1, t2, test_io_thread_dump_pcm, a2dp_sbc_dec_thread, 2 * 1024);
@@ -695,28 +685,159 @@ START_TEST(test_a2dp_sbc) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_sbc_invalid_config) {
+
+	const a2dp_sbc_t config_sbc_invalid = { 0 };
+	struct ba_transport *t = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/sbc", &a2dp_sbc_source,
+			&config_sbc_invalid);
+
+	int bt_fds[2];
+	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK, 0, bt_fds), 0);
+	debug("Created BT socket pair: %d, %d", bt_fds[0], bt_fds[1]);
+	t->mtu_read = t->mtu_write = 153 * 3;
+	t->bt_fd = bt_fds[1];
+
+	struct ba_transport_thread *th = &t->thread_enc;
+	ck_assert_int_eq(ba_transport_thread_create(th, a2dp_sbc_enc_thread, "sbc", true), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_running(th), -1);
+
+	ba_transport_destroy(t);
+	close(bt_fds[0]);
+
+} CK_END_TEST
+
+CK_START_TEST(test_a2dp_sbc_pcm_drop) {
+
+	int16_t pcm_zero[90] = { 0 };
+	int16_t pcm_rand[ARRAYSIZE(pcm_zero)];
+	for (size_t i = 0; i < ARRAYSIZE(pcm_rand); i++)
+		pcm_rand[i] = rand();
+
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/sbc", &a2dp_sbc_source,
+			&config_sbc_44100_stereo);
+
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/sbc", &a2dp_sbc_sink,
+			&config_sbc_44100_stereo);
+
+	struct ba_transport_thread *th1 = &t1->thread_enc;
+	struct ba_transport_thread *th2 = &t2->thread_dec;
+
+	int bt_fds[2];
+	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_NONBLOCK, 0, bt_fds), 0);
+	debug("Created BT socket pair: %d, %d", bt_fds[0], bt_fds[1]);
+	t1->mtu_read = t1->mtu_write = 256;
+	t2->mtu_read = t2->mtu_write = 256;
+	t1->bt_fd = bt_fds[1];
+	t2->bt_fd = bt_fds[0];
+
+	int pcm_snk_fds[2];
+	ck_assert_int_eq(pipe2(pcm_snk_fds, O_NONBLOCK), 0);
+	debug("Created PCM pipe pair: %d, %d", pcm_snk_fds[0], pcm_snk_fds[1]);
+	th1->pcm->fd = pcm_snk_fds[0];
+	th1->pcm->active = true;
+
+	int pcm_src_fds[2];
+	ck_assert_int_eq(pipe2(pcm_src_fds, O_NONBLOCK), 0);
+	debug("Created PCM pipe pair: %d, %d", pcm_src_fds[0], pcm_src_fds[1]);
+	th2->pcm->fd = pcm_src_fds[1];
+	th2->pcm->active = true;
+
+	/* sink PCM */
+	struct ba_transport_pcm *pcm = th1->pcm;
+
+	/* write zero samples to PCM FIFO until it is full */
+	while (write(pcm_snk_fds[1], pcm_zero, sizeof(pcm_zero)) > 0)
+		continue;
+
+	/* drop PCM samples before IO thread was started */
+	ck_assert_int_eq(ba_transport_pcm_drop(pcm), 0);
+
+	/* start IO thread and make sure it is running */
+	ck_assert_int_eq(ba_transport_thread_create(th1, a2dp_sbc_enc_thread, "sbc", true), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_running(th1), 0);
+
+	/* wait for 50 ms - let the thread to run for a while */
+	usleep(50000);
+
+	uint8_t bt_buffer[1024];
+	/* try to read data from BT socket - it should be empty because
+	 * the PCM has been dropped before the thread was started */
+	ck_assert_int_eq(read(bt_fds[0], bt_buffer, sizeof(bt_buffer)), -1);
+	ck_assert_int_eq(errno, EAGAIN);
+
+	/* write non-zero samples to PCM FIFO and process some of it */
+	while (write(pcm_snk_fds[1], pcm_rand, sizeof(pcm_rand)) > 0)
+		continue;
+	usleep(50000);
+
+	/* drop PCM samples */
+	ck_assert_int_eq(ba_transport_pcm_drop(pcm), 0);
+
+	/* write a little bit of non-zero samples and wait for processing; the
+	 * number of samples shall be small enough to not produce a single codec
+	 * frame, but enough to fill-in internal buffers */
+	ck_assert_int_gt(write(pcm_snk_fds[1], pcm_rand, sizeof(pcm_rand)), 0);
+	usleep(10000);
+
+	/* again drop PCM samples */
+	ck_assert_int_eq(ba_transport_pcm_drop(pcm), 0);
+
+	/* flush already processed data */
+	while (read(bt_fds[0], bt_buffer, sizeof(bt_buffer)) > 0)
+		continue;
+
+	/* After PCM has been dropped, IO thread should not process any more
+	 * non-zero samples. We will check this by writing zero samples and
+	 * checking if decoded data is all silence. */
+
+	ck_assert_int_eq(ba_transport_thread_create(th2, a2dp_sbc_dec_thread, "sbc", true), 0);
+	ck_assert_int_eq(ba_transport_thread_state_wait_running(th2), 0);
+
+	/* write some zero samples to PCM FIFO and process them */
+	for (size_t i = 0; i < 100; i++)
+		if (write(pcm_snk_fds[1], pcm_zero, sizeof(pcm_zero)) <= 0)
+			break;
+	usleep(250000);
+
+	ssize_t rv;
+	int16_t pcm_buffer[1024];
+	/* read decoded data and check if it is all silence */
+	while ((rv = read(pcm_src_fds[0], pcm_buffer, sizeof(pcm_buffer))) > 0) {
+		const size_t samples = rv / sizeof(pcm_buffer[0]);
+		for (size_t i = 0; i < samples; i++)
+			ck_assert_int_eq(pcm_buffer[i], 0);
+	}
+
+	ba_transport_destroy(t1);
+	ba_transport_destroy(t2);
+	close(pcm_snk_fds[0]);
+	close(pcm_src_fds[1]);
+	close(bt_fds[0]);
+
+} CK_END_TEST
 
 #if ENABLE_MP3LAME
-START_TEST(test_a2dp_mp3) {
+CK_START_TEST(test_a2dp_mp3) {
 
 	config_mp3_44100_stereo.vbr = enable_vbr_mode ? 1 : 0;
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_MPEG12 };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/mp3",
-			&a2dp_mpeg_source, &config_mp3_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/mp3",
-			&a2dp_mpeg_sink, &config_mp3_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/mp3", &a2dp_mpeg_source,
+			&config_mp3_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/mp3", &a2dp_mpeg_sink,
+			&config_mp3_44100_stereo);
 
 	if (aging_duration) {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 1024;
 		test_io(t1, t2, a2dp_mp3_enc_thread, a2dp_mpeg_dec_thread, 4 * 1024);
 	}
 	else {
-		debug("\n\n*** A2DP codec: MP3 ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 512;
 		test_io(t1, t2, a2dp_mp3_enc_thread, test_io_thread_dump_bt, 3 * 1024);
 		test_io(t1, t2, test_io_thread_dump_pcm, a2dp_mpeg_dec_thread, 3 * 1024);
@@ -725,31 +846,28 @@ START_TEST(test_a2dp_mp3) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_AAC
-START_TEST(test_a2dp_aac) {
+CK_START_TEST(test_a2dp_aac) {
 
 	config.aac_afterburner = true;
 	config.aac_prefer_vbr = enable_vbr_mode;
 	config_aac_44100_stereo.vbr = enable_vbr_mode ? 1 : 0;
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_MPEG24 };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/aac",
-			&a2dp_aac_source, &config_aac_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/aac",
-			&a2dp_aac_sink, &config_aac_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/aac", &a2dp_aac_source,
+			&config_aac_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/aac", &a2dp_aac_sink,
+			&config_aac_44100_stereo);
 
 	if (aging_duration) {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 450;
 		test_io(t1, t2, a2dp_aac_enc_thread, a2dp_aac_dec_thread, 4 * 1024);
 	}
 	else {
-		debug("\n\n*** A2DP codec: AAC ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 190;
 		test_io(t1, t2, a2dp_aac_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 		test_io(t1, t2, test_io_thread_dump_pcm, a2dp_aac_dec_thread, 2 * 1024);
@@ -758,20 +876,18 @@ START_TEST(test_a2dp_aac) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_APTX
-START_TEST(test_a2dp_aptx) {
+CK_START_TEST(test_a2dp_aptx) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_VENDOR_APTX };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/aptx",
-			&a2dp_aptx_source, &config_aptx_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/aptx",
-			&a2dp_aptx_sink, &config_aptx_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/aptx", &a2dp_aptx_source,
+			&config_aptx_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/aptx", &a2dp_aptx_sink,
+			&config_aptx_44100_stereo);
 
 	if (aging_duration) {
 #if HAVE_APTX_DECODE
@@ -780,7 +896,6 @@ START_TEST(test_a2dp_aptx) {
 #endif
 	}
 	else {
-		debug("\n\n*** A2DP codec: apt-X ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 40;
 		test_io(t1, t2, a2dp_aptx_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 #if HAVE_APTX_DECODE
@@ -791,20 +906,18 @@ START_TEST(test_a2dp_aptx) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_APTX_HD
-START_TEST(test_a2dp_aptx_hd) {
+CK_START_TEST(test_a2dp_aptx_hd) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_VENDOR_APTX_HD };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/aptxhd",
-			&a2dp_aptx_hd_source, &config_aptx_hd_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/aptxhd",
-			&a2dp_aptx_hd_sink, &config_aptx_hd_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/aptxhd", &a2dp_aptx_hd_source,
+			&config_aptx_hd_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/aptxhd", &a2dp_aptx_hd_sink,
+			&config_aptx_hd_44100_stereo);
 
 	if (aging_duration) {
 #if HAVE_APTX_HD_DECODE
@@ -813,7 +926,6 @@ START_TEST(test_a2dp_aptx_hd) {
 #endif
 	}
 	else {
-		debug("\n\n*** A2DP codec: apt-X HD ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 60;
 		test_io(t1, t2, a2dp_aptx_hd_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 #if HAVE_APTX_HD_DECODE
@@ -824,20 +936,18 @@ START_TEST(test_a2dp_aptx_hd) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_FASTSTREAM
-START_TEST(test_a2dp_faststream) {
+CK_START_TEST(test_a2dp_faststream_music) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_VENDOR_FASTSTREAM };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/faststream",
-			&a2dp_faststream_source, &config_faststream_44100_16000);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/faststream",
-			&a2dp_faststream_sink, &config_faststream_44100_16000);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/faststream", &a2dp_faststream_source,
+			&config_faststream_44100_16000);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/faststream", &a2dp_faststream_sink,
+			&config_faststream_44100_16000);
 
 	if (aging_duration) {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 72 * 3;
@@ -845,10 +955,32 @@ START_TEST(test_a2dp_faststream) {
 	}
 	else {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 72 * 3;
-		debug("\n\n*** A2DP codec: FastStream MUSIC ***");
 		test_io(t1, t2, a2dp_faststream_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 		test_io(t1, t2, test_io_thread_dump_pcm, a2dp_faststream_dec_thread, 2 * 1024);
-		debug("\n\n*** A2DP codec: FastStream VOICE ***");
+	}
+
+	ba_transport_destroy(t1);
+	ba_transport_destroy(t2);
+
+} CK_END_TEST
+#endif
+
+#if ENABLE_FASTSTREAM
+CK_START_TEST(test_a2dp_faststream_voice) {
+
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/faststream", &a2dp_faststream_source,
+			&config_faststream_44100_16000);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/faststream", &a2dp_faststream_sink,
+			&config_faststream_44100_16000);
+
+	if (aging_duration) {
+		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 72 * 3;
+		test_io(t2, t1, a2dp_faststream_enc_thread, a2dp_faststream_dec_thread, 4 * 1024);
+	}
+	else {
+		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 72 * 3;
 		test_io(t2, t1, a2dp_faststream_enc_thread, test_io_thread_dump_bt, 2 * 1024);
 		test_io(t2, t1, test_io_thread_dump_pcm, a2dp_faststream_dec_thread, 2 * 1024);
 	}
@@ -856,20 +988,18 @@ START_TEST(test_a2dp_faststream) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_LC3PLUS
-START_TEST(test_a2dp_lc3plus) {
+CK_START_TEST(test_a2dp_lc3plus) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_VENDOR_LC3PLUS };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/lc3plus",
-			&a2dp_lc3plus_source, &config_lc3plus_48000_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/lc3plus",
-			&a2dp_lc3plus_sink, &config_lc3plus_48000_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/lc3plus", &a2dp_lc3plus_source,
+			&config_lc3plus_48000_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/lc3plus", &a2dp_lc3plus_sink,
+			&config_lc3plus_48000_stereo);
 
 	if (aging_duration) {
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write =
@@ -877,7 +1007,6 @@ START_TEST(test_a2dp_lc3plus) {
 		test_io(t1, t2, a2dp_lc3plus_enc_thread, a2dp_lc3plus_dec_thread, 4 * 1024);
 	}
 	else {
-		debug("\n\n*** A2DP codec: LC3plus ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write =
 			RTP_HEADER_LEN + sizeof(rtp_media_header_t) + 300;
 		test_io(t1, t2, a2dp_lc3plus_enc_thread, test_io_thread_dump_bt, 2 * 1024);
@@ -887,23 +1016,21 @@ START_TEST(test_a2dp_lc3plus) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 #if ENABLE_LDAC
-START_TEST(test_a2dp_ldac) {
+CK_START_TEST(test_a2dp_ldac) {
 
 	config.ldac_abr = true;
 	config.ldac_eqmid = LDACBT_EQMID_HQ;
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_A2DP_SOURCE,
-		.codec = A2DP_CODEC_VENDOR_LDAC };
-	struct ba_transport *t1 = test_transport_new_a2dp(device1, ttype, "/path/ldac",
-			&a2dp_ldac_source, &config_ldac_44100_stereo);
-	ttype.profile = BA_TRANSPORT_PROFILE_A2DP_SINK;
-	struct ba_transport *t2 = test_transport_new_a2dp(device2, ttype, "/path/ldac",
-			&a2dp_ldac_sink, &config_ldac_44100_stereo);
+	struct ba_transport *t1 = test_transport_new_a2dp(device1,
+			BA_TRANSPORT_PROFILE_A2DP_SOURCE, "/path/ldac", &a2dp_ldac_source,
+			&config_ldac_44100_stereo);
+	struct ba_transport *t2 = test_transport_new_a2dp(device2,
+			BA_TRANSPORT_PROFILE_A2DP_SINK, "/path/ldac", &a2dp_ldac_sink,
+			&config_ldac_44100_stereo);
 
 	if (aging_duration) {
 #if HAVE_LDAC_DECODE
@@ -913,7 +1040,6 @@ START_TEST(test_a2dp_ldac) {
 #endif
 	}
 	else {
-		debug("\n\n*** A2DP codec: LDAC ***");
 		t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write =
 			RTP_HEADER_LEN + sizeof(rtp_media_header_t) + 660 + 6;
 		test_io(t1, t2, a2dp_ldac_enc_thread, test_io_thread_dump_bt, 2 * 1024);
@@ -925,17 +1051,16 @@ START_TEST(test_a2dp_ldac) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
-START_TEST(test_sco_cvsd) {
+CK_START_TEST(test_sco_cvsd) {
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_HSP_AG };
-	struct ba_transport *t1 = test_transport_new_sco(device1, ttype, "/path/sco/cvsd");
-	struct ba_transport *t2 = test_transport_new_sco(device2, ttype, "/path/sco/cvsd");
+	struct ba_transport *t1 = test_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HSP_AG, "/path/sco/cvsd");
+	struct ba_transport *t2 = test_transport_new_sco(device2,
+			BA_TRANSPORT_PROFILE_HSP_AG, "/path/sco/cvsd");
 
-	debug("\n\n*** SCO codec: CVSD ***");
 	t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 48;
 	test_io(t1, t2, sco_enc_thread, test_io_thread_dump_bt, 600);
 	test_io(t1, t2, test_io_thread_dump_pcm, sco_dec_thread, 600);
@@ -943,21 +1068,21 @@ START_TEST(test_sco_cvsd) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 
 #if ENABLE_MSBC
-START_TEST(test_sco_msbc) {
+CK_START_TEST(test_sco_msbc) {
 
 	adapter->hci.features[2] = LMP_TRSP_SCO;
 	adapter->hci.features[3] = LMP_ESCO;
 
-	struct ba_transport_type ttype = {
-		.profile = BA_TRANSPORT_PROFILE_HFP_AG,
-		.codec = HFP_CODEC_MSBC };
-	struct ba_transport *t1 = test_transport_new_sco(device1, ttype, "/path/sco/msbc");
-	struct ba_transport *t2 = test_transport_new_sco(device2, ttype, "/path/sco/msbc");
+	struct ba_transport *t1 = test_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HFP_AG, "/path/sco/msbc");
+	ba_transport_set_codec(t1, HFP_CODEC_MSBC);
+	struct ba_transport *t2 = test_transport_new_sco(device2,
+			BA_TRANSPORT_PROFILE_HFP_AG, "/path/sco/msbc");
+	ba_transport_set_codec(t2, HFP_CODEC_MSBC);
 
-	debug("\n\n*** SCO codec: mSBC ***");
 	t1->mtu_read = t1->mtu_write = t2->mtu_read = t2->mtu_write = 24;
 	test_io(t1, t2, sco_enc_thread, test_io_thread_dump_bt, 600);
 	test_io(t1, t2, test_io_thread_dump_pcm, sco_dec_thread, 600);
@@ -965,7 +1090,7 @@ START_TEST(test_sco_msbc) {
 	ba_transport_destroy(t1);
 	ba_transport_destroy(t2);
 
-} END_TEST
+} CK_END_TEST
 #endif
 
 int main(int argc, char *argv[]) {
@@ -979,6 +1104,8 @@ int main(int argc, char *argv[]) {
 #endif
 	} codecs[] = {
 		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_SBC), test_a2dp_sbc },
+		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_SBC), test_a2dp_sbc_invalid_config },
+		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_SBC), test_a2dp_sbc_pcm_drop },
 #if ENABLE_MP3LAME
 		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_MPEG12), test_a2dp_mp3 },
 #endif
@@ -992,7 +1119,8 @@ int main(int argc, char *argv[]) {
 		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_APTX_HD), test_a2dp_aptx_hd },
 #endif
 #if ENABLE_FASTSTREAM
-		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_FASTSTREAM), test_a2dp_faststream },
+		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_FASTSTREAM), test_a2dp_faststream_music },
+		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_FASTSTREAM), test_a2dp_faststream_voice },
 #endif
 #if ENABLE_LC3PLUS
 		{ a2dp_codecs_codec_id_to_string(A2DP_CODEC_VENDOR_LC3PLUS), test_a2dp_lc3plus },
@@ -1069,33 +1197,35 @@ int main(int argc, char *argv[]) {
 
 	if (input_bt_file != NULL) {
 
-		if ((btd = bt_dump_open(input_bt_file)) == NULL) {
+		if ((btdin = bt_dump_open(input_bt_file)) == NULL) {
 			error("Couldn't open input BT dump file: %s", strerror(errno));
 			return EXIT_FAILURE;
 		}
 
 		const char *codec = "";
-		switch (btd->mode) {
+		switch (btdin->mode) {
 		case BT_DUMP_MODE_A2DP_SOURCE:
 		case BT_DUMP_MODE_A2DP_SINK:
-			codec = a2dp_codecs_codec_id_to_string(btd->transport_codec_id);
-			debug("BT dump A2DP codec: %s (%#x)", codec, btd->transport_codec_id);
+			codec = a2dp_codecs_codec_id_to_string(btdin->transport_codec_id);
+			debug("BT dump A2DP codec: %s (%#x)", codec, btdin->transport_codec_id);
 			hexdump("BT dump A2DP configuration",
-					&btd->a2dp_configuration, btd->a2dp_configuration_size, true);
+					&btdin->a2dp_configuration, btdin->a2dp_configuration_size, true);
 			break;
 		case BT_DUMP_MODE_SCO:
-			codec = hfp_codec_id_to_string(btd->transport_codec_id);
-			debug("BT dump HFP codec: %s (%#x)", codec, btd->transport_codec_id);
+			codec = hfp_codec_id_to_string(btdin->transport_codec_id);
+			debug("BT dump HFP codec: %s (%#x)", codec, btdin->transport_codec_id);
 			break;
 		}
 
 		enabled_codecs = 0;
 		for (size_t i = 0; i < ARRAYSIZE(codecs); i++)
 			if (strcmp(codec, codecs[i].name) == 0)
-				enabled_codecs = 1 << i;
+				enabled_codecs |= 1 << i;
 
 	}
 
+	bluealsa_config_init();
+
 	bdaddr_t addr1 = {{ 1, 2, 3, 4, 5, 6 }};
 	bdaddr_t addr2 = {{ 1, 2, 3, 7, 8, 9 }};
 	adapter = ba_adapter_new(0);
@@ -1108,7 +1238,7 @@ int main(int argc, char *argv[]) {
 
 	suite_add_tcase(s, tc);
 
-	tcase_set_timeout(tc, aging_duration + 5);
+	tcase_set_timeout(tc, aging_duration + 10);
 	if (input_bt_file != NULL || input_pcm_file != NULL)
 		tcase_set_timeout(tc, aging_duration + 3600);
 
diff --git a/test/test-msbc.c b/test/test-msbc.c
index 717d442..fa39611 100644
--- a/test/test-msbc.c
+++ b/test/test-msbc.c
@@ -1,6 +1,6 @@
 /*
  * test-msbc.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -21,10 +21,11 @@
 #include "shared/ffb.h"
 #include "shared/log.h"
 
-#include "inc/sine.inc"
 #include "../src/codec-msbc.c"
+#include "inc/check.inc"
+#include "inc/sine.inc"
 
-START_TEST(test_msbc_init) {
+CK_START_TEST(test_msbc_init) {
 
 	struct esco_msbc msbc = { .initialized = false };
 
@@ -41,9 +42,9 @@ START_TEST(test_msbc_init) {
 
 	msbc_finish(&msbc);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_msbc_find_h2_header) {
+CK_START_TEST(test_msbc_find_h2_header) {
 
 	static const uint8_t raw[][10] = {
 		{ 0 },
@@ -84,9 +85,9 @@ START_TEST(test_msbc_find_h2_header) {
 	ck_assert_ptr_eq(msbc_find_h2_header(raw[5], &len), NULL);
 	ck_assert_int_eq(len, 1);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_msbc_encode_decode) {
+CK_START_TEST(test_msbc_encode_decode) {
 
 	int16_t sine[8 * MSBC_CODESAMPLES];
 	snd_pcm_sine_s16_2le(sine, ARRAYSIZE(sine), 1, 0, 1.0 / 128);
@@ -146,9 +147,9 @@ START_TEST(test_msbc_encode_decode) {
 
 	msbc_finish(&msbc);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_msbc_decode_plc) {
+CK_START_TEST(test_msbc_decode_plc) {
 
 	int16_t sine[18 * MSBC_CODESAMPLES];
 	snd_pcm_sine_s16_2le(sine, ARRAYSIZE(sine), 1, 0, 1.0 / 128);
@@ -222,7 +223,7 @@ START_TEST(test_msbc_decode_plc) {
 
 	msbc_finish(&msbc);
 
-} END_TEST
+} CK_END_TEST
 
 int main(void) {
 
diff --git a/test/test-rfcomm.c b/test/test-rfcomm.c
index fcae6af..f467477 100644
--- a/test/test-rfcomm.c
+++ b/test/test-rfcomm.c
@@ -1,6 +1,6 @@
 /*
  * test-rfcomm.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -25,6 +25,7 @@
 #include <check.h>
 #include <glib.h>
 
+#include "a2dp.h"
 #include "ba-adapter.h"
 #include "ba-device.h"
 #include "ba-rfcomm.h"
@@ -35,12 +36,26 @@
 #include "hfp.h"
 #include "shared/log.h"
 
-static struct ba_adapter *adapter = NULL;
-static struct ba_device *device = NULL;
+#include "inc/check.inc"
 
-static pthread_mutex_t transport_codec_updated_mtx = PTHREAD_MUTEX_INITIALIZER;
-static pthread_cond_t transport_codec_updated = PTHREAD_COND_INITIALIZER;
-static unsigned int transport_codec_updated_cnt = 0;
+static struct ba_adapter *adapter = NULL;
+static struct ba_device *device1 = NULL;
+static struct ba_device *device2 = NULL;
+
+static pthread_mutex_t dbus_update_mtx = PTHREAD_MUTEX_INITIALIZER;
+static pthread_cond_t dbus_update_cond = PTHREAD_COND_INITIALIZER;
+static struct {
+	unsigned int codec;
+	unsigned int volume;
+	unsigned int battery;
+} dbus_update_counters;
+
+static void dbus_update_counters_wait(unsigned int *counter, unsigned int value) {
+	pthread_mutex_lock(&dbus_update_mtx);
+	while (*counter != value)
+		pthread_cond_wait(&dbus_update_cond, &dbus_update_mtx);
+	pthread_mutex_unlock(&dbus_update_mtx);
+}
 
 void a2dp_aac_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_aac_transport_start(struct ba_transport *t) { (void)t; return 0; }
@@ -54,232 +69,449 @@ void a2dp_lc3plus_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_lc3plus_transport_start(struct ba_transport *t) { (void)t; return 0; }
 void a2dp_ldac_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_ldac_transport_start(struct ba_transport *t) { (void)t; return 0; }
-void *sco_enc_thread(struct ba_transport_thread *th) { return sleep(3600), th; }
-void *sco_dec_thread(struct ba_transport_thread *th) { return sleep(3600), th; }
 void a2dp_mpeg_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_mpeg_transport_start(struct ba_transport *t) { (void)t; return 0; }
 void a2dp_sbc_transport_init(struct ba_transport *t) { (void)t; }
 int a2dp_sbc_transport_start(struct ba_transport *t) { (void)t; return 0; }
+int sco_transport_start(struct ba_transport *t) { (void)t; return 0; }
+int storage_device_load(const struct ba_device *d) { (void)d; return 0; }
+int storage_device_save(const struct ba_device *d) { (void)d; return 0; }
+int storage_pcm_data_sync(struct ba_transport_pcm *pcm) { (void)pcm; return 0; }
+int storage_pcm_data_update(const struct ba_transport_pcm *pcm) { (void)pcm; return 0; }
 
 int bluealsa_dbus_pcm_register(struct ba_transport_pcm *pcm) {
 	debug("%s: %p", __func__, (void *)pcm);
 	pcm->ba_dbus_exported = true;
 	return 0; }
 void bluealsa_dbus_pcm_update(struct ba_transport_pcm *pcm, unsigned int mask) {
-	debug("%s: %p %#x", __func__, (void *)pcm, mask);
-	if (mask & BA_DBUS_PCM_UPDATE_CODEC) {
-		pthread_mutex_lock(&transport_codec_updated_mtx);
-		transport_codec_updated_cnt++;
-		pthread_cond_signal(&transport_codec_updated);
-		pthread_mutex_unlock(&transport_codec_updated_mtx); }}
+	debug("%s: %p %#x", __func__, (void *)pcm, mask); (void)pcm;
+	pthread_mutex_lock(&dbus_update_mtx);
+	dbus_update_counters.codec += !!(mask & BA_DBUS_PCM_UPDATE_CODEC);
+	dbus_update_counters.volume += !!(mask & BA_DBUS_PCM_UPDATE_VOLUME);
+	pthread_mutex_unlock(&dbus_update_mtx);
+	pthread_cond_signal(&dbus_update_cond); }
 void bluealsa_dbus_pcm_unregister(struct ba_transport_pcm *pcm) {
-	debug("%s: %p", __func__, (void *)pcm); }
+	debug("%s: %p", __func__, (void *)pcm); (void)pcm; }
 int bluealsa_dbus_rfcomm_register(struct ba_rfcomm *r) {
-	debug("%s: %p", __func__, (void *)r); return 0; }
+	debug("%s: %p", __func__, (void *)r); (void)r; return 0; }
 void bluealsa_dbus_rfcomm_update(struct ba_rfcomm *r, unsigned int mask) {
-	debug("%s: %p %#x", __func__, (void *)r, mask); }
+	debug("%s: %p %#x", __func__, (void *)r, mask); (void)r;
+	pthread_mutex_lock(&dbus_update_mtx);
+	dbus_update_counters.battery += !!(mask & BA_DBUS_RFCOMM_UPDATE_BATTERY);
+	pthread_mutex_unlock(&dbus_update_mtx);
+	pthread_cond_signal(&dbus_update_cond); }
 void bluealsa_dbus_rfcomm_unregister(struct ba_rfcomm *r) {
-	debug("%s: %p", __func__, (void *)r); }
+	debug("%s: %p", __func__, (void *)r); (void)r; }
 bool bluez_a2dp_set_configuration(const char *current_dbus_sep_path,
 		const struct a2dp_sep *sep, GError **error) {
-	debug("%s: %s", __func__, current_dbus_sep_path); (void)sep;
-	(void)error; return false; }
+	debug("%s: %s: %p", __func__, current_dbus_sep_path, sep);
+	(void)current_dbus_sep_path; (void)sep; (void)error; return false; }
 void bluez_battery_provider_update(struct ba_device *device) {
-	debug("%s: %p", __func__, device); }
+	debug("%s: %p", __func__, device); (void)device; }
+int ofono_call_volume_update(struct ba_transport *t) {
+	debug("%s: %p", __func__, t); (void)t; return 0; }
 
-START_TEST(test_rfcomm) {
+#define ck_assert_rfcomm_recv(fd, command) { \
+	char buffer[sizeof(command)] = { 0 }; \
+	const ssize_t len = strlen(command); \
+	ck_assert_int_eq(read(fd, buffer, sizeof(buffer) - 1), len); \
+	ck_assert_str_eq(buffer, command); }
 
-	transport_codec_updated_cnt = 0;
-	memset(adapter->hci.features, 0, sizeof(adapter->hci.features));
+#define ck_assert_rfcomm_send(fd, command) { \
+	const ssize_t len = strlen(command); \
+	ck_assert_int_eq(write(fd, command, len), len); }
+
+CK_START_TEST(test_rfcomm_hsp_ag) {
 
 	int fds[2];
 	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
+	struct ba_transport *sco = ba_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HSP_AG, ":test", "/sco", fds[0]);
+	const int fd = fds[1];
 
-	struct ba_transport_type ttype_ag = { .profile = BA_TRANSPORT_PROFILE_HFP_AG };
-	struct ba_transport *ag = ba_transport_new_sco(device, ttype_ag, ":test", "/sco/ag", fds[0]);
-	struct ba_transport_type ttype_hf = { .profile = BA_TRANSPORT_PROFILE_HFP_HF };
-	struct ba_transport *hf = ba_transport_new_sco(device, ttype_hf, ":test", "/sco/hf", fds[1]);
+	/* check support for setting microphone gain */
+	ck_assert_rfcomm_send(fd, "AT+VGM=10\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 1);
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+	/* check support for setting speaker gain */
+	ck_assert_rfcomm_send(fd, "AT+VGS=13\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 2);
 
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for SLC established signals */
-	while (transport_codec_updated_cnt < 0 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	/* check support for button press */
+	ck_assert_rfcomm_send(fd, "AT+CKPD=200\r");
+	ck_assert_rfcomm_recv(fd, "\r\nERROR\r\n");
 
-	ck_assert_int_eq(device->ref_count, 1 + 2);
+	/* check vendor-specific command for battery level notification */
+	ck_assert_rfcomm_send(fd, "AT+IPHONEACCEV=2,1,8,2,1\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.battery, 1);
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+	ba_transport_destroy(sco);
+	close(fd);
 
-	debug("Audio gateway destroying");
-	ba_transport_destroy(ag);
-	/* The hf transport shall be destroyed by the "link lost" quirk. However,
-	 * we have to wait "some" time before reference counter check, because
-	 * this action is asynchronous from our point of view. */
-	debug("Hands Free unreferencing");
-	ba_transport_unref(hf);
-	debug("Wait for asynchronous free");
-	usleep(100000);
+} CK_END_TEST
 
-	ck_assert_int_eq(device->ref_count, 1);
+CK_START_TEST(test_rfcomm_hsp_hs) {
 
-} END_TEST
+	int fds[2];
+	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
+	struct ba_transport *sco = ba_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HSP_HS, ":test", "/sco", fds[0]);
+	const int fd = fds[1];
 
-START_TEST(test_rfcomm_esco) {
+	/* wait for initial microphone gain */
+	ck_assert_rfcomm_recv(fd, "AT+VGM=15\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
 
-	transport_codec_updated_cnt = 0;
-	adapter->hci.features[2] = LMP_TRSP_SCO;
-	adapter->hci.features[3] = LMP_ESCO;
+	/* wait for initial speaker gain */
+	ck_assert_rfcomm_recv(fd, "AT+VGS=15\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* wait for initial vendor-specific battery level */
+	ck_assert_rfcomm_recv(fd, "AT+XAPL=DEAD-C0DE-1234,6\r");
+	ck_assert_rfcomm_send(fd, "\r\n+XAPL:TEST,6\r\n");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+	ck_assert_rfcomm_recv(fd, "AT+IPHONEACCEV=2,1,8,2,0\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* check support for setting speaker gain */
+	ck_assert_rfcomm_send(fd, "\r\n+VGS=13\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 1);
+
+	/* check support for setting microphone gain */
+	ck_assert_rfcomm_send(fd, "\r\n+VGM=10\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 2);
+
+	/* check support for RING command */
+	ck_assert_rfcomm_send(fd, "\r\nRING\r\n");
+	usleep(5000);
+
+	ba_transport_destroy(sco);
+	close(fd);
+
+} CK_END_TEST
+
+#if ENABLE_MSBC
+static void *test_rfcomm_hfp_ag_switch_codecs(void *userdata) {
+	struct ba_transport *sco = userdata;
+	/* the test code rejects first codec selection request for mSBC */
+	ck_assert_int_eq(ba_transport_select_codec_sco(sco, HFP_CODEC_CVSD), 0);
+	ck_assert_int_eq(ba_transport_select_codec_sco(sco, HFP_CODEC_MSBC), -1);
+	ck_assert_int_eq(ba_transport_select_codec_sco(sco, HFP_CODEC_MSBC), 0);
+	return NULL;
+}
+#endif
+
+CK_START_TEST(test_rfcomm_hfp_ag) {
 
 	int fds[2];
 	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
+	struct ba_transport *sco = ba_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HFP_AG, ":test", "/sco", fds[0]);
+	const int fd = fds[1];
 
-	struct ba_transport_type ttype_ag = { .profile = BA_TRANSPORT_PROFILE_HFP_AG };
-	struct ba_transport *ag = ba_transport_new_sco(device, ttype_ag, ":test", "/sco/ag", fds[0]);
-	struct ba_transport_type ttype_hf = { .profile = BA_TRANSPORT_PROFILE_HFP_HF };
-	struct ba_transport *hf = ba_transport_new_sco(device, ttype_hf, ":test", "/sco/hf", fds[1]);
+	/* SLC initialization shall be started by the HF */
 
-	ag->sco.rfcomm->link_lost_quirk = false;
-	hf->sco.rfcomm->link_lost_quirk = false;
+	/* supported features exchange: volume, codec, eSCO */
+	ck_assert_int_eq(HFP_HF_FEAT_VOLUME | HFP_HF_FEAT_CODEC | HFP_HF_FEAT_ESCO, 656);
+	ck_assert_rfcomm_send(fd, "AT+BRSF=656\r");
+#ifdef ENABLE_MSBC
+	ck_assert_rfcomm_recv(fd, "\r\n+BRSF:2784\r\n");
+#else
+	ck_assert_rfcomm_recv(fd, "\r\n+BRSF:2272\r\n");
+#endif
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
 
 #if ENABLE_MSBC
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_UNDEFINED);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_UNDEFINED);
-#else
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+	/* verify that AG supports codec negotiation and eSCO */
+	ck_assert_int_ne(2784 & (HFP_AG_FEAT_CODEC | HFP_AG_FEAT_ESCO), 0);
+	/* codec negotiation */
+	ck_assert_rfcomm_send(fd, "AT+BAC=1,2\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
 #endif
 
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for SLC established signals */
-	while (transport_codec_updated_cnt < 0 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	/* AG indicators: retrieve supported indicators and their ordering */
+	ck_assert_rfcomm_send(fd, "AT+CIND=?\r");
+	ck_assert_rfcomm_recv(fd,
+			"\r\n+CIND:(\"service\",(0,1)),(\"call\",(0,1)),(\"callsetup\",(0-3)),"
+			"(\"callheld\",(0-2)),(\"signal\",(0-5)),(\"roam\",(0,1)),(\"battchg\",(0-5))\r\n");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	/* AG indicators: retrieve the current status of the indicators */
+	ck_assert_rfcomm_send(fd, "AT+CIND?\r");
+	ck_assert_rfcomm_recv(fd, "\r\n+CIND:0,0,0,0,0,0,4\r\n");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	/* enable indicator status update */
+	ck_assert_rfcomm_send(fd, "AT+CMER=3,0,0,1\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	/* SLC has been established */
+
+	/* check support for setting microphone gain */
+	ck_assert_rfcomm_send(fd, "AT+VGM=10\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 1);
 
-	ck_assert_int_eq(device->ref_count, 1 + 2);
+	/* check vendor-specific command for microphone mute */
+	ck_assert_rfcomm_send(fd, "AT+ANDROID=XHSMICMUTE,1\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 2);
+
+	/* check vendor-specific command for ... */
+	ck_assert_rfcomm_send(fd, "AT+ANDROID=BOOM\r");
+	ck_assert_rfcomm_recv(fd, "\r\nERROR\r\n");
+
+	/* check support for setting speaker gain */
+	ck_assert_rfcomm_send(fd, "AT+VGS=13\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.volume, 3);
 
 #if ENABLE_MSBC
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for codec selection signals */
-	while (transport_codec_updated_cnt < 4 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	/* request codec connection setup */
+	ck_assert_rfcomm_send(fd, "AT+BCC\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	/* wait for codec selection */
+	ck_assert_rfcomm_recv(fd, "\r\n+BCS:2\r\n");
+	ck_assert_rfcomm_send(fd, "AT+BCS=2\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.codec, 2);
 #endif
 
 #if ENABLE_MSBC
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_MSBC);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_MSBC);
-#else
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+
+	/* use internal API to select the codec */
+	GThread *th = g_thread_new(NULL, test_rfcomm_hfp_ag_switch_codecs, sco);
+
+	ck_assert_rfcomm_recv(fd, "\r\n+BCS:1\r\n");
+	ck_assert_rfcomm_send(fd, "AT+BCS=1\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	/* reject codec selection request */
+	ck_assert_rfcomm_recv(fd, "\r\n+BCS:2\r\n");
+	ck_assert_rfcomm_send(fd, "AT+BAC=1,2\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	ck_assert_rfcomm_recv(fd, "\r\n+BCS:2\r\n");
+	ck_assert_rfcomm_send(fd, "AT+BCS=2\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
+
+	g_thread_join(th);
+
 #endif
 
-	debug("Audio gateway destroying");
-	ba_transport_destroy(ag);
-	debug("Hands Free destroying");
-	ba_transport_destroy(hf);
+	config.battery.level = 100;
+	/* use internal API to update battery level */
+	ba_rfcomm_send_signal(sco->sco.rfcomm, BA_RFCOMM_SIGNAL_UPDATE_BATTERY);
+	ck_assert_rfcomm_recv(fd, "\r\n+CIEV:7,5\r\n");
+
+	/* disable all indicators */
+	ck_assert_rfcomm_send(fd, "AT+BIA=0,0,0,0,0,0,0,0,0,0\r");
+	ck_assert_rfcomm_recv(fd, "\r\nOK\r\n");
 
-	ck_assert_int_eq(device->ref_count, 1);
+	/* battery level indicator shall not be reported */
+	ba_rfcomm_send_signal(sco->sco.rfcomm, BA_RFCOMM_SIGNAL_UPDATE_BATTERY);
+	ck_assert_rfcomm_recv(fd, "");
 
-} END_TEST
+	const int level = -1000;
+	struct ba_transport_pcm *pcm = &sco->sco.spk_pcm;
+	pthread_mutex_lock(&pcm->mutex);
+	ba_transport_pcm_volume_set(&pcm->volume[0], &level, NULL, NULL);
+	pthread_mutex_unlock(&pcm->mutex);
+	/* use internal API to update volume */
+	ba_transport_pcm_volume_update(pcm);
+	ck_assert_rfcomm_recv(fd, "\r\n+VGS:7\r\n");
 
+	ba_transport_destroy(sco);
+	close(fd);
+
+} CK_END_TEST
+
+CK_START_TEST(test_rfcomm_hfp_hf) {
+
+	int fds[2];
+	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
+	struct ba_transport *sco = ba_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HFP_HF, ":test", "/sco", fds[0]);
+	const int fd = fds[1];
+
+	/* SLC initialization shall be started by the HF */
+
+	/* supported features exchange */
 #if ENABLE_MSBC
-START_TEST(test_rfcomm_set_codec) {
+	ck_assert_rfcomm_recv(fd, "AT+BRSF=756\r");
+#else
+	ck_assert_rfcomm_recv(fd, "AT+BRSF=628\r");
+#endif
+	ck_assert_int_eq(HFP_AG_FEAT_CODEC | HFP_AG_FEAT_ESCO, 2560);
+	ck_assert_rfcomm_send(fd, "\r\n+BRSF:2560\r\n");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
 
-	transport_codec_updated_cnt = 0;
-	adapter->hci.features[2] = LMP_TRSP_SCO;
-	adapter->hci.features[3] = LMP_ESCO;
+#if ENABLE_MSBC
+	/* verify that HF supports codec negotiation and eSCO */
+	ck_assert_int_ne(756 & (HFP_HF_FEAT_CODEC | HFP_HF_FEAT_ESCO), 0);
+	/* codec negotiation */
+	ck_assert_rfcomm_recv(fd, "AT+BAC=1,2\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+#endif
+
+	/* AG indicators: retrieve supported indicators and their ordering */
+	ck_assert_rfcomm_recv(fd, "AT+CIND=?\r");
+	ck_assert_rfcomm_send(fd,
+			"\r\n+CIND:(\"service\",(0,1)),(\"call\",(0,1)),(\"callsetup\",(0-3)),"
+			"(\"callheld\",(0-2)),(\"signal\",(0-5)),(\"roam\",(0,1)),(\"battchg\",(0-5))\r\n");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* AG indicators: retrieve the current status of the indicators */
+	ck_assert_rfcomm_recv(fd, "AT+CIND?\r");
+	ck_assert_rfcomm_send(fd, "\r\n+CIND:0,0,0,0,0,0,4\r\n");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.battery, 1);
+
+	/* enable indicator status update */
+	ck_assert_rfcomm_recv(fd, "AT+CMER=3,0,0,1,0\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* SLC has been established */
+
+	/* report service, current signal strength and battery level */
+	ck_assert_rfcomm_send(fd, "\r\n+CIEV:1,1\r\n");
+	ck_assert_rfcomm_send(fd, "\r\n+CIEV:5,4\r\n");
+	ck_assert_rfcomm_send(fd, "\r\n+CIEV:7,5\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.battery, 2);
+
+	/* wait for initial microphone gain */
+	ck_assert_rfcomm_recv(fd, "AT+VGM=15\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* wait for initial speaker gain */
+	ck_assert_rfcomm_recv(fd, "AT+VGS=15\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* wait for initial vendor-specific battery level */
+	ck_assert_rfcomm_recv(fd, "AT+XAPL=DEAD-C0DE-1234,6\r");
+	ck_assert_rfcomm_send(fd, "\r\n+XAPL:TEST,6\r\n");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+	ck_assert_rfcomm_recv(fd, "AT+IPHONEACCEV=2,1,8,2,0\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+#if ENABLE_MSBC
+
+	/* wait for codec selection request */
+	ck_assert_rfcomm_recv(fd, "AT+BCC\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+
+	/* codec selection */
+	ck_assert_rfcomm_send(fd, "\r\n+BCS:1\r\n");
+	ck_assert_rfcomm_recv(fd, "AT+BCS=1\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.codec, 2);
+
+	/* codec selection: initial codec */
+	ck_assert_rfcomm_send(fd, "\r\n+BCS:3\r\n");
+	ck_assert_rfcomm_recv(fd, "AT+BAC=1,2\r");
+	ck_assert_rfcomm_send(fd, "\r\nOK\r\n");
+	dbus_update_counters_wait(&dbus_update_counters.codec, 2);
+
+#endif
+
+	/* check support for RING command */
+	ck_assert_rfcomm_send(fd, "\r\nRING\r\n");
+	usleep(5000);
+
+	ba_transport_destroy(sco);
+	close(fd);
+
+} CK_END_TEST
+
+CK_START_TEST(test_rfcomm_self_hfp_slc) {
+
+	/* disable eSCO, so that codec negotiation is not performed */
+	adapter->hci.features[2] &= ~LMP_TRSP_SCO;
+	adapter->hci.features[3] &= ~LMP_ESCO;
 
 	int fds[2];
 	ck_assert_int_eq(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
 
-	struct ba_transport_type ttype_ag = { .profile = BA_TRANSPORT_PROFILE_HFP_AG };
-	struct ba_transport *ag = ba_transport_new_sco(device, ttype_ag, ":test", "/sco/ag", fds[0]);
-	struct ba_transport_type ttype_hf = { .profile = BA_TRANSPORT_PROFILE_HFP_HF };
-	struct ba_transport *hf = ba_transport_new_sco(device, ttype_hf, ":test", "/sco/hf", fds[1]);
+	struct ba_transport *ag = ba_transport_new_sco(device1,
+			BA_TRANSPORT_PROFILE_HFP_AG, ":test", "/sco/ag", fds[0]);
+	struct ba_transport *hf = ba_transport_new_sco(device2,
+			BA_TRANSPORT_PROFILE_HFP_HF, ":test", "/sco/hf", fds[1]);
 
-	ag->sco.rfcomm->link_lost_quirk = false;
-	hf->sco.rfcomm->link_lost_quirk = false;
+	ck_assert_int_eq(ba_transport_get_codec(ag), HFP_CODEC_CVSD);
+	ck_assert_int_eq(ba_transport_get_codec(hf), HFP_CODEC_CVSD);
 
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for SLC established signals */
-	while (transport_codec_updated_cnt < 0 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	/* wait for codec selection signals */
-	while (transport_codec_updated_cnt < 4 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	/* wait for codec selection (SLC established) signals */
+	dbus_update_counters_wait(&dbus_update_counters.codec, 0 + (2 + 2));
 
-	/* Allow RFCOMM thread to finalize internal codec selection. This sleep is
-	 * required, because we are waiting on "codec-updated" signal which is sent
-	 * by the RFCOMM thread before the codec selection completeness signal. And
-	 * this latter signal might prematurely wake ba_transport_select_codec_sco()
-	 * function. */
-	usleep(10000);
+	pthread_mutex_lock(&adapter->devices_mutex);
+	ck_assert_int_eq(device1->ref_count, 1 + 1);
+	ck_assert_int_eq(device2->ref_count, 1 + 1);
+	pthread_mutex_unlock(&adapter->devices_mutex);
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_MSBC);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_MSBC);
+	ck_assert_int_eq(ba_transport_get_codec(ag), HFP_CODEC_CVSD);
+	ck_assert_int_eq(ba_transport_get_codec(hf), HFP_CODEC_CVSD);
 
-	/* select different audio codec */
-	ck_assert_int_eq(ba_transport_select_codec_sco(ag, HFP_CODEC_CVSD), 0);
+	debug("Audio gateway destroying");
+	ba_transport_destroy(ag);
+	/* The hf transport shall be destroyed by the "link lost" quirk. However,
+	 * we have to wait "some" time before reference counter check, because
+	 * this action is asynchronous from our point of view. */
+	debug("Hands Free unreferencing");
+	ba_transport_unref(hf);
+	debug("Wait for asynchronous free");
+	usleep(100000);
 
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for codec selection signals */
-	while (transport_codec_updated_cnt < 8 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	pthread_mutex_lock(&adapter->devices_mutex);
+	ck_assert_int_eq(device1->ref_count, 1);
+	ck_assert_int_eq(device2->ref_count, 1);
+	pthread_mutex_unlock(&adapter->devices_mutex);
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+} CK_END_TEST
 
-	/* select already selected audio codec */
-	ck_assert_int_eq(ba_transport_select_codec_sco(ag, HFP_CODEC_CVSD), 0);
+void tc_setup(void) {
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_CVSD);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_CVSD);
+	config.battery.available = true;
+	config.battery.level = 80;
 
-	/* switch back audio codec */
-	ck_assert_int_eq(ba_transport_select_codec_sco(ag, HFP_CODEC_MSBC), 0);
+	config.hfp.xapl_vendor_id = 0xDEAD,
+	config.hfp.xapl_product_id = 0xC0DE,
+	config.hfp.xapl_sw_version = 0x1234,
 
-	pthread_mutex_lock(&transport_codec_updated_mtx);
-	/* wait for codec selection signals */
-	while (transport_codec_updated_cnt < 12 + (2 + 2))
-		pthread_cond_wait(&transport_codec_updated, &transport_codec_updated_mtx);
-	pthread_mutex_unlock(&transport_codec_updated_mtx);
+	memset(adapter->hci.features, 0, sizeof(adapter->hci.features));
+	adapter->hci.features[2] = LMP_TRSP_SCO;
+	adapter->hci.features[3] = LMP_ESCO;
 
-	ck_assert_int_eq(ag->type.codec, HFP_CODEC_MSBC);
-	ck_assert_int_eq(hf->type.codec, HFP_CODEC_MSBC);
+	memset(&dbus_update_counters, 0, sizeof(dbus_update_counters));
 
-} END_TEST
-#endif
+}
 
 int main(void) {
 
 	struct sigaction sigact = { .sa_handler = SIG_IGN };
 	sigaction(SIGPIPE, &sigact, NULL);
 
-	bdaddr_t addr = {{ 1, 2, 3, 4, 5, 6 }};
 	adapter = ba_adapter_new(0);
-	device = ba_device_new(adapter, &addr);
+	bdaddr_t addr1 = {{ 1, 1, 1, 1, 1, 1 }};
+	device1 = ba_device_new(adapter, &addr1);
+	bdaddr_t addr2 = {{ 2, 2, 2, 2, 2, 2 }};
+	device2 = ba_device_new(adapter, &addr2);
 
 	Suite *s = suite_create(__FILE__);
 	TCase *tc = tcase_create(__FILE__);
 	SRunner *sr = srunner_create(s);
 
 	suite_add_tcase(s, tc);
+	tcase_add_checked_fixture(tc, tc_setup, NULL);
 	tcase_set_timeout(tc, 10);
 
-	config.battery.available = true;
-	config.battery.level = 80;
-
-	tcase_add_test(tc, test_rfcomm);
-	tcase_add_test(tc, test_rfcomm_esco);
-#if ENABLE_MSBC
-	tcase_add_test(tc, test_rfcomm_set_codec);
-#endif
+	tcase_add_test(tc, test_rfcomm_hsp_ag);
+	tcase_add_test(tc, test_rfcomm_hsp_hs);
+	tcase_add_test(tc, test_rfcomm_hfp_ag);
+	tcase_add_test(tc, test_rfcomm_hfp_hf);
+	tcase_add_test(tc, test_rfcomm_self_hfp_slc);
 
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
diff --git a/test/test-rtp.c b/test/test-rtp.c
index 51aeae7..da343cf 100644
--- a/test/test-rtp.c
+++ b/test/test-rtp.c
@@ -1,6 +1,6 @@
 /*
  * test-rtp.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -8,7 +8,12 @@
  *
  */
 
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
 #include <endian.h>
+#include <stdbool.h>
 #include <stdint.h>
 #include <string.h>
 
@@ -17,7 +22,9 @@
 #include "rtp.h"
 #include "shared/defs.h"
 
-START_TEST(test_rtp_a2dp_init) {
+#include "inc/check.inc"
+
+CK_START_TEST(test_rtp_a2dp_init) {
 
 	uint8_t buffer[RTP_HEADER_LEN + sizeof(rtp_media_header_t) + 16];
 	for (size_t i = 0; i < sizeof(buffer); i++)
@@ -33,9 +40,9 @@ START_TEST(test_rtp_a2dp_init) {
 	ck_assert_ptr_ne(payload, NULL);
 	ck_assert_int_eq(payload[0], 13);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_rtp_a2dp_get_payload) {
+CK_START_TEST(test_rtp_a2dp_get_payload) {
 
 	uint8_t buffer[RTP_HEADER_LEN + 16];
 	for (size_t i = 0; i < sizeof(buffer); i++)
@@ -54,9 +61,9 @@ START_TEST(test_rtp_a2dp_get_payload) {
 	ck_assert_ptr_ne(payload, NULL);
 	ck_assert_int_eq(payload[0], 12);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_rtp_state_new_frame) {
+CK_START_TEST(test_rtp_state_new_frame) {
 
 	struct rtp_state rtp;
 	rtp_state_init(&rtp, 8000, 8000);
@@ -71,9 +78,9 @@ START_TEST(test_rtp_state_new_frame) {
 		ck_assert_int_eq(be32toh(header.timestamp), ts_offset);
 	}
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_rtp_state_sync_stream) {
+CK_START_TEST(test_rtp_state_sync_stream) {
 
 	struct rtp_state rtp;
 	rtp_state_init(&rtp, 8000, 8000);
@@ -153,9 +160,9 @@ START_TEST(test_rtp_state_sync_stream) {
 
 	}
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_rtp_state_update) {
+CK_START_TEST(test_rtp_state_update) {
 
 	struct rtp_state rtp;
 	rtp_state_init(&rtp, 8000, 8000);
@@ -165,7 +172,7 @@ START_TEST(test_rtp_state_update) {
 
 	ck_assert_int_eq(rtp.ts_pcm_frames, 10 * 16);
 
-} END_TEST
+} CK_END_TEST
 
 int main(void) {
 
diff --git a/test/test-utils-aplay.c b/test/test-utils-aplay.c
new file mode 100644
index 0000000..365f7ad
--- /dev/null
+++ b/test/test-utils-aplay.c
@@ -0,0 +1,300 @@
+/*
+ * test-utils-aplay.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
+#include <libgen.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <check.h>
+
+#include "inc/check.inc"
+#include "inc/mock.inc"
+#include "inc/preload.inc"
+#include "inc/spawn.inc"
+
+static char bluealsa_aplay_path[256];
+static int spawn_bluealsa_aplay(struct spawn_process *sp, ...) {
+
+	char * argv[32] = { bluealsa_aplay_path };
+	size_t n = 1;
+
+	va_list ap;
+	va_start(ap, sp);
+
+	char *arg;
+	while ((arg = va_arg(ap, char *)) != NULL) {
+		argv[n++] = arg;
+		argv[n] = NULL;
+	}
+
+	va_end(ap);
+
+	const int flags = SPAWN_FLAG_REDIRECT_STDOUT | SPAWN_FLAG_REDIRECT_STDERR;
+	return spawn(sp, argv, NULL, flags);
+}
+
+CK_START_TEST(test_help) {
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"-v", "--help", NULL), -1);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stdout), 0);
+	fprintf(stderr, "%s", output);
+
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	spawn_close(&sp_ba_aplay, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_configuration) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "foo", true,
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--verbose",
+				"--dbus=foo",
+				"--pcm=TestPCM",
+				"--pcm-buffer-time=10000",
+				"--pcm-period-time=500",
+				"--mixer-device=TestMixer",
+				"--mixer-name=TestMixerName",
+				"--mixer-index=1",
+				"--profile-sco",
+				"12:34:56:78:90:AB",
+				NULL), -1);
+	spawn_terminate(&sp_ba_aplay, 100);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stderr), 0);
+	fprintf(stderr, "%s", output);
+
+	/* check selected configuration */
+	ck_assert_ptr_ne(strstr(output, "  BlueALSA service: org.bluealsa.foo"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  ALSA PCM device: TestPCM"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  ALSA PCM buffer time: 10000 us"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  ALSA PCM period time: 500 us"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  ALSA mixer device: TestMixer"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  ALSA mixer element: 'TestMixerName',1"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  Bluetooth device(s): 12:34:56:78:90:AB"), NULL);
+	ck_assert_ptr_ne(strstr(output, "  Profile: SCO"), NULL);
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_list_devices) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--device-name=23:45:67:89:AB:CD:Speaker",
+				"--profile=a2dp-source",
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--list-devices",
+				NULL), -1);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stdout), 0);
+	fprintf(stderr, "%s", output);
+
+	ck_assert_ptr_ne(strstr(output,
+				"hci0: 23:45:67:89:AB:CD [Speaker], audio-card"), NULL);
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_list_pcms) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "foo", true,
+				"--device-name=23:45:67:89:AB:CD:Speaker",
+				"--profile=a2dp-source",
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--dbus=foo",
+				"--list-pcms",
+				NULL), -1);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stdout), 0);
+	fprintf(stderr, "%s", output);
+
+	ck_assert_ptr_ne(strstr(output,
+				"bluealsa:DEV=23:45:67:89:AB:CD,PROFILE=sco,SRV=org.bluealsa.foo"), NULL);
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_play_all) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=a2dp-sink",
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--profile-a2dp",
+				"--pcm=null",
+				"-vv",
+				NULL), -1);
+	spawn_terminate(&sp_ba_aplay, 500);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stderr), 0);
+	fprintf(stderr, "%s", output);
+
+	/* check if playback was started from both devices */
+	ck_assert_ptr_ne(strstr(output,
+				"Used configuration for 12:34:56:78:9A:BC"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Used configuration for 23:45:67:89:AB:CD"), NULL);
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_play_single_audio) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=a2dp-sink",
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--single-audio",
+				"--profile-a2dp",
+				"--pcm=null",
+				"-vvv",
+				NULL), -1);
+	spawn_terminate(&sp_ba_aplay, 500);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stderr), 0);
+	fprintf(stderr, "%s", output);
+
+	/* Check if playback was started for only one device. However,
+	 * workers should be created for both devices. */
+
+#if DEBUG
+	ck_assert_ptr_ne(strstr(output,
+				"Creating IO worker 12:34:56:78:9A:BC"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Creating IO worker 23:45:67:89:AB:CD"), NULL);
+#endif
+
+	bool d1_ok = strstr(output, "Used configuration for 12:34:56:78:9A:BC") != NULL;
+	bool d2_ok = strstr(output, "Used configuration for 23:45:67:89:AB:CD") != NULL;
+	ck_assert_int_eq(d1_ok != d2_ok, true);
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_play_mixer_setup) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--device-name=23:45:67:89:AB:CD:Headset",
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	struct spawn_process sp_ba_aplay;
+	ck_assert_int_ne(spawn_bluealsa_aplay(&sp_ba_aplay,
+				"--profile-sco",
+				"--pcm=bluealsa:PROFILE=sco",
+				"--mixer-device=bluealsa:DEV=23:45:67:89:AB:CD",
+				"--mixer-name=SCO",
+				"-v",
+				NULL), -1);
+	spawn_terminate(&sp_ba_aplay, 500);
+
+	char output[4096] = "";
+	ck_assert_int_gt(fread(output, 1, sizeof(output) - 1, sp_ba_aplay.f_stderr), 0);
+	fprintf(stderr, "%s", output);
+
+#if DEBUG
+	ck_assert_ptr_ne(strstr(output,
+				"Opening ALSA mixer: name=bluealsa:DEV=23:45:67:89:AB:CD elem=SCO index=0"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Setting up ALSA mixer volume synchronization"), NULL);
+#endif
+
+	spawn_close(&sp_ba_aplay, NULL);
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+int main(int argc, char *argv[], char *envp[]) {
+	preload(argc, argv, envp, ".libs/aloader.so");
+
+	char *argv_0 = strdup(argv[0]);
+	char *argv_0_dir = dirname(argv_0);
+
+	snprintf(bluealsa_mock_path, sizeof(bluealsa_mock_path),
+			"%s/mock/bluealsa-mock", argv_0_dir);
+	snprintf(bluealsa_aplay_path, sizeof(bluealsa_aplay_path),
+			"%s/../utils/aplay/bluealsa-aplay", argv_0_dir);
+
+	Suite *s = suite_create(__FILE__);
+	TCase *tc = tcase_create(__FILE__);
+	SRunner *sr = srunner_create(s);
+
+	suite_add_tcase(s, tc);
+
+	tcase_add_test(tc, test_help);
+	tcase_add_test(tc, test_configuration);
+	tcase_add_test(tc, test_list_devices);
+	tcase_add_test(tc, test_list_pcms);
+	tcase_add_test(tc, test_play_all);
+	tcase_add_test(tc, test_play_single_audio);
+	tcase_add_test(tc, test_play_mixer_setup);
+
+	srunner_run_all(sr, CK_ENV);
+	int nf = srunner_ntests_failed(sr);
+
+	srunner_free(sr);
+	free(argv_0);
+
+	return nf == 0 ? 0 : 1;
+}
diff --git a/test/test-utils-cli.c b/test/test-utils-cli.c
new file mode 100644
index 0000000..18a6a88
--- /dev/null
+++ b/test/test-utils-cli.c
@@ -0,0 +1,448 @@
+/*
+ * test-utils-cli.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#if HAVE_CONFIG_H
+# include <config.h>
+#endif
+
+#include <libgen.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <check.h>
+
+#include "inc/check.inc"
+#include "inc/mock.inc"
+#include "inc/preload.inc"
+#include "inc/spawn.inc"
+
+static char bluealsa_cli_path[256];
+static int run_bluealsa_cli(char *output, size_t size, ...) {
+
+	char * argv[32] = { bluealsa_cli_path };
+	size_t n = 1;
+
+	va_list ap;
+	va_start(ap, size);
+
+	char *arg;
+	while ((arg = va_arg(ap, char *)) != NULL) {
+		argv[n++] = arg;
+		argv[n] = NULL;
+	}
+
+	va_end(ap);
+
+	struct spawn_process sp;
+	if (spawn(&sp, argv, NULL, SPAWN_FLAG_REDIRECT_STDOUT) == -1)
+		return -1;
+
+	size_t len = fread(output, 1, size - 1, sp.f_stdout);
+	output[len] = '\0';
+
+	fprintf(stderr, "%s", output);
+
+	int wstatus = 0;
+	spawn_close(&sp, &wstatus);
+	return WIFEXITED(wstatus) ? WEXITSTATUS(wstatus) : -1;
+}
+
+CK_START_TEST(test_help) {
+
+	char output[4096];
+
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"-qv", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_status) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=a2dp-source",
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"status", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check default command */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Service: org.bluealsa"), NULL);
+	ck_assert_ptr_ne(strstr(output, "A2DP-source"), NULL);
+	ck_assert_ptr_ne(strstr(output, "HFP-AG"), NULL);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_list_services) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "test", true, NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"list-services", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check service listing */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"list-services",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "org.bluealsa.test"), NULL);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_list_pcms) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, "test", true,
+				"--profile=a2dp-sink",
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"list-pcms", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check BlueALSA PCM listing */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"--dbus=test", "--verbose", "list-pcms",
+				NULL), 0);
+
+	ck_assert_ptr_ne(strstr(output,
+				"/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsnk/source"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"/org/bluealsa/hci0/dev_23_45_67_89_AB_CD/hspag/source"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"/org/bluealsa/hci0/dev_23_45_67_89_AB_CD/hspag/sink"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"/org/bluealsa/hci0/dev_23_45_67_89_AB_CD/a2dpsnk/source"), NULL);
+
+	/* check verbose output */
+	ck_assert_ptr_ne(strstr(output,
+				"Device: /org/bluez/hci0/dev_12_34_56_78_9A_BC"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Device: /org/bluez/hci0/dev_23_45_67_89_AB_CD"), NULL);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_info) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=a2dp-source",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"info", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check not existing BlueALSA PCM path */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"info", "/org/bluealsa/hci0/dev_FF_FF_FF_FF_FF_FF/a2dpsrc/sink",
+				NULL), EXIT_FAILURE);
+
+	/* check BlueALSA PCM info */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"info", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+
+	ck_assert_ptr_ne(strstr(output,
+				"Device: /org/bluez/hci0/dev_12_34_56_78_9A_BC"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Transport: A2DP-source"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Selected codec: SBC"), NULL);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_codec) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"codec", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check BlueALSA PCM codec get/set */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"-v", "codec", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Available codecs: CVSD"), NULL);
+
+#if !ENABLE_MSBC
+	/* CVSD shall be automatically selected if mSBC is not supported. */
+	ck_assert_ptr_ne(strstr(output, "Selected codec: CVSD"), NULL);
+#endif
+
+#if ENABLE_MSBC
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"codec", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/sink", "mSBC",
+				NULL), 0);
+
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"-v", "codec", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Selected codec: mSBC"), NULL);
+#endif
+
+	/* check selecting not available codec */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"codec", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/sink", "SBC",
+				NULL), EXIT_FAILURE);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_volume) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=a2dp-source",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"mute", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"soft-volume", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"volume", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check default volume */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Volume: L: 127 R: 127"), NULL);
+
+	/* check default mute */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"mute", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Muted: L: false R: false"), NULL);
+
+	/* check default soft-volume */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"soft-volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "SoftVolume: true"), NULL);
+
+	/* check setting volume */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink", "10", "50",
+				NULL), 0);
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Volume: L: 10 R: 50"), NULL);
+
+	/* check setting mute */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"mute", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink", "off", "on",
+				NULL), 0);
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"mute", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "Muted: L: false R: true"), NULL);
+
+	/* check setting soft-volume */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"soft-volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink", "off",
+				NULL), 0);
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"soft-volume", "/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsrc/sink",
+				NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "SoftVolume: false"), NULL);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_monitor) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, false,
+				"--timeout=0",
+				"--fuzzing=200",
+				"--profile=a2dp-source",
+				"--profile=hfp-ag",
+				NULL), -1);
+
+	char output[4096];
+
+	/* check printing help text */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"monitor", "--help", NULL), 0);
+	ck_assert_ptr_ne(strstr(output, "-h, --help"), NULL);
+
+	/* check monitor command */
+	ck_assert_int_eq(run_bluealsa_cli(output, sizeof(output),
+				"-v", "monitor", "--properties=codec,volume",
+				NULL), 0);
+
+	/* notifications for service start/stop */
+	ck_assert_ptr_ne(strstr(output, "ServiceRunning org.bluealsa"), NULL);
+	ck_assert_ptr_ne(strstr(output, "ServiceStopped org.bluealsa"), NULL);
+
+	/* notifications for PCM add/remove */
+	ck_assert_ptr_ne(strstr(output,
+				"PCMAdded /org/bluealsa/hci0/dev_23_45_67_89_AB_CD/a2dpsrc/sink"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"PCMRemoved /org/bluealsa/hci0/dev_23_45_67_89_AB_CD/a2dpsrc/sink"), NULL);
+
+	/* notifications for RFCOMM add/remove (because HFP is enabled) */
+	ck_assert_ptr_ne(strstr(output,
+				"RFCOMMAdded /org/bluealsa/hci0/dev_12_34_56_78_9A_BC/rfcomm"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"RFCOMMRemoved /org/bluealsa/hci0/dev_12_34_56_78_9A_BC/rfcomm"), NULL);
+
+	/* check verbose output */
+	ck_assert_ptr_ne(strstr(output,
+				"Device: /org/bluez/hci0/dev_12_34_56_78_9A_BC"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"Device: /org/bluez/hci0/dev_23_45_67_89_AB_CD"), NULL);
+
+#if ENABLE_MSBC
+	/* notifications for property changed */
+	ck_assert_ptr_ne(strstr(output,
+				"PropertyChanged /org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/sink Codec CVSD"), NULL);
+	ck_assert_ptr_ne(strstr(output,
+				"PropertyChanged /org/bluealsa/hci0/dev_12_34_56_78_9A_BC/hfpag/source Codec CVSD"), NULL);
+#endif
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+CK_START_TEST(test_open) {
+
+	struct spawn_process sp_ba_mock;
+	ck_assert_int_ne(spawn_bluealsa_mock(&sp_ba_mock, NULL, true,
+				"--profile=hsp-ag",
+				NULL), -1);
+
+	char * ba_cli_in_argv[32] = {
+		bluealsa_cli_path, "open",
+		"/org/bluealsa/hci0/dev_23_45_67_89_AB_CD/hspag/source",
+		NULL };
+	char * ba_cli_out_argv[32] = {
+		bluealsa_cli_path, "open",
+		"/org/bluealsa/hci0/dev_23_45_67_89_AB_CD/hspag/sink",
+		NULL };
+
+	struct spawn_process sp_ba_cli_in;
+	ck_assert_int_ne(spawn(&sp_ba_cli_in, ba_cli_in_argv,
+				NULL, SPAWN_FLAG_REDIRECT_STDOUT), -1);
+
+	struct spawn_process sp_ba_cli_out;
+	ck_assert_int_ne(spawn(&sp_ba_cli_out, ba_cli_out_argv,
+				sp_ba_cli_in.f_stdout, SPAWN_FLAG_NONE), -1);
+
+	/* let it run for a while */
+	sleep(1);
+
+	spawn_terminate(&sp_ba_cli_in, 0);
+	spawn_terminate(&sp_ba_cli_out, 0);
+
+	int wstatus = 0;
+	/* Make sure that both bluealsa-cli instances have been terminated by
+	 * us (SIGTERM) and not by premature exit or any other reason. */
+	spawn_close(&sp_ba_cli_in, &wstatus);
+	ck_assert_int_eq(WTERMSIG(wstatus), SIGTERM);
+	spawn_close(&sp_ba_cli_out, &wstatus);
+	ck_assert_int_eq(WTERMSIG(wstatus), SIGTERM);
+
+	spawn_terminate(&sp_ba_mock, 0);
+	spawn_close(&sp_ba_mock, NULL);
+
+} CK_END_TEST
+
+int main(int argc, char *argv[], char *envp[]) {
+	preload(argc, argv, envp, ".libs/aloader.so");
+
+	char *argv_0 = strdup(argv[0]);
+	char *argv_0_dir = dirname(argv_0);
+
+	snprintf(bluealsa_mock_path, sizeof(bluealsa_mock_path),
+			"%s/mock/bluealsa-mock", argv_0_dir);
+	snprintf(bluealsa_cli_path, sizeof(bluealsa_cli_path),
+			"%s/../utils/cli/bluealsa-cli", argv_0_dir);
+
+	Suite *s = suite_create(__FILE__);
+	TCase *tc = tcase_create(__FILE__);
+	SRunner *sr = srunner_create(s);
+
+	suite_add_tcase(s, tc);
+
+	tcase_add_test(tc, test_help);
+	tcase_add_test(tc, test_status);
+	tcase_add_test(tc, test_list_services);
+	tcase_add_test(tc, test_list_pcms);
+	tcase_add_test(tc, test_info);
+	tcase_add_test(tc, test_codec);
+	tcase_add_test(tc, test_volume);
+	tcase_add_test(tc, test_monitor);
+	tcase_add_test(tc, test_open);
+
+	srunner_run_all(sr, CK_ENV);
+	int nf = srunner_ntests_failed(sr);
+
+	srunner_free(sr);
+	free(argv_0);
+
+	return nf == 0 ? 0 : 1;
+}
diff --git a/test/test-utils.c b/test/test-utils.c
index b6656fe..19d0aa1 100644
--- a/test/test-utils.c
+++ b/test/test-utils.c
@@ -1,6 +1,6 @@
 /*
  * test-utils.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -14,32 +14,31 @@
 
 #include <errno.h>
 #include <stdint.h>
+#include <stdlib.h>
 #include <string.h>
 #include <time.h>
 
 #include <bluetooth/bluetooth.h>
 #include <check.h>
 
-#include "ba-transport.h"
 #include "hci.h"
-#include "hfp.h"
 #include "utils.h"
-#include "shared/a2dp-codecs.h"
-#include "shared/defs.h"
 #include "shared/ffb.h"
 #include "shared/hex.h"
 #include "shared/nv.h"
 #include "shared/rt.h"
 
-START_TEST(test_g_dbus_bluez_object_path_to_hci_dev_id) {
+#include "inc/check.inc"
+
+CK_START_TEST(test_g_dbus_bluez_object_path_to_hci_dev_id) {
 
 	ck_assert_int_eq(g_dbus_bluez_object_path_to_hci_dev_id("/org/bluez"), -1);
 	ck_assert_int_eq(g_dbus_bluez_object_path_to_hci_dev_id("/org/bluez/hci0"), 0);
 	ck_assert_int_eq(g_dbus_bluez_object_path_to_hci_dev_id("/org/bluez/hci5"), 5);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_g_dbus_bluez_object_path_to_bdaddr) {
+CK_START_TEST(test_g_dbus_bluez_object_path_to_bdaddr) {
 
 	bdaddr_t addr_ok = {{ 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12 }};
 	bdaddr_t addr;
@@ -55,58 +54,9 @@ START_TEST(test_g_dbus_bluez_object_path_to_bdaddr) {
 	ck_assert_ptr_eq(g_dbus_bluez_object_path_to_bdaddr(
 				"/org/bluez/dev_12_34_56_78_9A_XX", &addr), NULL);
 
-} END_TEST
-
-START_TEST(test_dbus_profile_object_path) {
-
-	static const struct {
-		struct ba_transport_type ttype;
-		const char *path;
-	} profiles[] = {
-		/* test null/invalid path */
-		{ { 0, -1 }, "/" },
-		{ { 0, -1 }, "/Invalid" },
-		/* test A2DP profiles */
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_SBC }, "/A2DP/SBC/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_SBC }, "/A2DP/SBC/source/1" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_SBC }, "/A2DP/SBC/source/2" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_SBC }, "/A2DP/SBC/sink" },
-#if ENABLE_MPEG
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_MPEG12 }, "/A2DP/MPEG/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_MPEG12 }, "/A2DP/MPEG/sink" },
-#endif
-#if ENABLE_AAC
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_MPEG24 }, "/A2DP/AAC/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_MPEG24 }, "/A2DP/AAC/sink" },
-#endif
-#if ENABLE_APTX
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_VENDOR_APTX }, "/A2DP/aptX/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_VENDOR_APTX }, "/A2DP/aptX/sink" },
-#endif
-#if ENABLE_APTX_HD
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_VENDOR_APTX_HD }, "/A2DP/aptXHD/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_VENDOR_APTX_HD }, "/A2DP/aptXHD/sink" },
-#endif
-#if ENABLE_LDAC
-		{ { BA_TRANSPORT_PROFILE_A2DP_SOURCE, A2DP_CODEC_VENDOR_LDAC }, "/A2DP/LDAC/source" },
-		{ { BA_TRANSPORT_PROFILE_A2DP_SINK, A2DP_CODEC_VENDOR_LDAC }, "/A2DP/LDAC/sink" },
-#endif
-		/* test HSP/HFP profiles */
-		{ { BA_TRANSPORT_PROFILE_HSP_HS, HFP_CODEC_CVSD }, "/HSP/Headset" },
-		{ { BA_TRANSPORT_PROFILE_HSP_AG, HFP_CODEC_CVSD }, "/HSP/AudioGateway" },
-		{ { BA_TRANSPORT_PROFILE_HFP_HF, HFP_CODEC_UNDEFINED }, "/HFP/HandsFree" },
-		{ { BA_TRANSPORT_PROFILE_HFP_AG, HFP_CODEC_UNDEFINED }, "/HFP/AudioGateway" },
-	};
-
-	size_t i;
-	for (i = 0; i < ARRAYSIZE(profiles); i++) {
-		const char *path = g_dbus_transport_type_to_bluez_object_path(profiles[i].ttype);
-		ck_assert_str_eq(strstr(profiles[i].path, path), profiles[i].path);
-	}
-
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_g_variant_sanitize_object_path) {
+CK_START_TEST(test_g_variant_sanitize_object_path) {
 
 	char path1[] = "/some/valid_path/123";
 	char path2[] = "/a#$*/invalid-path";
@@ -114,9 +64,10 @@ START_TEST(test_g_variant_sanitize_object_path) {
 	ck_assert_str_eq(g_variant_sanitize_object_path(path1), "/some/valid_path/123");
 	ck_assert_str_eq(g_variant_sanitize_object_path(path2), "/a___/invalid_path");
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_batostr_) {
+#if DEBUG
+CK_START_TEST(test_batostr_) {
 
 	const bdaddr_t ba = {{ 1, 2, 3, 4, 5, 6 }};
 	char tmp[18];
@@ -124,9 +75,10 @@ START_TEST(test_batostr_) {
 	ba2str(&ba, tmp);
 	ck_assert_str_eq(batostr_(&ba), tmp);
 
-} END_TEST
+} CK_END_TEST
+#endif
 
-START_TEST(test_nv_find) {
+CK_START_TEST(test_nv_find) {
 
 	const nv_entry_t entries[] = {
 		{ "name1", .v.i = 1 },
@@ -137,9 +89,9 @@ START_TEST(test_nv_find) {
 	ck_assert_ptr_eq(nv_find(entries, "invalid"), NULL);
 	ck_assert_ptr_eq(nv_find(entries, "name2"), &entries[1]);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_nv_join_names) {
+CK_START_TEST(test_nv_join_names) {
 
 	const nv_entry_t entries_zero[] = {{ 0 }};
 	const nv_entry_t entries[] = {
@@ -156,9 +108,9 @@ START_TEST(test_nv_join_names) {
 	ck_assert_str_eq(tmp = nv_join_names(entries), "name1, name2");
 	free(tmp);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_difftimespec) {
+CK_START_TEST(test_difftimespec) {
 
 	struct timespec ts1, ts2, ts;
 
@@ -216,9 +168,9 @@ START_TEST(test_difftimespec) {
 	ck_assert_int_eq(ts.tv_sec, 2);
 	ck_assert_int_eq(ts.tv_nsec, 0);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_ffb) {
+CK_START_TEST(test_ffb) {
 
 	ffb_t ffb_u8 = { 0 };
 	ffb_t ffb_16 = { 0 };
@@ -274,9 +226,9 @@ START_TEST(test_ffb) {
 	ffb_free(&ffb_16);
 	ck_assert_ptr_eq(ffb_16.data, NULL);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_ffb_resize) {
+CK_START_TEST(test_ffb_resize) {
 
 	const char *data = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
 	const size_t data_len = strlen(data);
@@ -299,9 +251,9 @@ START_TEST(test_ffb_resize) {
 
 	ffb_free(&ffb);
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_bin2hex) {
+CK_START_TEST(test_bin2hex) {
 
 	const uint8_t bin[] = { 0xDE, 0xAD, 0xBE, 0xEF };
 	char hex[sizeof(bin) * 2 + 1];
@@ -309,9 +261,9 @@ START_TEST(test_bin2hex) {
 	ck_assert_int_eq(bin2hex(bin, hex, sizeof(bin)), 8);
 	ck_assert_str_eq(hex, "deadbeef");
 
-} END_TEST
+} CK_END_TEST
 
-START_TEST(test_hex2bin) {
+CK_START_TEST(test_hex2bin) {
 
 	const uint8_t bin_ok[] = { 0xDE, 0xAD, 0xBE, 0xEF };
 	const char *hex = "DEADbeef";
@@ -323,7 +275,7 @@ START_TEST(test_hex2bin) {
 	ck_assert_int_eq(hex2bin(hex, bin, 3), -1);
 	ck_assert_int_eq(errno, EINVAL);
 
-} END_TEST
+} CK_END_TEST
 
 int main(void) {
 
@@ -350,9 +302,10 @@ int main(void) {
 
 	tcase_add_test(tc, test_g_dbus_bluez_object_path_to_hci_dev_id);
 	tcase_add_test(tc, test_g_dbus_bluez_object_path_to_bdaddr);
-	tcase_add_test(tc, test_dbus_profile_object_path);
 	tcase_add_test(tc, test_g_variant_sanitize_object_path);
+#if DEBUG
 	tcase_add_test(tc, test_batostr_);
+#endif
 
 	srunner_run_all(sr, CK_ENV);
 	int nf = srunner_ntests_failed(sr);
diff --git a/utils/a2dpconf.c b/utils/a2dpconf.c
index 5db8de7..d059d0b 100644
--- a/utils/a2dpconf.c
+++ b/utils/a2dpconf.c
@@ -13,9 +13,9 @@
 #endif
 
 #include <getopt.h>
+#include <stdbool.h>
 #include <stdint.h>
 #include <string.h>
-#include <strings.h>
 #include <stdio.h>
 #include <stdlib.h>
 
diff --git a/utils/aplay/alsa-pcm.c b/utils/aplay/alsa-pcm.c
index 346474b..ebe2ee6 100644
--- a/utils/aplay/alsa-pcm.c
+++ b/utils/aplay/alsa-pcm.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - alsa-pcm.c
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -163,3 +163,10 @@ fail:
 		free(tmp);
 	return err;
 }
+
+void alsa_pcm_dump(snd_pcm_t *pcm, FILE *fp) {
+	snd_output_t *out;
+	snd_output_stdio_attach(&out, fp, 0);
+	snd_pcm_dump(pcm, out);
+	snd_output_close(out);
+}
diff --git a/utils/aplay/alsa-pcm.h b/utils/aplay/alsa-pcm.h
index be332ea..73da92d 100644
--- a/utils/aplay/alsa-pcm.h
+++ b/utils/aplay/alsa-pcm.h
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - alsa-pcm.h
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -8,9 +8,12 @@
  *
  */
 
+#pragma once
 #ifndef BLUEALSA_APLAY_ALSAPCM_H_
 #define BLUEALSA_APLAY_ALSAPCM_H_
 
+#include <stdio.h>
+
 #include <alsa/asoundlib.h>
 
 int alsa_pcm_open(snd_pcm_t **pcm, const char *name,
@@ -18,4 +21,6 @@ int alsa_pcm_open(snd_pcm_t **pcm, const char *name,
 		unsigned int *buffer_time, unsigned int *period_time,
 		char **msg);
 
+void alsa_pcm_dump(snd_pcm_t *pcm, FILE *fp);
+
 #endif
diff --git a/utils/aplay/aplay.c b/utils/aplay/aplay.c
index 6e35021..e329c9b 100644
--- a/utils/aplay/aplay.c
+++ b/utils/aplay/aplay.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - aplay.c
- * Copyright (c) 2016-2021 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -19,10 +19,12 @@
 #include <poll.h>
 #include <pthread.h>
 #include <signal.h>
+#include <stdatomic.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/param.h>
 #include <unistd.h>
 
 #include <alsa/asoundlib.h>
@@ -37,7 +39,7 @@
 #include "alsa-pcm.h"
 #include "dbus.h"
 
-struct pcm_worker {
+struct io_worker {
 	pthread_t thread;
 	/* used BlueALSA PCM device */
 	struct ba_pcm ba_pcm;
@@ -46,12 +48,13 @@ struct pcm_worker {
 	/* file descriptor of PCM control */
 	int ba_pcm_ctrl_fd;
 	/* opened playback PCM device */
-	snd_pcm_t *pcm;
+	snd_pcm_t *snd_pcm;
 	/* mixer for volume control */
-	snd_mixer_t *mixer;
-	snd_mixer_elem_t *mixer_elem;
+	snd_mixer_t *snd_mixer;
+	snd_mixer_elem_t *snd_mixer_elem;
+	bool mixer_has_mute_switch;
 	/* if true, playback is active */
-	bool active;
+	atomic_bool active;
 	/* human-readable BT address */
 	char addr[18];
 };
@@ -69,7 +72,9 @@ static bdaddr_t *ba_addrs = NULL;
 static size_t ba_addrs_count = 0;
 static unsigned int pcm_buffer_time = 500000;
 static unsigned int pcm_period_time = 100000;
-static bool pcm_mixer = true;
+
+/* local PCM muted state for software mute */
+static bool pcm_muted = false;
 
 static struct ba_dbus_ctx dbus_ctx;
 static char dbus_ba_service[32] = BLUEALSA_SERVICE;
@@ -77,12 +82,15 @@ static char dbus_ba_service[32] = BLUEALSA_SERVICE;
 static struct ba_pcm *ba_pcms = NULL;
 static size_t ba_pcms_count = 0;
 
+static pthread_mutex_t single_playback_mutex = PTHREAD_MUTEX_INITIALIZER;
+static bool force_single_playback = false;
+
 static pthread_rwlock_t workers_lock = PTHREAD_RWLOCK_INITIALIZER;
-static struct pcm_worker *workers = NULL;
+static struct io_worker *workers = NULL;
 static size_t workers_count = 0;
 static size_t workers_size = 0;
 
-static bool main_loop_on = true;
+static atomic_bool main_loop_on = true;
 static void main_loop_stop(int sig) {
 	/* Call to this handler restores the default action, so on the
 	 * second call the program will be forcefully terminated. */
@@ -180,17 +188,17 @@ static void print_bt_device_list(void) {
 				ba2str(&dev.bt_addr, bt_addr);
 
 				printf("%s: %s [%s], %s%s\n",
-					dev.hci_name, bt_addr, dev.name,
-					dev.trusted ? "trusted ": "", dev.icon);
+						dev.hci_name, bt_addr, dev.name,
+						dev.trusted ? "trusted " : "", dev.icon);
 
 			}
 
 			printf("  %s (%s): %s %d channel%s %d Hz\n",
-				bluealsa_get_profile(pcm),
-				pcm->codec,
-				snd_pcm_format_name(bluealsa_get_snd_pcm_format(pcm)),
-				pcm->channels, pcm->channels != 1 ? "s" : "",
-				pcm->sampling);
+					bluealsa_get_profile(pcm),
+					pcm->codec.name,
+					snd_pcm_format_name(bluealsa_get_snd_pcm_format(pcm)),
+					pcm->channels, pcm->channels != 1 ? "s" : "",
+					pcm->sampling);
 
 		}
 	}
@@ -219,20 +227,20 @@ static void print_bt_pcm_list(void) {
 		ba2str(&dev.bt_addr, bt_addr);
 
 		printf(
-				"bluealsa:SRV=%s,DEV=%s,PROFILE=%s\n"
+				"bluealsa:DEV=%s,PROFILE=%s,SRV=%s\n"
 				"    %s, %s%s, %s\n"
 				"    %s (%s): %s %d channel%s %d Hz\n",
-			dbus_ba_service,
-			bt_addr,
-			pcm->transport & BA_PCM_TRANSPORT_MASK_A2DP ? "a2dp" : "sco",
-			dev.name,
-			dev.trusted ? "trusted ": "", dev.icon,
-			pcm->mode == BA_PCM_MODE_SINK ? "playback" : "capture",
-			bluealsa_get_profile(pcm),
-			pcm->codec,
-			snd_pcm_format_name(bluealsa_get_snd_pcm_format(pcm)),
-			pcm->channels, pcm->channels != 1 ? "s" : "",
-			pcm->sampling);
+				bt_addr,
+				pcm->transport & BA_PCM_TRANSPORT_MASK_A2DP ? "a2dp" : "sco",
+				dbus_ba_service,
+				dev.name,
+				dev.trusted ? "trusted " : "", dev.icon,
+				pcm->mode == BA_PCM_MODE_SINK ? "playback" : "capture",
+				bluealsa_get_profile(pcm),
+				pcm->codec.name,
+				snd_pcm_format_name(bluealsa_get_snd_pcm_format(pcm)),
+				pcm->channels, pcm->channels != 1 ? "s" : "",
+				pcm->sampling);
 
 	}
 
@@ -249,9 +257,9 @@ static struct ba_pcm *get_ba_pcm(const char *path) {
 	return NULL;
 }
 
-static struct pcm_worker *get_active_worker(void) {
+static struct io_worker *get_active_io_worker(void) {
 
-	struct pcm_worker *w = NULL;
+	struct io_worker *w = NULL;
 	size_t i;
 
 	pthread_rwlock_rdlock(&workers_lock);
@@ -299,19 +307,12 @@ final:
 }
 
 /**
- * Synchronize BlueALSA PCM volume with ALSA mixer element. */
-static int pcm_worker_mixer_volume_sync(
-		struct pcm_worker *worker,
+ * Update BlueALSA PCM volume according to ALSA mixer element. */
+static int io_worker_mixer_volume_sync_ba_pcm(
+		struct io_worker *worker,
 		struct ba_pcm *ba_pcm) {
 
-	/* skip sync in case of software volume */
-	if (ba_pcm->soft_volume)
-		return 0;
-
-	snd_mixer_elem_t *elem = worker->mixer_elem;
-	if (elem == NULL)
-		return 0;
-
+	snd_mixer_elem_t *elem = worker->snd_mixer_elem;
 	const int vmax = BA_PCM_VOLUME_MAX(ba_pcm);
 	long long volume_db_sum = 0;
 	bool muted = true;
@@ -320,15 +321,22 @@ static int pcm_worker_mixer_volume_sync(
 	for (ch = 0; snd_mixer_selem_has_playback_channel(elem, ch) == 1; ch++) {
 
 		long ch_volume_db;
-		int ch_switch;
+		int ch_switch = 1;
 
 		int err;
-		if ((err = snd_mixer_selem_get_playback_dB(elem, 0, &ch_volume_db)) != 0 ||
-				(err = snd_mixer_selem_get_playback_switch(elem, 0, &ch_switch)) != 0) {
-			error("Couldn't get playback volume: %s", snd_strerror(err));
+		if ((err = snd_mixer_selem_get_playback_dB(elem, 0, &ch_volume_db)) != 0) {
+			error("Couldn't get ALSA mixer playback dB level: %s", snd_strerror(err));
 			return -1;
 		}
 
+		/* mute switch is an optional feature for a mixer element */
+		if ((worker->mixer_has_mute_switch = snd_mixer_selem_has_playback_switch(elem))) {
+			if ((err = snd_mixer_selem_get_playback_switch(elem, 0, &ch_switch)) != 0) {
+				error("Couldn't get ALSA mixer playback switch: %s", snd_strerror(err));
+				return -1;
+			}
+		}
+
 		volume_db_sum += ch_volume_db;
 		if (ch_switch == 1)
 			muted = false;
@@ -343,6 +351,11 @@ static int pcm_worker_mixer_volume_sync(
 	 * round to the nearest integer. */
 	int volume = lround(pow(2, (0.01 * volume_db_sum / ch) / 10) * vmax);
 
+	/* If mixer element does not support playback switch,
+	 * use our global muted state. */
+	if (!worker->mixer_has_mute_switch)
+		muted = pcm_muted;
+
 	ba_pcm->volume.ch1_muted = muted;
 	ba_pcm->volume.ch1_volume = volume;
 	ba_pcm->volume.ch2_muted = muted;
@@ -350,7 +363,7 @@ static int pcm_worker_mixer_volume_sync(
 
 	DBusError err = DBUS_ERROR_INIT;
 	if (!bluealsa_dbus_pcm_update(&dbus_ctx, ba_pcm, BLUEALSA_PCM_VOLUME, &err)) {
-		error("Couldn't update PCM: %s", err.message);
+		error("Couldn't update BlueALSA source PCM: %s", err.message);
 		dbus_error_free(&err);
 		return -1;
 	}
@@ -360,15 +373,15 @@ static int pcm_worker_mixer_volume_sync(
 
 /**
  * Update ALSA mixer element according to BlueALSA PCM volume. */
-static int pcm_worker_mixer_volume_update(
-		struct pcm_worker *worker,
+static int io_worker_mixer_volume_sync_snd_mixer_elem(
+		struct io_worker *worker,
 		struct ba_pcm *ba_pcm) {
 
 	/* skip update in case of software volume */
 	if (ba_pcm->soft_volume)
 		return 0;
 
-	snd_mixer_elem_t *elem = worker->mixer_elem;
+	snd_mixer_elem_t *elem = worker->snd_mixer_elem;
 	if (elem == NULL)
 		return 0;
 
@@ -388,20 +401,60 @@ static int pcm_worker_mixer_volume_update(
 		muted = ba_pcm->volume.ch1_muted || ba_pcm->volume.ch2_muted;
 	}
 
+	/* keep local muted state up to date */
+	pcm_muted = muted;
+
 	/* convert loudness to dB using decibel formula */
 	long db = 10 * log2(1.0 * volume / vmax) * 100;
 
 	int err;
-	if ((err = snd_mixer_selem_set_playback_dB_all(elem, db, 0)) != 0 ||
+	if ((err = snd_mixer_selem_set_playback_dB_all(elem, db, 0)) != 0) {
+		error("Couldn't set ALSA mixer playback dB level: %s", snd_strerror(err));
+		return -1;
+	}
+
+	/* mute switch is an optional feature for a mixer element */
+	if (worker->mixer_has_mute_switch &&
 			(err = snd_mixer_selem_set_playback_switch_all(elem, !muted)) != 0) {
-		error("Couldn't set playback volume: %s", snd_strerror(err));
+		error("Couldn't set ALSA mixer playback mute switch: %s", snd_strerror(err));
 		return -1;
 	}
 
 	return 0;
 }
 
-static void pcm_worker_routine_exit(struct pcm_worker *worker) {
+int io_worker_mixer_elem_callback(snd_mixer_elem_t *elem, unsigned int mask) {
+	struct io_worker *worker = snd_mixer_elem_get_callback_private(elem);
+	if (mask & SND_CTL_EVENT_MASK_VALUE)
+		io_worker_mixer_volume_sync_ba_pcm(worker, &worker->ba_pcm);
+	return 0;
+}
+
+/**
+ * Setup volume synchronization between ALSA mixer and BlueALSA PCM. */
+static int io_worker_mixer_volume_sync_setup(
+		struct io_worker *worker) {
+
+	/* skip setup in case of software volume */
+	if (worker->ba_pcm.soft_volume)
+		return 0;
+
+	snd_mixer_elem_t *elem = worker->snd_mixer_elem;
+	if (elem == NULL)
+		return 0;
+
+	debug("Setting up ALSA mixer volume synchronization");
+
+	snd_mixer_elem_set_callback(elem, io_worker_mixer_elem_callback);
+	snd_mixer_elem_set_callback_private(elem, worker);
+
+	/* initial synchronization */
+	io_worker_mixer_volume_sync_ba_pcm(worker, &worker->ba_pcm);
+
+	return 0;
+}
+
+static void io_worker_routine_exit(struct io_worker *worker) {
 	if (worker->ba_pcm_fd != -1) {
 		close(worker->ba_pcm_fd);
 		worker->ba_pcm_fd = -1;
@@ -410,19 +463,19 @@ static void pcm_worker_routine_exit(struct pcm_worker *worker) {
 		close(worker->ba_pcm_ctrl_fd);
 		worker->ba_pcm_ctrl_fd = -1;
 	}
-	if (worker->pcm != NULL) {
-		snd_pcm_close(worker->pcm);
-		worker->pcm = NULL;
+	if (worker->snd_pcm != NULL) {
+		snd_pcm_close(worker->snd_pcm);
+		worker->snd_pcm = NULL;
 	}
-	if (worker->mixer != NULL) {
-		snd_mixer_close(worker->mixer);
-		worker->mixer_elem = NULL;
-		worker->mixer = NULL;
+	if (worker->snd_mixer != NULL) {
+		snd_mixer_close(worker->snd_mixer);
+		worker->snd_mixer_elem = NULL;
+		worker->snd_mixer = NULL;
 	}
-	debug("Exiting PCM worker %s", worker->addr);
+	debug("Exiting IO worker %s", worker->addr);
 }
 
-static void *pcm_worker_routine(struct pcm_worker *w) {
+static void *io_worker_routine(struct io_worker *w) {
 
 	snd_pcm_format_t pcm_format = bluealsa_get_snd_pcm_format(&w->ba_pcm);
 	ssize_t pcm_format_size = snd_pcm_format_size(pcm_format, 1);
@@ -433,7 +486,7 @@ static void *pcm_worker_routine(struct pcm_worker *w) {
 	 * in order to prevent memory leaks and resources not being released. */
 	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 
-	pthread_cleanup_push(PTHREAD_CLEANUP(pcm_worker_routine_exit), w);
+	pthread_cleanup_push(PTHREAD_CLEANUP(io_worker_routine_exit), w);
 	pthread_cleanup_push(PTHREAD_CLEANUP(ffb_free), &buffer);
 
 	/* create buffer big enough to hold 100 ms of PCM data */
@@ -443,9 +496,10 @@ static void *pcm_worker_routine(struct pcm_worker *w) {
 	}
 
 	DBusError err = DBUS_ERROR_INIT;
+	debug("Opening BlueALSA source PCM: %s", w->ba_pcm.pcm_path);
 	if (!bluealsa_dbus_pcm_open(&dbus_ctx, w->ba_pcm.pcm_path,
 				&w->ba_pcm_fd, &w->ba_pcm_ctrl_fd, &err)) {
-		error("Couldn't open PCM: %s", err.message);
+		error("Couldn't open BlueALSA source PCM: %s", err.message);
 		dbus_error_free(&err);
 		goto fail;
 	}
@@ -454,163 +508,247 @@ static void *pcm_worker_routine(struct pcm_worker *w) {
 	 * will be opened, this value will be adjusted to one period size. */
 	size_t pcm_max_read_len_init = pcm_1s_samples / 100 * pcm_format_size;
 	size_t pcm_max_read_len = pcm_max_read_len_init;
+
+	/* Track the lock state of the single playback mutex within this thread. */
+	bool single_playback_mutex_locked = false;
+
+	/* Intervals in seconds between consecutive PCM open retry attempts. */
+	const unsigned int pcm_open_retry_intervals[] = { 1, 1, 2, 3, 5 };
+	size_t pcm_open_retry_pcm_samples = 0;
 	size_t pcm_open_retries = 0;
 
-	/* These variables determine how and when the pause command will be send
-	 * to the device player. In order not to flood BT connection with AVRCP
-	 * packets, we are going to send pause command every 0.5 second. */
-	size_t pause_threshold = pcm_1s_samples / 2 * pcm_format_size;
-	size_t pause_counter = 0;
-	size_t pause_bytes = 0;
+	size_t pause_retry_pcm_samples = pcm_1s_samples;
+	size_t pause_retries = 0;
 
-	struct pollfd pfds[] = {{ w->ba_pcm_fd, POLLIN, 0 }};
 	int timeout = -1;
 
-	debug("Starting PCM loop");
+	debug("Starting IO loop");
 	while (main_loop_on) {
-		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
 
-		ssize_t ret;
+		if (single_playback_mutex_locked) {
+			pthread_mutex_unlock(&single_playback_mutex);
+			single_playback_mutex_locked = false;
+		}
+
+		struct pollfd fds[16] = {{ w->ba_pcm_fd, POLLIN, 0 }};
+		nfds_t nfds = 1;
+
+		if (w->snd_mixer != NULL)
+			nfds += snd_mixer_poll_descriptors_count(w->snd_mixer);
+
+		if (nfds > ARRAYSIZE(fds)) {
+			error("Poll FD array size exceeded: %zu > %zu", nfds, ARRAYSIZE(fds));
+			goto fail;
+		}
+
+		if (w->snd_mixer != NULL)
+			snd_mixer_poll_descriptors(w->snd_mixer, fds + 1, nfds - 1);
 
 		/* Reading from the FIFO won't block unless there is an open connection
 		 * on the writing side. However, the server does not open PCM FIFO until
 		 * a transport is created. With the A2DP, the transport is created when
 		 * some clients (BT device) requests audio transfer. */
-		switch (poll(pfds, ARRAYSIZE(pfds), timeout)) {
+
+		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+		int poll_rv = poll(fds, nfds, timeout);
+		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+
+		switch (poll_rv) {
 		case -1:
 			if (errno == EINTR)
 				continue;
-			error("PCM FIFO poll error: %s", strerror(errno));
+			error("IO loop poll error: %s", strerror(errno));
 			goto fail;
 		case 0:
-			debug("Device marked as inactive: %s", w->addr);
-			pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
-			pcm_max_read_len = pcm_max_read_len_init;
-			pause_counter = pause_bytes = 0;
-			ffb_rewind(&buffer);
-			if (w->pcm != NULL) {
-				snd_pcm_close(w->pcm);
-				w->pcm = NULL;
-			}
+			debug("BT device marked as inactive: %s", w->addr);
+			pause_retry_pcm_samples = pcm_1s_samples;
+			pause_retries = 0;
 			w->active = false;
 			timeout = -1;
-			continue;
+			goto close_alsa;
 		}
 
-		/* FIFO has been terminated on the writing side */
-		if (pfds[0].revents & POLLHUP)
-			break;
+		if (w->snd_mixer != NULL)
+			snd_mixer_handle_events(w->snd_mixer);
 
-		#define MIN(a,b) a < b ? a : b
-		size_t _in = MIN(pcm_max_read_len, ffb_blen_in(&buffer));
-		if ((ret = read(w->ba_pcm_fd, buffer.tail, _in)) == -1) {
-			if (errno == EINTR)
-				continue;
-			error("PCM FIFO read error: %s", strerror(errno));
-			goto fail;
+		size_t read_samples = 0;
+		if (fds[0].revents & POLLIN) {
+
+			ssize_t ret;
+			size_t _in = MIN(pcm_max_read_len, ffb_blen_in(&buffer));
+			if ((ret = read(w->ba_pcm_fd, buffer.tail, _in)) == -1) {
+				if (errno == EINTR)
+					continue;
+				error("BlueALSA source PCM read error: %s", strerror(errno));
+				goto fail;
+			}
+
+			read_samples = ret / pcm_format_size;
+			if (ret % pcm_format_size != 0)
+				warn("Invalid read from BlueALSA source PCM: %zd %% %zd != 0", ret, pcm_format_size);
+
+		}
+		else if (fds[0].revents & POLLHUP) {
+			/* source PCM FIFO has been terminated on the writing side */
+			debug("BlueALSA source PCM disconnected: %s", w->ba_pcm.pcm_path);
+			break;
 		}
+		else if (fds[0].revents)
+			error("Unexpected BlueALSA source PCM poll event: %#x", fds[0].revents);
 
-		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+		if (read_samples == 0)
+			continue;
 
-		/* If PCM mixer is disabled, check whether we should play audio. */
-		if (!pcm_mixer) {
-			struct pcm_worker *worker = get_active_worker();
-			if (worker != NULL && worker != w) {
-				if (pause_counter < 5 && (pause_bytes += ret) > pause_threshold) {
+		/* If current worker is not active and the single playback mode was
+		 * enabled, we have to check if there is any other active worker. */
+		if (force_single_playback && !w->active) {
+
+			/* Before checking active worker, we need to lock the single playback
+			 * mutex. It is required to lock it, because the active state is changed
+			 * in the worker thread after opening the PCM device, so we have to
+			 * synchronize all threads at this point. */
+			pthread_mutex_lock(&single_playback_mutex);
+			single_playback_mutex_locked = true;
+
+			if (get_active_io_worker() != NULL) {
+				/* In order not to flood BT connection with AVRCP packets,
+				 * we are going to send pause command every 0.5 second. */
+				if (pause_retries < 5 &&
+						(pause_retry_pcm_samples += read_samples) > pcm_1s_samples / 2) {
 					if (pause_device_player(&w->ba_pcm) == -1)
 						/* pause command does not work, stop further requests */
-						pause_counter = 5;
-					pause_counter++;
-					pause_bytes = 0;
+						pause_retries = 5;
+					pause_retry_pcm_samples = 0;
+					pause_retries++;
 					timeout = 100;
 				}
 				continue;
 			}
+
 		}
 
-		if (w->pcm == NULL) {
+		if (w->snd_pcm == NULL) {
 
 			unsigned int buffer_time = pcm_buffer_time;
 			unsigned int period_time = pcm_period_time;
-			snd_pcm_uframes_t buffer_size;
-			snd_pcm_uframes_t period_size;
+			snd_pcm_uframes_t buffer_frames;
+			snd_pcm_uframes_t period_frames;
 			char *tmp;
 
-			/* After PCM open failure wait one second before retry. This can not be
-			 * done with a single sleep() call, because we have to drain PCM FIFO. */
-			if (pcm_open_retries++ % 20 != 0) {
-				usleep(50000);
-				continue;
+			if (pcm_open_retries > 0) {
+				/* After PCM open failure wait some time before retry. This can not be
+				 * done with a sleep() call, because we have to drain PCM FIFO, so it
+				 * will not have any stale data. */
+				unsigned int interval = pcm_open_retries > ARRAYSIZE(pcm_open_retry_intervals) ?
+					pcm_open_retry_intervals[ARRAYSIZE(pcm_open_retry_intervals) - 1] :
+					pcm_open_retry_intervals[pcm_open_retries - 1];
+				if ((pcm_open_retry_pcm_samples += read_samples) <= interval * pcm_1s_samples)
+					continue;
 			}
 
-			if (alsa_pcm_open(&w->pcm, pcm_device, pcm_format, w->ba_pcm.channels,
+			debug("Opening ALSA playback PCM: name=%s channels=%u rate=%u",
+					pcm_device, w->ba_pcm.channels, w->ba_pcm.sampling);
+			if (alsa_pcm_open(&w->snd_pcm, pcm_device, pcm_format, w->ba_pcm.channels,
 						w->ba_pcm.sampling, &buffer_time, &period_time, &tmp) != 0) {
-				warn("Couldn't open PCM: %s", tmp);
+				warn("Couldn't open ALSA playback PCM: %s", tmp);
 				pcm_max_read_len = pcm_max_read_len_init;
-				usleep(50000);
+				pcm_open_retry_pcm_samples = 0;
+				pcm_open_retries++;
 				free(tmp);
 				continue;
 			}
 
-			if (alsa_mixer_open(&w->mixer, &w->mixer_elem,
+			snd_pcm_get_params(w->snd_pcm, &buffer_frames, &period_frames);
+			pcm_max_read_len = period_frames * w->ba_pcm.channels * pcm_format_size;
+
+			debug("Opening ALSA mixer: name=%s elem=%s index=%u",
+					mixer_device, mixer_elem_name, mixer_elem_index);
+			if (alsa_mixer_open(&w->snd_mixer, &w->snd_mixer_elem,
 						mixer_device, mixer_elem_name, mixer_elem_index, &tmp) != 0) {
-				warn("Couldn't open mixer: %s", tmp);
+				warn("Couldn't open ALSA mixer: %s", tmp);
 				free(tmp);
 			}
 
-			/* initial volume synchronization */
-			pcm_worker_mixer_volume_sync(w, &w->ba_pcm);
+			io_worker_mixer_volume_sync_setup(w);
 
-			snd_pcm_get_params(w->pcm, &buffer_size, &period_size);
-			pcm_max_read_len = period_size * w->ba_pcm.channels * pcm_format_size;
+			/* reset retry counters */
+			pcm_open_retry_pcm_samples = 0;
 			pcm_open_retries = 0;
 
 			if (verbose >= 2) {
-				printf("Used configuration for %s:\n"
-						"  PCM buffer time: %u us (%zu bytes)\n"
-						"  PCM period time: %u us (%zu bytes)\n"
+				info("Used configuration for %s:\n"
+						"  ALSA PCM buffer time: %u us (%zu bytes)\n"
+						"  ALSA PCM period time: %u us (%zu bytes)\n"
 						"  PCM format: %s\n"
 						"  Sampling rate: %u Hz\n"
-						"  Channels: %u\n",
+						"  Channels: %u",
 						w->addr,
-						buffer_time, snd_pcm_frames_to_bytes(w->pcm, buffer_size),
-						period_time, snd_pcm_frames_to_bytes(w->pcm, period_size),
+						buffer_time, snd_pcm_frames_to_bytes(w->snd_pcm, buffer_frames),
+						period_time, snd_pcm_frames_to_bytes(w->snd_pcm, period_frames),
 						snd_pcm_format_name(pcm_format),
 						w->ba_pcm.sampling,
 						w->ba_pcm.channels);
 			}
 
+			if (verbose >= 3)
+				alsa_pcm_dump(w->snd_pcm, stderr);
+
 		}
 
 		/* mark device as active and set timeout to 500ms */
 		w->active = true;
 		timeout = 500;
 
-		ffb_seek(&buffer, ret / pcm_format_size);
+		/* Current worker was marked as active, so we can safely
+		 * release the single playback mutex if it was locked. */
+		if (single_playback_mutex_locked) {
+			pthread_mutex_unlock(&single_playback_mutex);
+			single_playback_mutex_locked = false;
+		}
+
+		ffb_seek(&buffer, read_samples);
+		size_t samples = ffb_len_out(&buffer);
+
+		if (!w->mixer_has_mute_switch && pcm_muted)
+			snd_pcm_format_set_silence(pcm_format, buffer.data, samples);
 
-		/* calculate the overall number of frames in the buffer */
-		snd_pcm_sframes_t frames = ffb_len_out(&buffer) / w->ba_pcm.channels;
+		snd_pcm_sframes_t frames;
 
-		if ((frames = snd_pcm_writei(w->pcm, buffer.data, frames)) < 0)
+retry_alsa_write:
+		frames = samples / w->ba_pcm.channels;
+		if ((frames = snd_pcm_writei(w->snd_pcm, buffer.data, frames)) < 0)
 			switch (-frames) {
+			case EINTR:
+				goto retry_alsa_write;
 			case EPIPE:
-				debug("An underrun has occurred");
-				snd_pcm_prepare(w->pcm);
-				usleep(50000);
-				frames = 0;
-				break;
+				debug("ALSA playback PCM underrun");
+				snd_pcm_prepare(w->snd_pcm);
+				goto retry_alsa_write;
 			default:
-				error("Couldn't write to PCM: %s", snd_strerror(frames));
-				goto fail;
+				error("ALSA playback PCM write error: %s", snd_strerror(frames));
+				goto close_alsa;
 			}
 
 		/* move leftovers to the beginning and reposition tail */
 		ffb_shift(&buffer, frames * w->ba_pcm.channels);
 
+		continue;
+
+close_alsa:
+		ffb_rewind(&buffer);
+		pcm_max_read_len = pcm_max_read_len_init;
+		if (w->snd_pcm != NULL) {
+			snd_pcm_close(w->snd_pcm);
+			w->snd_pcm = NULL;
+		}
+		if (w->snd_mixer != NULL) {
+			snd_mixer_close(w->snd_mixer);
+			w->snd_mixer_elem = NULL;
+			w->snd_mixer = NULL;
+		}
 	}
 
 fail:
-	pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
 	pthread_cleanup_pop(1);
 	pthread_cleanup_pop(1);
 	return NULL;
@@ -630,7 +768,7 @@ static bool pcm_hw_params_equal(
 
 /**
  * Stop the worker thread at workers[index]. */
-static void pcm_worker_stop(size_t index) {
+static void io_worker_stop(size_t index) {
 
 	/* Safety check for out-of-bounds read. */
 	assert(index < workers_count);
@@ -649,7 +787,7 @@ static void pcm_worker_stop(size_t index) {
 
 }
 
-static struct pcm_worker *supervise_pcm_worker_start(const struct ba_pcm *ba_pcm) {
+static struct io_worker *supervise_io_worker_start(const struct ba_pcm *ba_pcm) {
 
 	size_t i;
 	for (i = 0; i < workers_count; i++)
@@ -658,7 +796,7 @@ static struct pcm_worker *supervise_pcm_worker_start(const struct ba_pcm *ba_pcm
 			 * audio format may have changed. If it has, the worker thread
 			 * needs to be restarted. */
 			if (!pcm_hw_params_equal(&workers[i].ba_pcm, ba_pcm))
-				pcm_worker_stop(i);
+				io_worker_stop(i);
 			else
 				return &workers[i];
 		}
@@ -667,31 +805,34 @@ static struct pcm_worker *supervise_pcm_worker_start(const struct ba_pcm *ba_pcm
 
 	workers_count++;
 	if (workers_size < workers_count) {
-		struct pcm_worker *tmp = workers;
+		struct io_worker *tmp = workers;
 		workers_size += 4;  /* coarse-grained realloc */
 		if ((workers = realloc(workers, sizeof(*workers) * workers_size)) == NULL) {
-			error("Couldn't (re)allocate memory for PCM workers: %s", strerror(ENOMEM));
+			error("Couldn't (re)allocate memory for IO workers: %s", strerror(ENOMEM));
 			workers = tmp;
 			pthread_rwlock_unlock(&workers_lock);
 			return NULL;
 		}
 	}
 
-	struct pcm_worker *worker = &workers[workers_count - 1];
+	struct io_worker *worker = &workers[workers_count - 1];
 	memcpy(&worker->ba_pcm, ba_pcm, sizeof(worker->ba_pcm));
 	ba2str(&worker->ba_pcm.addr, worker->addr);
-	worker->active = false;
 	worker->ba_pcm_fd = -1;
 	worker->ba_pcm_ctrl_fd = -1;
-	worker->pcm = NULL;
+	worker->snd_pcm = NULL;
+	worker->snd_mixer = NULL;
+	worker->snd_mixer_elem = NULL;
+	worker->mixer_has_mute_switch = false;
+	worker->active = false;
 
 	pthread_rwlock_unlock(&workers_lock);
 
-	debug("Creating PCM worker %s", worker->addr);
+	debug("Creating IO worker %s", worker->addr);
 
 	if ((errno = pthread_create(&worker->thread, NULL,
-					PTHREAD_ROUTINE(pcm_worker_routine), worker)) != 0) {
-		error("Couldn't create PCM worker %s: %s", worker->addr, strerror(errno));
+					PTHREAD_FUNC(io_worker_routine), worker)) != 0) {
+		error("Couldn't create IO worker %s: %s", worker->addr, strerror(errno));
 		workers_count--;
 		return NULL;
 	}
@@ -699,17 +840,17 @@ static struct pcm_worker *supervise_pcm_worker_start(const struct ba_pcm *ba_pcm
 	return worker;
 }
 
-static struct pcm_worker *supervise_pcm_worker_stop(const struct ba_pcm *ba_pcm) {
+static struct io_worker *supervise_io_worker_stop(const struct ba_pcm *ba_pcm) {
 
 	size_t i;
 	for (i = 0; i < workers_count; i++)
 		if (strcmp(workers[i].ba_pcm.pcm_path, ba_pcm->pcm_path) == 0)
-			pcm_worker_stop(i);
+			io_worker_stop(i);
 
 	return NULL;
 }
 
-static struct pcm_worker *supervise_pcm_worker(const struct ba_pcm *ba_pcm) {
+static struct io_worker *supervise_io_worker(const struct ba_pcm *ba_pcm) {
 
 	if (ba_pcm == NULL)
 		return NULL;
@@ -737,9 +878,9 @@ static struct pcm_worker *supervise_pcm_worker(const struct ba_pcm *ba_pcm) {
 			goto start;
 
 stop:
-	return supervise_pcm_worker_stop(ba_pcm);
+	return supervise_io_worker_stop(ba_pcm);
 start:
-	return supervise_pcm_worker_start(ba_pcm);
+	return supervise_io_worker_start(ba_pcm);
 }
 
 static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *message, void *data) {
@@ -754,7 +895,7 @@ static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *
 	const char *signal = dbus_message_get_member(message);
 
 	DBusMessageIter iter;
-	struct pcm_worker *worker;
+	struct io_worker *worker;
 
 	if (strcmp(interface, DBUS_INTERFACE_OBJECT_MANAGER) == 0) {
 
@@ -764,7 +905,7 @@ static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *
 			struct ba_pcm pcm;
 			DBusError err = DBUS_ERROR_INIT;
 			if (!bluealsa_dbus_message_iter_get_pcm(&iter, &err, &pcm)) {
-				error("Couldn't add new PCM: %s", err.message);
+				error("Couldn't add new BlueALSA PCM: %s", err.message);
 				dbus_error_free(&err);
 				goto fail;
 			}
@@ -772,26 +913,26 @@ static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *
 				goto fail;
 			struct ba_pcm *tmp = ba_pcms;
 			if ((ba_pcms = realloc(ba_pcms, (ba_pcms_count + 1) * sizeof(*ba_pcms))) == NULL) {
-				error("Couldn't add new PCM: %s", strerror(ENOMEM));
+				error("Couldn't add new BlueALSA PCM: %s", strerror(ENOMEM));
 				ba_pcms = tmp;
 				goto fail;
 			}
 			memcpy(&ba_pcms[ba_pcms_count++], &pcm, sizeof(*ba_pcms));
-			supervise_pcm_worker(&pcm);
+			supervise_io_worker(&pcm);
 			return DBUS_HANDLER_RESULT_HANDLED;
 		}
 
 		if (strcmp(signal, "InterfacesRemoved") == 0) {
 			if (!dbus_message_iter_init(message, &iter) ||
 					dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_OBJECT_PATH) {
-				error("Couldn't remove PCM: %s", "Invalid signal signature");
+				error("Couldn't remove BlueALSA PCM: %s", "Invalid signal signature");
 				goto fail;
 			}
 			dbus_message_iter_get_basic(&iter, &path);
 			struct ba_pcm *pcm;
 			if ((pcm = get_ba_pcm(path)) == NULL)
 				goto fail;
-			supervise_pcm_worker_stop(pcm);
+			supervise_io_worker_stop(pcm);
 			return DBUS_HANDLER_RESULT_HANDLED;
 		}
 
@@ -803,15 +944,15 @@ static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *
 			goto fail;
 		if (!dbus_message_iter_init(message, &iter) ||
 				dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_STRING) {
-			error("Couldn't update PCM: %s", "Invalid signal signature");
+			error("Couldn't update BlueALSA PCM: %s", "Invalid signal signature");
 			goto fail;
 		}
 		dbus_message_iter_get_basic(&iter, &interface);
 		dbus_message_iter_next(&iter);
 		if (!bluealsa_dbus_message_iter_get_pcm_props(&iter, NULL, pcm))
 			goto fail;
-		if ((worker = supervise_pcm_worker(pcm)) != NULL)
-			pcm_worker_mixer_volume_update(worker, pcm);
+		if ((worker = supervise_io_worker(pcm)) != NULL)
+			io_worker_mixer_volume_sync_snd_mixer_elem(worker, pcm);
 		return DBUS_HANDLER_RESULT_HANDLED;
 	}
 
@@ -822,10 +963,11 @@ fail:
 int main(int argc, char *argv[]) {
 
 	int opt;
-	const char *opts = "hVvlLB:D:M:";
+	const char *opts = "hVSvlLB:D:M:";
 	const struct option longopts[] = {
 		{ "help", no_argument, NULL, 'h' },
 		{ "version", no_argument, NULL, 'V' },
+		{ "syslog", no_argument, NULL, 'S' },
 		{ "verbose", no_argument, NULL, 'v' },
 		{ "list-devices", no_argument, NULL, 'l' },
 		{ "list-pcms", no_argument, NULL, 'L' },
@@ -842,6 +984,12 @@ int main(int argc, char *argv[]) {
 		{ 0, 0, 0, 0 },
 	};
 
+	bool syslog = false;
+
+	/* Check if syslog forwarding has been enabled. This check has to be
+	 * done before anything else, so we can log early stage warnings and
+	 * errors. */
+	opterr = 0;
 	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
 		switch (opt) {
 		case 'h' /* --help */ :
@@ -850,6 +998,7 @@ int main(int argc, char *argv[]) {
 					"\nOptions:\n"
 					"  -h, --help\t\t\tprint this help and exit\n"
 					"  -V, --version\t\t\tprint version and exit\n"
+					"  -S, --syslog\t\t\tsend output to syslog\n"
 					"  -v, --verbose\t\t\tmake output more verbose\n"
 					"  -l, --list-devices\t\tlist available BT audio devices\n"
 					"  -L, --list-pcms\t\tlist available BT audio PCMs\n"
@@ -875,9 +1024,27 @@ int main(int argc, char *argv[]) {
 			printf("%s\n", PACKAGE_VERSION);
 			return EXIT_SUCCESS;
 
+		case 'S' /* --syslog */ :
+			syslog = true;
+			break;
+
 		case 'v' /* --verbose */ :
 			verbose++;
 			break;
+		}
+
+	log_open(basename(argv[0]), syslog);
+	dbus_threads_init_default();
+
+	/* parse options */
+	optind = 0; opterr = 1;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+		case 'V' /* --version */ :
+		case 'S' /* --syslog */ :
+		case 'v' /* --verbose */ :
+			break;
 
 		case 'l' /* --list-devices */ :
 			list_bt_devices = true;
@@ -922,7 +1089,7 @@ int main(int argc, char *argv[]) {
 			break;
 
 		case 5 /* --single-audio */ :
-			pcm_mixer = false;
+			force_single_playback = true;
 			break;
 
 		default:
@@ -930,9 +1097,6 @@ int main(int argc, char *argv[]) {
 			return EXIT_FAILURE;
 		}
 
-	log_open(argv[0], false, false);
-	dbus_threads_init_default();
-
 	DBusError err = DBUS_ERROR_INIT;
 	if (!bluealsa_dbus_connection_ctx_init(&dbus_ctx, dbus_ba_service, &err)) {
 		error("Couldn't initialize D-Bus context: %s", err.message);
@@ -971,15 +1135,15 @@ int main(int argc, char *argv[]) {
 		for (i = 0; i < ba_addrs_count; i++, tmp += 19)
 			ba2str(&ba_addrs[i], stpcpy(tmp, ", "));
 
-		printf("Selected configuration:\n"
+		info("Selected configuration:\n"
 				"  BlueALSA service: %s\n"
-				"  PCM device: %s\n"
-				"  PCM buffer time: %u us\n"
-				"  PCM period time: %u us\n"
+				"  ALSA PCM device: %s\n"
+				"  ALSA PCM buffer time: %u us\n"
+				"  ALSA PCM period time: %u us\n"
 				"  ALSA mixer device: %s\n"
 				"  ALSA mixer element: '%s',%u\n"
 				"  Bluetooth device(s): %s\n"
-				"  Profile: %s\n",
+				"  Profile: %s",
 				dbus_ba_service,
 				pcm_device, pcm_buffer_time, pcm_period_time,
 				mixer_device, mixer_elem_name, mixer_elem_index,
@@ -1007,9 +1171,8 @@ int main(int argc, char *argv[]) {
 	if (!bluealsa_dbus_get_pcms(&dbus_ctx, &ba_pcms, &ba_pcms_count, &err))
 		warn("Couldn't get BlueALSA PCM list: %s", err.message);
 
-	size_t i;
-	for (i = 0; i < ba_pcms_count; i++)
-		supervise_pcm_worker(&ba_pcms[i]);
+	for (size_t i = 0; i < ba_pcms_count; i++)
+		supervise_io_worker(&ba_pcms[i]);
 
 	struct sigaction sigact = { .sa_handler = main_loop_stop };
 	sigaction(SIGTERM, &sigact, NULL);
@@ -1036,5 +1199,10 @@ int main(int argc, char *argv[]) {
 
 	}
 
+	for (size_t i = 0; i < workers_count; i++)
+		io_worker_stop(i);
+	free(workers);
+
+	bluealsa_dbus_connection_ctx_free(&dbus_ctx);
 	return EXIT_SUCCESS;
 }
diff --git a/utils/cli/Makefile.am b/utils/cli/Makefile.am
index b31b110..e631487 100644
--- a/utils/cli/Makefile.am
+++ b/utils/cli/Makefile.am
@@ -1,5 +1,5 @@
 # BlueALSA - Makefile.am
-# Copyright (c) 2016-2021 Arkadiusz Bokowy
+# Copyright (c) 2016-2022 Arkadiusz Bokowy
 
 if ENABLE_CLI
 
@@ -10,6 +10,16 @@ bluealsa_cli_SOURCES = \
 	../../src/shared/dbus-client.c \
 	../../src/shared/hex.c \
 	../../src/shared/log.c \
+	cmd-codec.c \
+	cmd-info.c \
+	cmd-list-pcms.c \
+	cmd-list-services.c \
+	cmd-monitor.c \
+	cmd-mute.c \
+	cmd-open.c \
+	cmd-softvol.c \
+	cmd-status.c \
+	cmd-volume.c \
 	cli.c
 
 bluealsa_cli_CFLAGS = \
diff --git a/utils/cli/cli.c b/utils/cli/cli.c
index cc824a5..d480ef1 100644
--- a/utils/cli/cli.c
+++ b/utils/cli/cli.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - cli.c
- * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -12,39 +12,35 @@
 # include <config.h>
 #endif
 
-#include <errno.h>
 #include <getopt.h>
+#include <stdarg.h>
 #include <stdbool.h>
+#include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <strings.h>
 #include <sys/param.h>
-#include <unistd.h>
 
 #include <dbus/dbus.h>
 
+#include "cli.h"
 #include "shared/dbus-client.h"
 #include "shared/defs.h"
-#include "shared/hex.h"
 #include "shared/log.h"
 
-/**
- * Helper macros for internal usage. */
-#define cli_print_error(M, ...) if (!quiet) { error(M, ##__VA_ARGS__); }
-#define cmd_print_error(M, ...) if (!quiet) { error("CMD \"%s\": " M, argv[0], ##__VA_ARGS__); }
-
-static struct ba_dbus_ctx dbus_ctx;
-static char dbus_ba_service[32] = BLUEALSA_SERVICE;
-static bool quiet = false;
-static bool verbose = false;
+/* Initialize global configuration variable. */
+struct cli_config config = {
+	.quiet = false,
+	.verbose = false,
+};
 
 static const char *transport_code_to_string(int transport_code) {
 	switch (transport_code) {
 	case BA_PCM_TRANSPORT_A2DP_SOURCE:
 		return "A2DP-source";
 	case BA_PCM_TRANSPORT_A2DP_SINK:
-		return"A2DP-sink";
+		return "A2DP-sink";
 	case BA_PCM_TRANSPORT_HFP_AG:
 		return "HFP-AG";
 	case BA_PCM_TRANSPORT_HFP_HF:
@@ -98,162 +94,27 @@ static const char *pcm_format_to_string(int pcm_format) {
 	}
 }
 
-static bool get_pcm(const char *path, struct ba_pcm *pcm) {
-
-	struct ba_pcm *pcms = NULL;
-	size_t pcms_count = 0;
-	bool found = false;
-	size_t i;
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_get_pcms(&dbus_ctx, &pcms, &pcms_count, &err))
-		return false;
-
-	for (i = 0; i < pcms_count; i++)
-		if (strcmp(pcms[i].pcm_path, path) == 0) {
-			memcpy(pcm, &pcms[i], sizeof(*pcm));
-			found = true;
-			break;
-		}
-
-	free(pcms);
-	return found;
-}
-
-static bool print_pcm_codecs(const char *path, DBusError *err) {
+static const char *pcm_codec_to_string(const struct ba_pcm_codec *codec) {
 
-	DBusMessage *msg = NULL, *rep = NULL;
-	bool result = false;
-	int count = 0;
+	static char buffer[128];
 
-	printf("Available codecs:");
+	const size_t data_len = codec->data_len;
+	size_t n;
 
-	if ((msg = dbus_message_new_method_call(dbus_ctx.ba_service, path,
-					BLUEALSA_INTERFACE_PCM, "GetCodecs")) == NULL) {
-		dbus_set_error(err, DBUS_ERROR_NO_MEMORY, NULL);
-		goto fail;
-	}
+	snprintf(buffer, sizeof(buffer), "%s", codec->name);
 
-	if ((rep = dbus_connection_send_with_reply_and_block(dbus_ctx.conn,
-					msg, DBUS_TIMEOUT_USE_DEFAULT, err)) == NULL) {
-		goto fail;
+	if (config.verbose && data_len > 0 &&
+			/* attach data blob if we can fit at least one hex-byte */
+			sizeof(buffer) - (n = strlen(buffer)) > 1 + 2) {
+		buffer[n++] = ':';
+		for (size_t i = 0; i < data_len && n < sizeof(buffer) - 2; i++, n += 2)
+			sprintf(&buffer[n], "%.2x", codec->data[i]);
 	}
 
-	DBusMessageIter iter;
-	if (!dbus_message_iter_init(rep, &iter)) {
-		dbus_set_error(err, DBUS_ERROR_NO_MEMORY, NULL);
-		goto fail;
-	}
-
-	DBusMessageIter iter_codecs;
-	for (dbus_message_iter_recurse(&iter, &iter_codecs);
-			dbus_message_iter_get_arg_type(&iter_codecs) != DBUS_TYPE_INVALID;
-			dbus_message_iter_next(&iter_codecs)) {
-
-		if (dbus_message_iter_get_arg_type(&iter_codecs) != DBUS_TYPE_DICT_ENTRY) {
-			dbus_set_error(err, DBUS_ERROR_FAILED, "Message corrupted");
-			goto fail;
-		}
-
-		DBusMessageIter iter_codecs_entry;
-		dbus_message_iter_recurse(&iter_codecs, &iter_codecs_entry);
-
-		if (dbus_message_iter_get_arg_type(&iter_codecs_entry) != DBUS_TYPE_STRING) {
-			dbus_set_error(err, DBUS_ERROR_FAILED, "Message corrupted");
-			goto fail;
-		}
-
-		const char *codec;
-		dbus_message_iter_get_basic(&iter_codecs_entry, &codec);
-		printf(" %s", codec);
-		++count;
-
-		/* Ignore the properties field, get next codec. */
-	}
-	result = true;
-
-fail:
-	if (count == 0)
-		printf(" [ Unknown ]");
-	printf("\n");
-
-	if (msg != NULL)
-		dbus_message_unref(msg);
-	if (rep != NULL)
-		dbus_message_unref(rep);
-	return result;
-}
-
-static void print_adapters(const struct ba_service_props *props) {
-	printf("Adapters:");
-	for (size_t i = 0; i < props->adapters_len; i++)
-		printf(" %s", props->adapters[i]);
-	printf("\n");
-}
-
-static void print_profiles_and_codecs(const struct ba_service_props *props) {
-	printf("Profiles:\n");
-	for (size_t i = 0; i < props->profiles_len; i++) {
-		printf("  %-11s :", props->profiles[i]);
-		size_t len = strlen(props->profiles[i]);
-		for (size_t ii = 0; ii < props->codecs_len; ii++)
-			if (strncmp(props->codecs[ii], props->profiles[i], len) == 0)
-				printf(" %s", &props->codecs[ii][len + 1]);
-		printf("\n");
-	}
-}
-
-static void print_volume(const struct ba_pcm *pcm) {
-	if (pcm->channels == 2)
-		printf("Volume: L: %u R: %u\n", pcm->volume.ch1_volume, pcm->volume.ch2_volume);
-	else
-		printf("Volume: %u\n", pcm->volume.ch1_volume);
-}
-
-static void print_mute(const struct ba_pcm *pcm) {
-	if (pcm->channels == 2)
-		printf("Muted: L: %c R: %c\n",
-				pcm->volume.ch1_muted ? 'Y' : 'N', pcm->volume.ch2_muted ? 'Y' : 'N');
-	else
-		printf("Muted: %c\n", pcm->volume.ch1_muted ? 'Y' : 'N');
-}
-
-static void print_properties(const struct ba_pcm *pcm, DBusError *err) {
-	printf("Device: %s\n", pcm->device_path);
-	printf("Sequence: %u\n", pcm->sequence);
-	printf("Transport: %s\n", transport_code_to_string(pcm->transport));
-	printf("Mode: %s\n", pcm_mode_to_string(pcm->mode));
-	printf("Format: %s\n", pcm_format_to_string(pcm->format));
-	printf("Channels: %d\n", pcm->channels);
-	printf("Sampling: %d Hz\n", pcm->sampling);
-	print_pcm_codecs(pcm->pcm_path, err);
-	printf("Selected codec: %s\n", pcm->codec);
-	printf("Delay: %#.1f ms\n", (double)pcm->delay / 10);
-	printf("SoftVolume: %s\n", pcm->soft_volume ? "Y" : "N");
-	print_volume(pcm);
-	print_mute(pcm);
-}
-
-typedef bool (*get_services_cb)(const char *name, void *data);
-
-static bool print_bluealsa_service(const char *name, void *data) {
-	(void) data;
-	if (strncmp(name, BLUEALSA_SERVICE, sizeof(BLUEALSA_SERVICE) - 1) == 0)
-		printf("%s\n", name);
-	return true;
+	return buffer;
 }
 
-static bool test_bluealsa_service(const char *name, void *data) {
-	bool *result = data;
-	if (strcmp(name, BLUEALSA_SERVICE) == 0) {
-		*result = true;
-		return false;
-	}
-	*result = false;
-	return true;
-}
-
-static void get_services(get_services_cb func, void *data, DBusError *err) {
+void cli_get_ba_services(cli_get_ba_services_cb func, void *data, DBusError *err) {
 
 	DBusMessage *msg = NULL, *rep = NULL;
 
@@ -263,7 +124,7 @@ static void get_services(get_services_cb func, void *data, DBusError *err) {
 		goto fail;
 	}
 
-	if ((rep = dbus_connection_send_with_reply_and_block(dbus_ctx.conn,
+	if ((rep = dbus_connection_send_with_reply_and_block(config.dbus.conn,
 					msg, DBUS_TIMEOUT_USE_DEFAULT, err)) == NULL) {
 		goto fail;
 	}
@@ -302,589 +163,195 @@ fail:
 		dbus_message_unref(rep);
 }
 
-static int cmd_list_services(int argc, char *argv[]) {
-
-	if (argc != 1) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	get_services(print_bluealsa_service, NULL, &err);
-	if (dbus_error_is_set(&err)) {
-		cmd_print_error("D-Bus error: %s", err.message);
-		return EXIT_FAILURE;
-	}
-
-	return EXIT_SUCCESS;
-}
-
-static int cmd_list_pcms(int argc, char *argv[]) {
-
-	if (argc != 1) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
+bool cli_get_ba_pcm(const char *path, struct ba_pcm *pcm, DBusError *err) {
 
 	struct ba_pcm *pcms = NULL;
 	size_t pcms_count = 0;
 
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_get_pcms(&dbus_ctx, &pcms, &pcms_count, &err)) {
-		cmd_print_error("Couldn't get BlueALSA PCM list: %s", err.message);
-		return EXIT_FAILURE;
-	}
+	if (!dbus_validate_path(path, err))
+		return false;
 
-	size_t i;
-	for (i = 0; i < pcms_count; i++) {
-		printf("%s\n", pcms[i].pcm_path);
-		if (verbose) {
-			print_properties(&pcms[i], &err);
-			printf("\n");
+	if (!bluealsa_dbus_get_pcms(&config.dbus, &pcms, &pcms_count, err))
+		return false;
+
+	bool found = false;
+	for (size_t i = 0; i < pcms_count; i++)
+		if (strcmp(pcms[i].pcm_path, path) == 0) {
+			memcpy(pcm, &pcms[i], sizeof(*pcm));
+			found = true;
+			break;
 		}
-	}
 
 	free(pcms);
-	return EXIT_SUCCESS;
-}
-
-static int cmd_status(int argc, char *argv[]) {
 
-	if (argc != 1) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	struct ba_service_props props = { 0 };
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_get_props(&dbus_ctx, &props, &err)) {
-		cmd_print_error("D-Bus error: %s", err.message);
-		bluealsa_dbus_props_free(&props);
-		return EXIT_FAILURE;
-	}
-
-	printf("Service: %s\n", dbus_ctx.ba_service);
-	printf("Version: %s\n", props.version);
-	print_adapters(&props);
-	print_profiles_and_codecs(&props);
-
-	bluealsa_dbus_props_free(&props);
-	return EXIT_SUCCESS;
+	if (!found)
+		dbus_set_error(err, DBUS_ERROR_UNKNOWN_OBJECT,
+				"Object path not found: '%s'", path);
+	return found;
 }
 
-static int cmd_info(int argc, char *argv[]) {
+bool cli_parse_value_on_off(const char *value, bool *out) {
 
-	if (argc != 2) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	const char *path = argv[1];
+	static const char * const value_on[] = { "on", "yes", "true", "y", "1" };
+	static const char * const value_off[] = { "off", "no", "false", "n", "0" };
 
-	struct ba_pcm pcm;
-	if (!get_pcm(path, &pcm)) {
-		cmd_print_error("Invalid BlueALSA PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
+	for (size_t i = 0; i < ARRAYSIZE(value_on); i++)
+		if (strcasecmp(value, value_on[i]) == 0) {
+			*out = true;
+			return true;
+		}
 
-	print_properties(&pcm, &err);
-	if (dbus_error_is_set(&err))
-		warn("Unable to read available codecs: %s", err.message);
+	for (size_t i = 0; i < ARRAYSIZE(value_off); i++)
+		if (strcasecmp(value, value_off[i]) == 0) {
+			*out = false;
+			return true;
+		}
 
-	return EXIT_SUCCESS;
+	return false;
 }
 
-static int cmd_codec(int argc, char *argv[]) {
-
-	if (argc < 2 || argc > 4) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	const char *path = argv[1];
-
-	struct ba_pcm pcm;
-	if (!get_pcm(path, &pcm)) {
-		cmd_print_error("Invalid BlueALSA PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
+void cli_print_adapters(const struct ba_service_props *props) {
+	printf("Adapters:");
+	for (size_t i = 0; i < props->adapters_len; i++)
+		printf(" %s", props->adapters[i]);
+	printf("\n");
+}
 
-	if (argc == 2) {
-		print_pcm_codecs(path, &err);
-		printf("Selected codec: %s\n", pcm.codec);
-		return EXIT_SUCCESS;
+void cli_print_profiles_and_codecs(const struct ba_service_props *props) {
+	printf("Profiles:\n");
+	for (size_t i = 0; i < props->profiles_len; i++) {
+		printf("  %-11s :", props->profiles[i]);
+		size_t len = strlen(props->profiles[i]);
+		for (size_t ii = 0; ii < props->codecs_len; ii++)
+			if (strncmp(props->codecs[ii], props->profiles[i], len) == 0)
+				printf(" %s", &props->codecs[ii][len + 1]);
+		printf("\n");
 	}
+}
 
-	const char *codec = argv[2];
-	int result = EXIT_FAILURE;
-
-	uint8_t config[64];
-	ssize_t config_len = 0;
+void cli_print_pcm_available_codecs(const struct ba_pcm *pcm, DBusError *err) {
 
-	if (argc == 4) {
-		size_t config_hex_len;
-		if ((config_hex_len = strlen(argv[3])) > sizeof(config) * 2) {
-			dbus_set_error(&err, DBUS_ERROR_FAILED, "Invalid codec configuration: %s", argv[3]);
-			goto fail;
-		}
-		if ((config_len = hex2bin(argv[3], config, config_hex_len)) == -1) {
-			dbus_set_error(&err, DBUS_ERROR_FAILED, "%s", strerror(errno));
-			goto fail;
-		}
-	}
+	printf("Available codecs:");
 
-	if (!bluealsa_dbus_pcm_select_codec(&dbus_ctx, path,
-				bluealsa_dbus_pcm_get_codec_canonical_name(codec), config, config_len, &err))
+	struct ba_pcm_codecs codecs = { 0 };
+	if (!bluealsa_dbus_pcm_get_codecs(&config.dbus, pcm->pcm_path, &codecs, err))
 		goto fail;
 
-	result = EXIT_SUCCESS;
+	for (size_t i = 0; i < codecs.codecs_len; i++)
+		printf(" %s", pcm_codec_to_string(&codecs.codecs[i]));
+
+	bluealsa_dbus_pcm_codecs_free(&codecs);
 
 fail:
-	if (dbus_error_is_set(&err))
-		cmd_print_error("Couldn't select BlueALSA PCM Codec: %s", err.message);
-	return result;
+	if (codecs.codecs_len == 0)
+		printf(" [ Unknown ]");
+	printf("\n");
 }
 
-static int cmd_volume(int argc, char *argv[]) {
-
-	if (argc < 2 || argc > 4) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	const char *path = argv[1];
-
-	struct ba_pcm pcm;
-	if (!get_pcm(path, &pcm)) {
-		cmd_print_error("Invalid BlueALSA PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
-
-	if (argc == 2) {
-		print_volume(&pcm);
-		return EXIT_SUCCESS;
-	}
-
-	int vol1, vol2;
-	vol1 = vol2 = atoi(argv[2]);
-	if (argc == 4)
-		vol2 = atoi(argv[3]);
-
-	if (pcm.transport & BA_PCM_TRANSPORT_MASK_A2DP) {
-		if (vol1 < 0 || vol1 > 127) {
-			cmd_print_error("Invalid volume [0, 127]: %d", vol1);
-			return EXIT_FAILURE;
-		}
-		pcm.volume.ch1_volume = vol1;
-		if (pcm.channels == 2) {
-			if (vol2 < 0 || vol2 > 127) {
-				cmd_print_error("Invalid volume [0, 127]: %d", vol2);
-				return EXIT_FAILURE;
-			}
-			pcm.volume.ch2_volume = vol2;
-		}
-	}
-	else {
-		if (vol1 < 0 || vol1 > 15) {
-			cmd_print_error("Invalid volume [0, 15]: %d", vol1);
-			return EXIT_FAILURE;
-		}
-		pcm.volume.ch1_volume = vol1;
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_pcm_update(&dbus_ctx, &pcm, BLUEALSA_PCM_VOLUME, &err)) {
-		cmd_print_error("Volume loudness update failed: %s", err.message);
-		return EXIT_FAILURE;
-	}
-
-	return EXIT_SUCCESS;
+void cli_print_pcm_selected_codec(const struct ba_pcm *pcm) {
+	printf("Selected codec: %s\n", pcm_codec_to_string(&pcm->codec));
 }
 
-static int cmd_mute(int argc, char *argv[]) {
-
-	if (argc < 2 || argc > 4) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	const char *path = argv[1];
-
-	struct ba_pcm pcm;
-	if (!get_pcm(path, &pcm)) {
-		cmd_print_error("Invalid BlueALSA PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
-
-	if (argc == 2) {
-		print_mute(&pcm);
-		return EXIT_SUCCESS;
-	}
-
-	pcm.volume.ch1_muted = pcm.volume.ch2_muted = false;
-
-	if (strcasecmp(argv[2], "y") == 0)
-		pcm.volume.ch1_muted = pcm.volume.ch2_muted = true;
-	else if (strcasecmp(argv[2], "n") != 0) {
-		cmd_print_error("Invalid argument [y|n]: %s", argv[2]);
-		return EXIT_FAILURE;
-	}
-
-	if (pcm.channels == 2 && argc == 4) {
-		if (strcasecmp(argv[3], "y") == 0)
-			pcm.volume.ch2_muted = true;
-		else if (strcasecmp(argv[3], "n") != 0) {
-			cmd_print_error("Invalid argument [y|n]: %s", argv[3]);
-			return EXIT_FAILURE;
-		}
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_pcm_update(&dbus_ctx, &pcm, BLUEALSA_PCM_VOLUME, &err)) {
-		cmd_print_error("Volume mute update failed: %s", err.message);
-		return EXIT_FAILURE;
-	}
-
-	return EXIT_SUCCESS;
+void cli_print_pcm_soft_volume(const struct ba_pcm *pcm) {
+	printf("SoftVolume: %s\n", pcm->soft_volume ? "true" : "false");
 }
 
-static int cmd_softvol(int argc, char *argv[]) {
-
-	if (argc < 2 || argc > 3) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	const char *path = argv[1];
-
-	struct ba_pcm pcm;
-	if (!get_pcm(path, &pcm)) {
-		cmd_print_error("Invalid BlueALSA PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
-
-	if (argc == 2) {
-		printf("SoftVolume: %c\n", pcm.soft_volume ? 'Y' : 'N');
-		return EXIT_SUCCESS;
-	}
-
-	if (strcasecmp(argv[2], "y") == 0)
-		pcm.soft_volume = true;
-	else if (strcasecmp(argv[2], "n") == 0)
-		pcm.soft_volume = false;
-	else {
-		cmd_print_error("Invalid argument [y|n]: %s", argv[2]);
-		return EXIT_FAILURE;
-	}
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_pcm_update(&dbus_ctx, &pcm, BLUEALSA_PCM_SOFT_VOLUME, &err)) {
-		cmd_print_error("SoftVolume update failed: %s", err.message);
-		return EXIT_FAILURE;
-	}
-
-	return EXIT_SUCCESS;
+void cli_print_pcm_volume(const struct ba_pcm *pcm) {
+	if (pcm->channels == 2)
+		printf("Volume: L: %u R: %u\n", pcm->volume.ch1_volume, pcm->volume.ch2_volume);
+	else
+		printf("Volume: %u\n", pcm->volume.ch1_volume);
 }
 
-static int cmd_open(int argc, char *argv[]) {
-
-	if (argc != 2) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	const char *path = argv[1];
-	if (!dbus_validate_path(path, NULL)) {
-		cmd_print_error("Invalid PCM path: %s", path);
-		return EXIT_FAILURE;
-	}
-
-	int fd_pcm, fd_pcm_ctrl, input, output;
-	size_t len = strlen(path);
-
-	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_pcm_open(&dbus_ctx, path, &fd_pcm, &fd_pcm_ctrl, &err)) {
-		cmd_print_error("Cannot open PCM: %s", err.message);
-		return EXIT_FAILURE;
-	}
-
-	if (strcmp(path + len - strlen("source"), "source") == 0) {
-		input = fd_pcm;
-		output = STDOUT_FILENO;
-	}
-	else {
-		input = STDIN_FILENO;
-		output = fd_pcm;
-	}
-
-	ssize_t count;
-	char buffer[4096];
-	while ((count = read(input, buffer, sizeof(buffer))) > 0) {
-		ssize_t written = 0;
-		const char *pos = buffer;
-		while (written < count) {
-			ssize_t res = write(output, pos, count - written);
-			if (res <= 0) {
-				/* Cannot write any more, so just terminate */
-				goto finish;
-			}
-			written += res;
-			pos += res;
-		}
-	}
-
-	if (output == fd_pcm)
-		bluealsa_dbus_pcm_ctrl_send_drain(fd_pcm_ctrl, &err);
-
-finish:
-	close(fd_pcm);
-	close(fd_pcm_ctrl);
-	return EXIT_SUCCESS;
+void cli_print_pcm_mute(const struct ba_pcm *pcm) {
+	if (pcm->channels == 2)
+		printf("Muted: L: %s R: %s\n", pcm->volume.ch1_muted ? "true" : "false",
+				pcm->volume.ch2_muted ? "true" : "false");
+	else
+		printf("Muted: %s\n", pcm->volume.ch1_muted ? "true" : "false");
 }
 
-static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *message, void *data) {
-	(void)conn;
-	(void)data;
-
-	if (dbus_message_get_type(message) != DBUS_MESSAGE_TYPE_SIGNAL)
-		return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
-
-	const char *interface = dbus_message_get_interface(message);
-	const char *signal = dbus_message_get_member(message);
-
-	DBusMessageIter iter;
-	if (!dbus_message_iter_init(message, &iter))
-		goto fail;
-
-	if (strcmp(interface, DBUS_INTERFACE_OBJECT_MANAGER) == 0) {
-
-		const char *path;
-		if (dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_OBJECT_PATH)
-			goto fail;
-		dbus_message_iter_get_basic(&iter, &path);
-
-		if (!dbus_message_iter_next(&iter))
-			goto fail;
-
-		if (strcmp(signal, "InterfacesAdded") == 0) {
-
-			DBusMessageIter iter_ifaces;
-			for (dbus_message_iter_recurse(&iter, &iter_ifaces);
-					dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_INVALID;
-					dbus_message_iter_next(&iter_ifaces)) {
-
-				DBusMessageIter iter_iface_entry;
-				if (dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_DICT_ENTRY)
-					goto fail;
-				dbus_message_iter_recurse(&iter_ifaces, &iter_iface_entry);
-
-				const char *iface;
-				if (dbus_message_iter_get_arg_type(&iter_iface_entry) != DBUS_TYPE_STRING)
-					goto fail;
-				dbus_message_iter_get_basic(&iter_iface_entry, &iface);
-
-				if (strcmp(iface, BLUEALSA_INTERFACE_PCM) == 0) {
-
-					printf("PCMAdded %s\n", path);
-
-					if (verbose) {
-
-						DBusMessageIter iter2;
-						if (!dbus_message_iter_init(message, &iter2))
-							goto fail;
-
-						struct ba_pcm pcm;
-						DBusError err = DBUS_ERROR_INIT;
-						if (!bluealsa_dbus_message_iter_get_pcm(&iter2, &err, &pcm)) {
-							error("Couldn't read PCM properties: %s", err.message);
-							dbus_error_free(&err);
-							goto fail;
-						}
-
-						print_properties(&pcm, &err);
-						printf("\n");
-
-					}
-
-				}
-				else if (strcmp(iface, BLUEALSA_INTERFACE_RFCOMM) == 0) {
-					printf("RFCOMMAdded %s\n", path);
-				}
-
-			}
-
-			return DBUS_HANDLER_RESULT_HANDLED;
-		}
-		else if (strcmp(signal, "InterfacesRemoved") == 0) {
-
-			DBusMessageIter iter_ifaces;
-			for (dbus_message_iter_recurse(&iter, &iter_ifaces);
-					dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_INVALID;
-					dbus_message_iter_next(&iter_ifaces)) {
-
-				const char *iface;
-				if (dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_STRING)
-					goto fail;
-				dbus_message_iter_get_basic(&iter_ifaces, &iface);
-
-				if (strcmp(iface, BLUEALSA_INTERFACE_PCM) == 0)
-					printf("PCMRemoved %s\n", path);
-				else if (strcmp(iface, BLUEALSA_INTERFACE_RFCOMM) == 0)
-					printf("RFCOMMRemoved %s\n", path);
-
-			}
-
-			return DBUS_HANDLER_RESULT_HANDLED;
-		}
-
-	}
-	else if (strcmp(interface, DBUS_INTERFACE_DBUS) == 0) {
-		if (strcmp(signal, "NameOwnerChanged") == 0) {
-
-			const char *arg0 = NULL, *arg1 = NULL, *arg2 = NULL;
-			if (dbus_message_iter_init(message, &iter) &&
-					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
-				dbus_message_iter_get_basic(&iter, &arg0);
-			else
-				goto fail;
-			if (dbus_message_iter_next(&iter) &&
-					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
-				dbus_message_iter_get_basic(&iter, &arg1);
-			else
-				goto fail;
-			if (dbus_message_iter_next(&iter) &&
-					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
-				dbus_message_iter_get_basic(&iter, &arg2);
-			else
-				goto fail;
-
-			if (strcmp(arg0, dbus_ctx.ba_service))
-				goto fail;
-
-			if (strlen(arg1) == 0)
-				printf("ServiceRunning %s\n", dbus_ctx.ba_service);
-			else if (strlen(arg2) == 0)
-				printf("ServiceStopped %s\n", dbus_ctx.ba_service);
-			else
-				goto fail;
-
-			return DBUS_HANDLER_RESULT_HANDLED;
-		}
-	}
-
-fail:
-	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+void cli_print_pcm_properties(const struct ba_pcm *pcm, DBusError *err) {
+	printf("Device: %s\n", pcm->device_path);
+	printf("Sequence: %u\n", pcm->sequence);
+	printf("Transport: %s\n", transport_code_to_string(pcm->transport));
+	printf("Mode: %s\n", pcm_mode_to_string(pcm->mode));
+	printf("Running: %s\n", pcm->running ? "true" : "false");
+	printf("Format: %s\n", pcm_format_to_string(pcm->format));
+	printf("Channels: %d\n", pcm->channels);
+	printf("Sampling: %d Hz\n", pcm->sampling);
+	cli_print_pcm_available_codecs(pcm, err);
+	cli_print_pcm_selected_codec(pcm);
+	printf("Delay: %#.1f ms\n", (double)pcm->delay / 10);
+	cli_print_pcm_soft_volume(pcm);
+	cli_print_pcm_volume(pcm);
+	cli_print_pcm_mute(pcm);
 }
 
-static int cmd_monitor(int argc, char *argv[]) {
-
-	if (argc != 1) {
-		cmd_print_error("Invalid number of arguments");
-		return EXIT_FAILURE;
-	}
-
-	/* Force line buffered output to be sure each event will be flushed
-	 * immediately, as this command will most likely be used to write to
-	 * a pipe. */
-	setvbuf(stdout, NULL, _IOLBF, 0);
-
-	bluealsa_dbus_connection_signal_match_add(&dbus_ctx,
-			dbus_ba_service, NULL, DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesAdded",
-			"path_namespace='/org/bluealsa'");
-	bluealsa_dbus_connection_signal_match_add(&dbus_ctx,
-			dbus_ba_service, NULL, DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesRemoved",
-			"path_namespace='/org/bluealsa'");
-
-	char dbus_args[50];
-	snprintf(dbus_args, sizeof(dbus_args), "arg0='%s',arg2=''", dbus_ctx.ba_service);
-	bluealsa_dbus_connection_signal_match_add(&dbus_ctx,
-			DBUS_SERVICE_DBUS, NULL, DBUS_INTERFACE_DBUS, "NameOwnerChanged", dbus_args);
-	snprintf(dbus_args, sizeof(dbus_args), "arg0='%s',arg1=''", dbus_ctx.ba_service);
-	bluealsa_dbus_connection_signal_match_add(&dbus_ctx,
-			DBUS_SERVICE_DBUS, NULL, DBUS_INTERFACE_DBUS, "NameOwnerChanged", dbus_args);
-
-	if (!dbus_connection_add_filter(dbus_ctx.conn, dbus_signal_handler, NULL, NULL)) {
-		cmd_print_error("Couldn't add D-Bus filter");
-		return EXIT_FAILURE;
-	}
-
-	bool running = false;
-	DBusError err = DBUS_ERROR_INIT;
-	get_services(test_bluealsa_service, &running, &err);
-	if (dbus_error_is_set(&err)) {
-		cmd_print_error("D-Bus error: %s", err.message);
-		return EXIT_FAILURE;
-	}
+static const char *progname = NULL;
+void cli_print_usage(const char *format, ...) {
 
-	if (running)
-		printf("ServiceRunning %s\n", dbus_ctx.ba_service);
-	else
-		printf("ServiceStopped %s\n", dbus_ctx.ba_service);
+	char usage[256];
+	va_list va;
 
-	while (dbus_connection_read_write_dispatch(dbus_ctx.conn, -1))
-		continue;
+	va_start(va, format);
+	vsnprintf(usage, sizeof(usage), format, va);
+	va_end(va);
 
-	return EXIT_SUCCESS;
+	printf("Usage:\n  %s %s\n", progname, usage);
 }
 
-static struct command {
-	const char *name;
-	int (*func)(int argc, char *arg[]);
-	const char *args;
-	const char *help;
-	unsigned int name_len;
-	unsigned int args_len;
-} commands[] = {
-#define CMD(name, f, args, help) { name, f, args, help, sizeof(name), sizeof(args) }
-	CMD("list-services", cmd_list_services, "", "List all BlueALSA services"),
-	CMD("list-pcms", cmd_list_pcms, "", "List all BlueALSA PCM paths"),
-	CMD("status", cmd_status, "", "Show service runtime properties"),
-	CMD("info", cmd_info, "<pcm-path>", "Show PCM properties etc"),
-	CMD("codec", cmd_codec, "<pcm-path> [<codec>] [<config>]", "Change codec used by PCM"),
-	CMD("volume", cmd_volume, "<pcm-path> [<val>] [<val>]", "Set audio volume"),
-	CMD("mute", cmd_mute, "<pcm-path> [y|n] [y|n]", "Mute/unmute audio"),
-	CMD("soft-volume", cmd_softvol, "<pcm-path> [y|n]", "Enable/disable SoftVolume property"),
-	CMD("monitor", cmd_monitor, "", "Display PCMAdded & PCMRemoved signals"),
-	CMD("open", cmd_open, "<pcm-path>", "Transfer raw PCM via stdin or stdout"),
+extern const struct cli_command cmd_list_services;
+extern const struct cli_command cmd_list_pcms;
+extern const struct cli_command cmd_status;
+extern const struct cli_command cmd_info;
+extern const struct cli_command cmd_codec;
+extern const struct cli_command cmd_monitor;
+extern const struct cli_command cmd_mute;
+extern const struct cli_command cmd_open;
+extern const struct cli_command cmd_softvol;
+extern const struct cli_command cmd_volume;
+
+static const struct cli_command *commands[] = {
+	&cmd_list_services,
+	&cmd_list_pcms,
+	&cmd_status,
+	&cmd_info,
+	&cmd_codec,
+	&cmd_volume,
+	&cmd_mute,
+	&cmd_softvol,
+	&cmd_monitor,
+	&cmd_open,
 };
 
-static void usage(const char *progname) {
+static void usage(const char *name) {
 
-	unsigned int max_len = 0;
-	size_t i;
-
-	for (i = 0; i < ARRAYSIZE(commands); i++)
-		max_len = MAX(max_len, commands[i].name_len + commands[i].args_len);
+	size_t command_name_max_len = 0;
+	for (size_t i = 0; i < ARRAYSIZE(commands); i++) {
+		size_t len = strlen(commands[i]->name);
+		command_name_max_len = MAX(command_name_max_len, len);
+	}
 
-	printf("%s - Utility to issue BlueALSA API commands\n", progname);
-	printf("\nUsage:\n  %s [options] <command> [command-args]\n", progname);
+	printf("%s - Utility to issue BlueALSA API commands\n\n", name);
+	cli_print_usage("[OPTION]... COMMAND [COMMAND-ARGS]");
 	printf("\nOptions:\n");
-	printf("  -h, --help          Show this help\n");
-	printf("  -V, --version       Show version\n");
+	printf("  -h, --help          Show this message and exit\n");
+	printf("  -V, --version       Show version and exit\n");
 	printf("  -B, --dbus=NAME     BlueALSA service name suffix\n");
 	printf("  -q, --quiet         Do not print any error messages\n");
 	printf("  -v, --verbose       Show extra information\n");
 	printf("\nCommands:\n");
-	for (i = 0; i < ARRAYSIZE(commands); i++)
-		printf("  %s %-*s%s\n", commands[i].name,
-				max_len - commands[i].name_len, commands[i].args,
-				commands[i].help);
-	printf("\nNotes:\n");
-	printf("   1. <pcm-path> must be a valid BlueALSA PCM path as returned by "
-	       "the list-pcms command.\n");
-	printf("   2. For commands that accept optional arguments, if no such "
-	       "argument is given then the current status of the associated "
-	       "attribute is printed.\n");
-	printf("   3. The codec command requires BlueZ version >= 5.52 "
-	       "for SEP support.\n");
+	for (size_t i = 0; i < ARRAYSIZE(commands); i++)
+		printf("  %-*s  %s\n", (int)(command_name_max_len),
+				commands[i]->name, commands[i]->description);
 
 }
 
 int main(int argc, char *argv[]) {
 
+	progname = argv[0];
+
 	int opt;
 	const char *opts = "+B:Vhqv";
 	const struct option longopts[] = {
@@ -896,6 +363,8 @@ int main(int argc, char *argv[]) {
 		{ 0 },
 	};
 
+	char dbus_ba_service[32] = BLUEALSA_SERVICE;
+
 	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
 		switch (opt) {
 		case 'h' /* --help */ :
@@ -912,36 +381,40 @@ int main(int argc, char *argv[]) {
 			}
 			break;
 		case 'q' /* --quiet */ :
-			quiet = true;
+			config.quiet = true;
 			break;
 		case 'v' /* --verbose */ :
-			verbose = true;
+			config.verbose = true;
 			break;
 		default:
 			fprintf(stderr, "Try '%s --help' for more information.\n", argv[0]);
 			return EXIT_FAILURE;
 		}
 
-	log_open(argv[0], false, false);
+	log_open(basename(argv[0]), false);
 	dbus_threads_init_default();
 
 	DBusError err = DBUS_ERROR_INIT;
-	if (!bluealsa_dbus_connection_ctx_init(&dbus_ctx, dbus_ba_service, &err)) {
+	if (!bluealsa_dbus_connection_ctx_init(&config.dbus, dbus_ba_service, &err)) {
 		cli_print_error("Couldn't initialize D-Bus context: %s", err.message);
 		return EXIT_FAILURE;
 	}
 
-	if (argc == optind) {
+	argc -= optind;
+	argv += optind;
+	optind = 0;
+
+	if (argc == 0) {
 		/* show "status" information by default */
 		char *status_argv[] = { "status", NULL };
-		return cmd_status(1, status_argv);
+		return cmd_status.func(1, status_argv);
 	}
 
 	size_t i;
 	for (i = 0; i < ARRAYSIZE(commands); i++)
-		if (strcmp(argv[optind], commands[i].name) == 0)
-			return commands[i].func(argc - optind, &argv[optind]);
+		if (strcmp(argv[0], commands[i]->name) == 0)
+			return commands[i]->func(argc, argv);
 
-	cli_print_error("Invalid command: %s", argv[optind]);
+	cli_print_error("Invalid command: %s", argv[0]);
 	return EXIT_FAILURE;
 }
diff --git a/utils/cli/cli.h b/utils/cli/cli.h
new file mode 100644
index 0000000..86fc0c5
--- /dev/null
+++ b/utils/cli/cli.h
@@ -0,0 +1,64 @@
+/*
+ * BlueALSA - cli.h
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#pragma once
+#ifndef BLUEALSA_CLI_CLI_H_
+#define BLUEALSA_CLI_CLI_H_
+
+#include <stdbool.h>
+
+#include <dbus/dbus.h>
+
+#include "shared/dbus-client.h"
+#include "shared/log.h"
+
+struct cli_config {
+
+	/* initialized BlueALSA D-Bus context */
+	struct ba_dbus_ctx dbus;
+
+	bool quiet;
+	bool verbose;
+
+};
+
+struct cli_command {
+	const char *name;
+	const char *description;
+	int (*func)(int argc, char *argv[]);
+};
+
+typedef bool (*cli_get_ba_services_cb)(const char *name, void *data);
+
+void cli_get_ba_services(cli_get_ba_services_cb func, void *data, DBusError *err);
+bool cli_get_ba_pcm(const char *path, struct ba_pcm *pcm, DBusError *err);
+
+bool cli_parse_value_on_off(const char *value, bool *out);
+
+void cli_print_adapters(const struct ba_service_props *props);
+void cli_print_profiles_and_codecs(const struct ba_service_props *props);
+void cli_print_pcm_available_codecs(const struct ba_pcm *pcm, DBusError *err);
+void cli_print_pcm_selected_codec(const struct ba_pcm *pcm);
+void cli_print_pcm_soft_volume(const struct ba_pcm *pcm);
+void cli_print_pcm_volume(const struct ba_pcm *pcm);
+void cli_print_pcm_mute(const struct ba_pcm *pcm);
+void cli_print_pcm_properties(const struct ba_pcm *pcm, DBusError *err);
+void cli_print_usage(const char *format, ...);
+
+#define cli_print_error(M, ...) \
+	if (!config.quiet) { error(M, ##__VA_ARGS__); }
+#define cmd_print_error(M, ...) \
+	if (!config.quiet) { error("CMD \"%s\": " M, argv[0], ##__VA_ARGS__); }
+
+/**
+ * Global configuration. */
+extern struct cli_config config;
+
+#endif
diff --git a/utils/cli/cmd-codec.c b/utils/cli/cmd-codec.c
new file mode 100644
index 0000000..17f35bf
--- /dev/null
+++ b/utils/cli/cmd-codec.c
@@ -0,0 +1,117 @@
+/*
+ * BlueALSA - cmd-codec.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <errno.h>
+#include <getopt.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+#include "shared/hex.h"
+
+static void usage(const char *command) {
+	printf("Get or set the Bluetooth codec used by the given PCM.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH [CODEC [CONFIG]]", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+			"  CODEC\t\tCodec identifier for setting new codec\n"
+			"  CONFIG\tOptional configuration for new codec\n"
+			"\nNote:\n"
+			"  This command requires BlueZ version >= 5.52 for SEP support.\n"
+	);
+}
+
+static int cmd_codec_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 3) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	const char *path = argv[optind];
+
+	struct ba_pcm pcm;
+	if (!cli_get_ba_pcm(path, &pcm, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (argc - optind == 1) {
+		cli_print_pcm_available_codecs(&pcm, NULL);
+		cli_print_pcm_selected_codec(&pcm);
+		return EXIT_SUCCESS;
+	}
+
+	const char *codec = argv[optind + 1];
+	int result = EXIT_FAILURE;
+
+	uint8_t codec_config[64];
+	ssize_t codec_config_len = 0;
+
+	if (argc - optind == 3) {
+		size_t codec_config_hex_len;
+		const char *codec_config_hex = argv[optind + 2];
+		if ((codec_config_hex_len = strlen(codec_config_hex)) > sizeof(codec_config) * 2) {
+			dbus_set_error(&err, DBUS_ERROR_FAILED, "Invalid codec configuration: %s", codec_config_hex);
+			goto fail;
+		}
+		if ((codec_config_len = hex2bin(codec_config_hex, codec_config, codec_config_hex_len)) == -1) {
+			dbus_set_error(&err, DBUS_ERROR_FAILED, "%s", strerror(errno));
+			goto fail;
+		}
+	}
+
+	if (!bluealsa_dbus_pcm_select_codec(&config.dbus, path,
+				bluealsa_dbus_pcm_get_codec_canonical_name(codec), codec_config, codec_config_len, &err))
+		goto fail;
+
+	result = EXIT_SUCCESS;
+
+fail:
+	if (dbus_error_is_set(&err))
+		cmd_print_error("Couldn't select BlueALSA PCM Codec: %s", err.message);
+	return result;
+}
+
+const struct cli_command cmd_codec = {
+	"codec",
+	"Get or set PCM Bluetooth codec",
+	cmd_codec_func,
+};
diff --git a/utils/cli/cmd-info.c b/utils/cli/cmd-info.c
new file mode 100644
index 0000000..dc8fc7b
--- /dev/null
+++ b/utils/cli/cmd-info.c
@@ -0,0 +1,80 @@
+/*
+ * BlueALSA - cmd-info.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+#include "shared/log.h"
+
+static void usage(const char *command) {
+	printf("Show PCM properties.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+	);
+}
+
+static int cmd_info_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 1) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	const char *path = argv[optind];
+
+	struct ba_pcm pcm;
+	if (!cli_get_ba_pcm(path, &pcm, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	cli_print_pcm_properties(&pcm, &err);
+	if (dbus_error_is_set(&err))
+		warn("Unable to read available codecs: %s", err.message);
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_info = {
+	"info",
+	"Show PCM properties",
+	cmd_info_func,
+};
diff --git a/utils/cli/cmd-list-pcms.c b/utils/cli/cmd-list-pcms.c
new file mode 100644
index 0000000..643278b
--- /dev/null
+++ b/utils/cli/cmd-list-pcms.c
@@ -0,0 +1,79 @@
+/*
+ * BlueALSA - cmd-list-pcms.c
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("List all BlueALSA PCM paths.\n\n");
+	cli_print_usage("%s [OPTION]...", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+	);
+}
+
+static int cmd_list_pcms_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc != optind) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	struct ba_pcm *pcms = NULL;
+	size_t pcms_count = 0;
+
+	DBusError err = DBUS_ERROR_INIT;
+	if (!bluealsa_dbus_get_pcms(&config.dbus, &pcms, &pcms_count, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM list: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	size_t i;
+	for (i = 0; i < pcms_count; i++) {
+		printf("%s\n", pcms[i].pcm_path);
+		if (config.verbose) {
+			cli_print_pcm_properties(&pcms[i], &err);
+			printf("\n");
+		}
+	}
+
+	free(pcms);
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_list_pcms = {
+	"list-pcms",
+	"List all BlueALSA PCM paths",
+	cmd_list_pcms_func,
+};
diff --git a/utils/cli/cmd-list-services.c b/utils/cli/cmd-list-services.c
new file mode 100644
index 0000000..d3af0b9
--- /dev/null
+++ b/utils/cli/cmd-list-services.c
@@ -0,0 +1,76 @@
+/*
+ * BlueALSA - cmd-list-services.c
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static bool print_service(const char *name, void *data) {
+	(void)data;
+	if (strncmp(name, BLUEALSA_SERVICE, sizeof(BLUEALSA_SERVICE) - 1) == 0)
+		printf("%s\n", name);
+	return true;
+}
+
+static void usage(const char *command) {
+	printf("List all BlueALSA services.\n\n");
+	cli_print_usage("%s [OPTION]...", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+	);
+}
+
+static int cmd_list_services_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc != optind) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	cli_get_ba_services(print_service, NULL, &err);
+	if (dbus_error_is_set(&err)) {
+		cmd_print_error("D-Bus error: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_list_services = {
+	"list-services",
+	"List all BlueALSA services",
+	cmd_list_services_func,
+};
diff --git a/utils/cli/cmd-monitor.c b/utils/cli/cmd-monitor.c
new file mode 100644
index 0000000..1590c0b
--- /dev/null
+++ b/utils/cli/cmd-monitor.c
@@ -0,0 +1,389 @@
+/*
+ * BlueALSA - cmd-monitor.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+#include "shared/defs.h"
+#include "shared/log.h"
+
+enum {
+	PROPERTY_CODEC,
+	PROPERTY_RUNNING,
+	PROPERTY_SOFTVOL,
+	PROPERTY_VOLUME,
+};
+
+struct property {
+	const char *name;
+	bool enabled;
+};
+
+static bool monitor_properties = false;
+static struct property monitor_properties_set[] = {
+	[PROPERTY_CODEC] = { "Codec", false },
+	[PROPERTY_RUNNING] = { "Running", false },
+	[PROPERTY_SOFTVOL] = { "SoftVolume", false },
+	[PROPERTY_VOLUME] = { "Volume", false },
+};
+
+static bool test_bluealsa_service(const char *name, void *data) {
+	bool *result = data;
+	if (strcmp(name, BLUEALSA_SERVICE) == 0) {
+		*result = true;
+		return false;
+	}
+	*result = false;
+	return true;
+}
+
+static dbus_bool_t monitor_dbus_message_iter_get_pcm_props_cb(const char *key,
+		DBusMessageIter *value, void *userdata, DBusError *error) {
+	const char *path = userdata;
+
+	char type;
+	if ((type = dbus_message_iter_get_arg_type(value)) != DBUS_TYPE_VARIANT) {
+		dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+				"Incorrect property value type: %c != %c", type, DBUS_TYPE_VARIANT);
+		return FALSE;
+	}
+
+	DBusMessageIter variant;
+	dbus_message_iter_recurse(value, &variant);
+	type = dbus_message_iter_get_arg_type(&variant);
+	char type_expected;
+
+	if (monitor_properties_set[PROPERTY_CODEC].enabled &&
+			strcmp(key, monitor_properties_set[PROPERTY_CODEC].name) == 0) {
+		if (type != (type_expected = DBUS_TYPE_STRING))
+			goto fail;
+		const char *codec;
+		dbus_message_iter_get_basic(&variant, &codec);
+		printf("PropertyChanged %s Codec %s\n", path, codec);
+	}
+	else if (monitor_properties_set[PROPERTY_RUNNING].enabled &&
+			strcmp(key, monitor_properties_set[PROPERTY_RUNNING].name) == 0) {
+		if (type != (type_expected = DBUS_TYPE_BOOLEAN))
+			goto fail;
+		dbus_bool_t running;
+		dbus_message_iter_get_basic(&variant, &running);
+		printf("PropertyChanged %s Running %s\n", path, running ? "true" : "false");
+	}
+	else if (monitor_properties_set[PROPERTY_SOFTVOL].enabled &&
+			strcmp(key, monitor_properties_set[PROPERTY_SOFTVOL].name) == 0) {
+		if (type != (type_expected = DBUS_TYPE_BOOLEAN))
+			goto fail;
+		dbus_bool_t softvol;
+		dbus_message_iter_get_basic(&variant, &softvol);
+		printf("PropertyChanged %s SoftVolume %s\n", path, softvol ? "true" : "false");
+	}
+	else if (monitor_properties_set[PROPERTY_VOLUME].enabled &&
+			strcmp(key, monitor_properties_set[PROPERTY_VOLUME].name) == 0) {
+		if (type != (type_expected = DBUS_TYPE_UINT16))
+			goto fail;
+		dbus_uint16_t volume;
+		dbus_message_iter_get_basic(&variant, &volume);
+		printf("PropertyChanged %s Volume 0x%.4X\n", path, volume);
+	}
+
+	return TRUE;
+
+fail:
+	dbus_set_error(error, DBUS_ERROR_INVALID_SIGNATURE,
+			"Incorrect variant for '%s': %c != %c", key, type, type_expected);
+	return FALSE;
+}
+
+static DBusHandlerResult dbus_signal_handler(DBusConnection *conn, DBusMessage *message, void *data) {
+	(void)conn;
+	(void)data;
+
+	if (dbus_message_get_type(message) != DBUS_MESSAGE_TYPE_SIGNAL)
+		return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+
+	const char *interface = dbus_message_get_interface(message);
+	const char *signal = dbus_message_get_member(message);
+
+	DBusMessageIter iter;
+	if (!dbus_message_iter_init(message, &iter))
+		goto fail;
+
+	if (strcmp(interface, DBUS_INTERFACE_OBJECT_MANAGER) == 0) {
+
+		const char *path;
+		if (dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_OBJECT_PATH)
+			goto fail;
+		dbus_message_iter_get_basic(&iter, &path);
+
+		if (!dbus_message_iter_next(&iter))
+			goto fail;
+
+		if (strcmp(signal, "InterfacesAdded") == 0) {
+
+			DBusMessageIter iter_ifaces;
+			for (dbus_message_iter_recurse(&iter, &iter_ifaces);
+					dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_INVALID;
+					dbus_message_iter_next(&iter_ifaces)) {
+
+				DBusMessageIter iter_iface_entry;
+				if (dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_DICT_ENTRY)
+					goto fail;
+				dbus_message_iter_recurse(&iter_ifaces, &iter_iface_entry);
+
+				const char *iface;
+				if (dbus_message_iter_get_arg_type(&iter_iface_entry) != DBUS_TYPE_STRING)
+					goto fail;
+				dbus_message_iter_get_basic(&iter_iface_entry, &iface);
+
+				if (strcmp(iface, BLUEALSA_INTERFACE_PCM) == 0) {
+
+					printf("PCMAdded %s\n", path);
+
+					if (config.verbose) {
+
+						DBusMessageIter iter2;
+						if (!dbus_message_iter_init(message, &iter2))
+							goto fail;
+
+						struct ba_pcm pcm;
+						DBusError err = DBUS_ERROR_INIT;
+						if (!bluealsa_dbus_message_iter_get_pcm(&iter2, &err, &pcm)) {
+							error("Couldn't read PCM properties: %s", err.message);
+							dbus_error_free(&err);
+							goto fail;
+						}
+
+						cli_print_pcm_properties(&pcm, &err);
+						printf("\n");
+
+					}
+
+				}
+				else if (strcmp(iface, BLUEALSA_INTERFACE_RFCOMM) == 0) {
+					printf("RFCOMMAdded %s\n", path);
+				}
+
+			}
+
+			return DBUS_HANDLER_RESULT_HANDLED;
+		}
+		else if (strcmp(signal, "InterfacesRemoved") == 0) {
+
+			DBusMessageIter iter_ifaces;
+			for (dbus_message_iter_recurse(&iter, &iter_ifaces);
+					dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_INVALID;
+					dbus_message_iter_next(&iter_ifaces)) {
+
+				const char *iface;
+				if (dbus_message_iter_get_arg_type(&iter_ifaces) != DBUS_TYPE_STRING)
+					goto fail;
+				dbus_message_iter_get_basic(&iter_ifaces, &iface);
+
+				if (strcmp(iface, BLUEALSA_INTERFACE_PCM) == 0)
+					printf("PCMRemoved %s\n", path);
+				else if (strcmp(iface, BLUEALSA_INTERFACE_RFCOMM) == 0)
+					printf("RFCOMMRemoved %s\n", path);
+
+			}
+
+			return DBUS_HANDLER_RESULT_HANDLED;
+		}
+
+	}
+	else if (strcmp(interface, DBUS_INTERFACE_DBUS) == 0) {
+		if (strcmp(signal, "NameOwnerChanged") == 0) {
+
+			const char *arg0 = NULL, *arg1 = NULL, *arg2 = NULL;
+			if (dbus_message_iter_init(message, &iter) &&
+					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
+				dbus_message_iter_get_basic(&iter, &arg0);
+			else
+				goto fail;
+			if (dbus_message_iter_next(&iter) &&
+					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
+				dbus_message_iter_get_basic(&iter, &arg1);
+			else
+				goto fail;
+			if (dbus_message_iter_next(&iter) &&
+					dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING)
+				dbus_message_iter_get_basic(&iter, &arg2);
+			else
+				goto fail;
+
+			if (strcmp(arg0, config.dbus.ba_service))
+				goto fail;
+
+			if (strlen(arg1) == 0)
+				printf("ServiceRunning %s\n", config.dbus.ba_service);
+			else if (strlen(arg2) == 0)
+				printf("ServiceStopped %s\n", config.dbus.ba_service);
+			else
+				goto fail;
+
+			return DBUS_HANDLER_RESULT_HANDLED;
+		}
+	}
+	else if (strcmp(interface, DBUS_INTERFACE_PROPERTIES) == 0 &&
+			strcmp(signal, "PropertiesChanged") == 0) {
+
+		const char *updated_interface;
+		dbus_message_iter_get_basic(&iter, &updated_interface);
+		dbus_message_iter_next(&iter);
+
+		if (strcmp(updated_interface, BLUEALSA_INTERFACE_PCM) == 0) {
+
+			DBusError err = DBUS_ERROR_INIT;
+			const char *path = dbus_message_get_path(message);
+			if (!bluealsa_dbus_message_iter_dict(&iter, &err,
+						monitor_dbus_message_iter_get_pcm_props_cb, (void *)path)) {
+				error("Unexpected D-Bus signal: %s", err.message);
+				dbus_error_free(&err);
+				goto fail;
+			}
+
+			return DBUS_HANDLER_RESULT_HANDLED;
+		}
+	}
+
+fail:
+	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+}
+
+static bool parse_property_list(char *argv[], char *props) {
+
+	if (props == NULL) {
+		/* monitor all properties */
+		for (size_t i = 0; i < ARRAYSIZE(monitor_properties_set); i++)
+			monitor_properties_set[i].enabled = true;
+		return true;
+	}
+
+	char *prop = strtok(props, ",");
+	for (; prop; prop = strtok(NULL, ",")) {
+
+		size_t i;
+		for (i = 0; i < ARRAYSIZE(monitor_properties_set); i++) {
+			if (strcasecmp(prop, monitor_properties_set[i].name) == 0) {
+				monitor_properties_set[i].enabled = true;
+				break;
+			}
+		}
+
+		if (i == ARRAYSIZE(monitor_properties_set)) {
+			cmd_print_error("Unknown property '%s'", prop);
+			return false;
+		}
+
+	}
+
+	return true;
+}
+
+static void usage(const char *command) {
+	printf("Display D-Bus signals.\n\n");
+	cli_print_usage("%s [OPTION]...", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\t\tShow this message and exit\n"
+			"  -p, --properties[=PROPS]\tShow PCM property changes\n"
+	);
+}
+
+static int cmd_monitor_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "hp::";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ "properties", optional_argument, NULL, 'p' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		case 'p' /* --properties[=PROPS] */ :
+			monitor_properties = true;
+			if (!parse_property_list(argv, optarg))
+				return EXIT_FAILURE;
+			break;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc != optind) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	/* Force line buffered output to be sure each event will be flushed
+	 * immediately, as this command will most likely be used to write to
+	 * a pipe. */
+	setvbuf(stdout, NULL, _IOLBF, 0);
+
+	bluealsa_dbus_connection_signal_match_add(&config.dbus,
+			config.dbus.ba_service, NULL, DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesAdded",
+			"path_namespace='/org/bluealsa'");
+	bluealsa_dbus_connection_signal_match_add(&config.dbus,
+			config.dbus.ba_service, NULL, DBUS_INTERFACE_OBJECT_MANAGER, "InterfacesRemoved",
+			"path_namespace='/org/bluealsa'");
+
+	char dbus_args[50];
+	snprintf(dbus_args, sizeof(dbus_args), "arg0='%s',arg2=''", config.dbus.ba_service);
+	bluealsa_dbus_connection_signal_match_add(&config.dbus,
+			DBUS_SERVICE_DBUS, NULL, DBUS_INTERFACE_DBUS, "NameOwnerChanged", dbus_args);
+	snprintf(dbus_args, sizeof(dbus_args), "arg0='%s',arg1=''", config.dbus.ba_service);
+	bluealsa_dbus_connection_signal_match_add(&config.dbus,
+			DBUS_SERVICE_DBUS, NULL, DBUS_INTERFACE_DBUS, "NameOwnerChanged", dbus_args);
+
+	if (monitor_properties)
+		bluealsa_dbus_connection_signal_match_add(&config.dbus, config.dbus.ba_service, NULL,
+				DBUS_INTERFACE_PROPERTIES, "PropertiesChanged", "arg0='"BLUEALSA_INTERFACE_PCM"'");
+
+	if (!dbus_connection_add_filter(config.dbus.conn, dbus_signal_handler, NULL, NULL)) {
+		cmd_print_error("Couldn't add D-Bus filter");
+		return EXIT_FAILURE;
+	}
+
+	bool running = false;
+	DBusError err = DBUS_ERROR_INIT;
+	cli_get_ba_services(test_bluealsa_service, &running, &err);
+	if (dbus_error_is_set(&err)) {
+		cmd_print_error("D-Bus error: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (running)
+		printf("ServiceRunning %s\n", config.dbus.ba_service);
+	else
+		printf("ServiceStopped %s\n", config.dbus.ba_service);
+
+	while (dbus_connection_read_write_dispatch(config.dbus.conn, -1))
+		continue;
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_monitor = {
+	"monitor",
+	"Display D-Bus signals",
+	cmd_monitor_func,
+};
diff --git a/utils/cli/cmd-mute.c b/utils/cli/cmd-mute.c
new file mode 100644
index 0000000..8d7b9f7
--- /dev/null
+++ b/utils/cli/cmd-mute.c
@@ -0,0 +1,111 @@
+/*
+ * BlueALSA - cmd-mute.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("Get or set the mute switch of the given PCM.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH [STATE [STATE]]", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+			"  STATE\t\tEnable or disable mute switch\n"
+	);
+}
+
+static int cmd_mute_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 3) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	const char *path = argv[optind];
+
+	struct ba_pcm pcm;
+	if (!cli_get_ba_pcm(path, &pcm, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (argc - optind == 1) {
+		cli_print_pcm_mute(&pcm);
+		return EXIT_SUCCESS;
+	}
+
+	const char *value;
+	bool state;
+
+	value = argv[optind + 1];
+	if (!cli_parse_value_on_off(value, &state)) {
+		cmd_print_error("Invalid argument: %s", value);
+		return EXIT_FAILURE;
+	}
+
+	pcm.volume.ch1_muted = state;
+	pcm.volume.ch2_muted = state;
+
+	if (pcm.channels == 2 && argc - optind == 3) {
+
+		value = argv[optind + 2];
+		if (!cli_parse_value_on_off(value, &state)) {
+			cmd_print_error("Invalid argument: %s", value);
+			return EXIT_FAILURE;
+		}
+
+		pcm.volume.ch2_muted = state;
+
+	}
+
+	if (!bluealsa_dbus_pcm_update(&config.dbus, &pcm, BLUEALSA_PCM_VOLUME, &err)) {
+		cmd_print_error("Volume mute update failed: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_mute = {
+	"mute",
+	"Get or set PCM mute switch",
+	cmd_mute_func,
+};
diff --git a/utils/cli/cmd-open.c b/utils/cli/cmd-open.c
new file mode 100644
index 0000000..a5b7496
--- /dev/null
+++ b/utils/cli/cmd-open.c
@@ -0,0 +1,114 @@
+/*
+ * BlueALSA - cmd-open.c
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("Transfer raw PCM data via stdin or stdout.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+	);
+}
+
+static int cmd_open_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 2) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	const char *path = argv[optind];
+	if (!dbus_validate_path(path, NULL)) {
+		cmd_print_error("Invalid PCM path: %s", path);
+		return EXIT_FAILURE;
+	}
+
+	int fd_pcm, fd_pcm_ctrl, input, output;
+	size_t len = strlen(path);
+
+	DBusError err = DBUS_ERROR_INIT;
+	if (!bluealsa_dbus_pcm_open(&config.dbus, path, &fd_pcm, &fd_pcm_ctrl, &err)) {
+		cmd_print_error("Cannot open PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (strcmp(path + len - strlen("source"), "source") == 0) {
+		input = fd_pcm;
+		output = STDOUT_FILENO;
+	}
+	else {
+		input = STDIN_FILENO;
+		output = fd_pcm;
+	}
+
+	ssize_t count;
+	char buffer[4096];
+	while ((count = read(input, buffer, sizeof(buffer))) > 0) {
+		ssize_t written = 0;
+		const char *pos = buffer;
+		while (written < count) {
+			ssize_t res = write(output, pos, count - written);
+			if (res <= 0) {
+				/* Cannot write any more, so just terminate */
+				goto finish;
+			}
+			written += res;
+			pos += res;
+		}
+	}
+
+	if (output == fd_pcm)
+		bluealsa_dbus_pcm_ctrl_send_drain(fd_pcm_ctrl, &err);
+
+finish:
+	close(fd_pcm);
+	close(fd_pcm_ctrl);
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_open = {
+	"open",
+	"Transfer raw PCM via stdin or stdout",
+	cmd_open_func,
+};
diff --git a/utils/cli/cmd-softvol.c b/utils/cli/cmd-softvol.c
new file mode 100644
index 0000000..9f890ae
--- /dev/null
+++ b/utils/cli/cmd-softvol.c
@@ -0,0 +1,96 @@
+/*
+ * BlueALSA - cmd-softvol.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("Get or set the SoftVolume property of the given PCM.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH [STATE]", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+			"  STATE\t\tEnable or disable SoftVolume property\n"
+	);
+}
+
+static int cmd_softvol_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 2) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	const char *path = argv[optind];
+
+	struct ba_pcm pcm;
+	if (!cli_get_ba_pcm(path, &pcm, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (argc - optind == 1) {
+		cli_print_pcm_soft_volume(&pcm);
+		return EXIT_SUCCESS;
+	}
+
+	bool state;
+	const char *value = argv[optind + 1];
+	if (!cli_parse_value_on_off(value, &state)) {
+		cmd_print_error("Invalid argument: %s", value);
+		return EXIT_FAILURE;
+	}
+
+	pcm.soft_volume = state;
+
+	if (!bluealsa_dbus_pcm_update(&config.dbus, &pcm, BLUEALSA_PCM_SOFT_VOLUME, &err)) {
+		cmd_print_error("SoftVolume update failed: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_softvol = {
+	"soft-volume",
+	"Get or set PCM SoftVolume property",
+	cmd_softvol_func,
+};
diff --git a/utils/cli/cmd-status.c b/utils/cli/cmd-status.c
new file mode 100644
index 0000000..bbd3136
--- /dev/null
+++ b/utils/cli/cmd-status.c
@@ -0,0 +1,75 @@
+/*
+ * BlueALSA - cmd-status.c
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("Show BlueALSA service runtime status.\n\n");
+	cli_print_usage("%s [OPTION]...", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+	);
+}
+
+static int cmd_status_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc != optind) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	struct ba_service_props props = { 0 };
+
+	DBusError err = DBUS_ERROR_INIT;
+	if (!bluealsa_dbus_get_props(&config.dbus, &props, &err)) {
+		cmd_print_error("D-Bus error: %s", err.message);
+		bluealsa_dbus_props_free(&props);
+		return EXIT_FAILURE;
+	}
+
+	printf("Service: %s\n", config.dbus.ba_service);
+	printf("Version: %s\n", props.version);
+	cli_print_adapters(&props);
+	cli_print_profiles_and_codecs(&props);
+
+	bluealsa_dbus_props_free(&props);
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_status = {
+	"status",
+	"Show BlueALSA service status",
+	cmd_status_func,
+};
diff --git a/utils/cli/cmd-volume.c b/utils/cli/cmd-volume.c
new file mode 100644
index 0000000..5f76f63
--- /dev/null
+++ b/utils/cli/cmd-volume.c
@@ -0,0 +1,113 @@
+/*
+ * BlueALSA - cmd-volume.c
+ * Copyright (c) 2016-2023 Arkadiusz Bokowy
+ *
+ * This file is a part of bluez-alsa.
+ *
+ * This project is licensed under the terms of the MIT license.
+ *
+ */
+
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <dbus/dbus.h>
+
+#include "cli.h"
+#include "shared/dbus-client.h"
+
+static void usage(const char *command) {
+	printf("Get or set the volume value of the given PCM.\n\n");
+	cli_print_usage("%s [OPTION]... PCM-PATH [VOLUME [VOLUME]]", command);
+	printf("\nOptions:\n"
+			"  -h, --help\t\tShow this message and exit\n"
+			"\nPositional arguments:\n"
+			"  PCM-PATH\tBlueALSA PCM D-Bus object path\n"
+			"  VOLUME\tVolume value (range depends on BT transport)\n"
+	);
+}
+
+static int cmd_volume_func(int argc, char *argv[]) {
+
+	int opt;
+	const char *opts = "h";
+	const struct option longopts[] = {
+		{ "help", no_argument, NULL, 'h' },
+		{ 0 },
+	};
+
+	opterr = 0;
+	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
+		switch (opt) {
+		case 'h' /* --help */ :
+			usage(argv[0]);
+			return EXIT_SUCCESS;
+		default:
+			cmd_print_error("Invalid argument '%s'", argv[optind - 1]);
+			return EXIT_FAILURE;
+		}
+
+	if (argc - optind < 1) {
+		cmd_print_error("Missing BlueALSA PCM path argument");
+		return EXIT_FAILURE;
+	}
+	if (argc - optind > 3) {
+		cmd_print_error("Invalid number of arguments");
+		return EXIT_FAILURE;
+	}
+
+	DBusError err = DBUS_ERROR_INIT;
+	const char *path = argv[optind];
+
+	struct ba_pcm pcm;
+	if (!cli_get_ba_pcm(path, &pcm, &err)) {
+		cmd_print_error("Couldn't get BlueALSA PCM: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	if (argc - optind == 1) {
+		cli_print_pcm_volume(&pcm);
+		return EXIT_SUCCESS;
+	}
+
+	int vol1, vol2;
+	vol1 = vol2 = atoi(argv[optind + 1]);
+	if (argc - optind == 3)
+		vol2 = atoi(argv[optind + 2]);
+
+	if (pcm.transport & BA_PCM_TRANSPORT_MASK_A2DP) {
+		if (vol1 < 0 || vol1 > 127) {
+			cmd_print_error("Invalid volume [0, 127]: %d", vol1);
+			return EXIT_FAILURE;
+		}
+		pcm.volume.ch1_volume = vol1;
+		if (pcm.channels == 2) {
+			if (vol2 < 0 || vol2 > 127) {
+				cmd_print_error("Invalid volume [0, 127]: %d", vol2);
+				return EXIT_FAILURE;
+			}
+			pcm.volume.ch2_volume = vol2;
+		}
+	}
+	else {
+		if (vol1 < 0 || vol1 > 15) {
+			cmd_print_error("Invalid volume [0, 15]: %d", vol1);
+			return EXIT_FAILURE;
+		}
+		pcm.volume.ch1_volume = vol1;
+	}
+
+	if (!bluealsa_dbus_pcm_update(&config.dbus, &pcm, BLUEALSA_PCM_VOLUME, &err)) {
+		cmd_print_error("Volume loudness update failed: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
+	return EXIT_SUCCESS;
+}
+
+const struct cli_command cmd_volume = {
+	"volume",
+	"Get or set PCM audio volume",
+	cmd_volume_func,
+};
diff --git a/utils/hcitop.c b/utils/hcitop.c
index f7b4637..d3cfd0e 100644
--- a/utils/hcitop.c
+++ b/utils/hcitop.c
@@ -17,10 +17,10 @@
 #include <stdlib.h>
 #include <string.h>
 
-#include <ncurses.h>
+#include <curses.h>
 #include <bsd/stdlib.h>
 
-#include <bluetooth/bluetooth.h>
+#include <bluetooth/bluetooth.h> /* IWYU pragma: keep */
 #include <bluetooth/hci.h>
 #include <bluetooth/hci_lib.h>
 
diff --git a/utils/rfcomm/rfcomm.c b/utils/rfcomm/rfcomm.c
index b881c9b..9eba57f 100644
--- a/utils/rfcomm/rfcomm.c
+++ b/utils/rfcomm/rfcomm.c
@@ -1,6 +1,6 @@
 /*
  * BlueALSA - rfcomm.c
- * Copyright (c) 2016-2020 Arkadiusz Bokowy
+ * Copyright (c) 2016-2022 Arkadiusz Bokowy
  *
  * This file is a part of bluez-alsa.
  *
@@ -35,6 +35,7 @@ static int rfcomm_fd = -1;
 static bool main_loop_on = true;
 static bool input_is_tty = true;
 static bool output_is_tty = true;
+static bool properties = false;
 
 static int path2hci(const char *path) {
 	int id;
@@ -71,21 +72,30 @@ static char *strtrim(char *str) {
 	return str;
 }
 
-static char *build_rfcomm_command(const char *cmd) {
+static bool print_properties(struct ba_dbus_ctx *dbus_ctx, const char* path, DBusError *err) {
 
-	static char command[512];
-	bool at;
+	struct ba_rfcomm_props props = { 0 };
+	if (!bluealsa_dbus_get_rfcomm_props(dbus_ctx, path, &props, err)) {
+		bluealsa_dbus_rfcomm_props_free(&props);
+		return false;
+	}
+
+	printf("Transport: %s\n", props.transport);
+	printf("Features:");
+	for (size_t i = 0; i < props.features_len; i++)
+		printf(" %s", props.features[i]);
+	printf("\n");
+	printf("Battery: %d\n", props.battery);
 
-	command[0] = '\0';
-	if (!(at = strncmp(cmd, "AT", 2) == 0))
-		strcpy(command, "\r\n");
+	bluealsa_dbus_rfcomm_props_free(&props);
 
-	strcat(command, cmd);
-	strcat(command, "\r");
-	if (!at)
-		strcat(command, "\n");
+	return true;
+}
 
-	return command;
+static char *build_rfcomm_command(char *buffer, size_t size, const char *cmd) {
+	bool at = strncmp(cmd, "AT", 2) == 0;
+	snprintf(buffer, size, "%s%s%s", at ? "" : "\r\n", cmd, at ? "\r" : "\r\n");
+	return buffer;
 }
 
 static void rl_callback_handler(char *line) {
@@ -100,7 +110,9 @@ static void rl_callback_handler(char *line) {
 	if (strlen(line) == 0)
 		return;
 
-	char *cmd = build_rfcomm_command(line);
+	char cmd[512];
+	build_rfcomm_command(cmd, sizeof(cmd), line);
+
 	if (write(rfcomm_fd, cmd, strlen(cmd)) == -1)
 		warn("Couldn't send RFCOMM command: %s", strerror(errno));
 
@@ -111,17 +123,18 @@ static void rl_callback_handler(char *line) {
 int main(int argc, char *argv[]) {
 
 	int opt;
-	const char *opts = "hVB:";
+	const char *opts = "hVB:p";
 	const struct option longopts[] = {
 		{ "help", no_argument, NULL, 'h' },
 		{ "version", no_argument, NULL, 'V' },
 		{ "dbus", required_argument, NULL, 'B' },
+		{ "properties", no_argument, NULL, 'p' },
 		{ 0, 0, 0, 0 },
 	};
 
 	char dbus_ba_service[32] = BLUEALSA_SERVICE;
 
-	log_open(argv[0], false, false);
+	log_open(basename(argv[0]), false);
 
 	while ((opt = getopt_long(argc, argv, opts, longopts, NULL)) != -1)
 		switch (opt) {
@@ -132,7 +145,8 @@ usage:
 					"\nOptions:\n"
 					"  -h, --help\t\tprint this help and exit\n"
 					"  -V, --version\t\tprint version and exit\n"
-					"  -B, --dbus=NAME\tBlueALSA service name suffix\n",
+					"  -B, --dbus=NAME\tBlueALSA service name suffix\n"
+					"  -p, --properties\tprint device properties and exit\n",
 					argv[0]);
 			return EXIT_SUCCESS;
 
@@ -148,6 +162,10 @@ usage:
 			}
 			break;
 
+		case 'p' /* --properties */ :
+			properties = true;
+			break;
+
 		default:
 			fprintf(stderr, "Try '%s --help' for more information.\n", argv[0]);
 			return EXIT_FAILURE;
@@ -176,6 +194,14 @@ usage:
 	char rfcomm_path[128];
 	sprintf(rfcomm_path, "/org/bluealsa/hci%d/dev_%.2X_%.2X_%.2X_%.2X_%.2X_%.2X/rfcomm",
 			hci_dev_id, addr.b[5], addr.b[4], addr.b[3], addr.b[2], addr.b[1], addr.b[0]);
+
+	if (properties) {
+		if (print_properties(&dbus_ctx, rfcomm_path, &err))
+			return EXIT_SUCCESS;
+		error("D-Bus error: %s", err.message);
+		return EXIT_FAILURE;
+	}
+
 	if (!bluealsa_dbus_open_rfcomm(&dbus_ctx, rfcomm_path, &rfcomm_fd, &err)) {
 		error("Couldn't open RFCOMM: %s", err.message);
 		return EXIT_FAILURE;

More details

Full run details

Historical runs