BlueALSA: Bump to v5.0.0

This commit is contained in:
dirwin31
2026-10-21 08:12:02 -07:00
committed by Danny
parent 89509ba3c7
commit c7cc700e4e
29 changed files with 1000 additions and 34 deletions
+2
View File
@@ -19,6 +19,7 @@ a.out
bin/
!openpilot/starpilot/galaxy/bin/
!openpilot/starpilot/soloist/pulseaudio/bin/
!openpilot/starpilot/soloist/bluealsa/bin/
# created at launch for comma hardware PYTHONPATH (PC uses editable installs via pyproject.toml)
/msgq
@@ -47,6 +48,7 @@ bin/
!openpilot/tools/replay/libmedia_mux.so
!openpilot/starpilot/soloist/pulseaudio/lib/*.so
!openpilot/starpilot/soloist/pulseaudio/modules/*.so
!openpilot/starpilot/soloist/bluealsa/lib/*.so
*.a
st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z]
*.unchunked
+8 -12
View File
@@ -43,17 +43,13 @@ page only after they are updated.
## Runtime behavior
The cached AGNOS image's BlueALSA service starts `/usr/bin/bluealsa -p a2dp-source`.
`phone_profile.py`, invoked through noninteractive sudo on the device, temporarily
adds `a2dp-sink` using its own drop-in under `/run/systemd/system/bluealsa.service.d`.
Only BlueALSA is restarted, once on entry and once on exit; Bluetooth bonds,
bluetoothd, adapter power/identity and HFP registration are not changed. Unknown
service commands and externally modified drop-ins are rejected. The override is
removed on orderly shutdown/source change, recovered at the next supervisor start
after an abrupt shutdown, and disappears at reboot. If the supervisor is killed
without restarting, the receiving profile can remain advertised until reboot;
its capture worker exits on parent death. Bluetooth music may briefly disconnect
while BlueALSA restarts. Radio-off is respected; this feature never powers it on.
The checkout bundles BlueALSA 5.0.0's daemon and matching PCM/CTL plugins for
phone reception and Bluetooth output. `phone_profile.py` uses temporary `/run`
overrides; the system's BlueALSA 4.1.1 installation stays intact. Phone capture
uses `hwcompat "silence"` for continuity across input interruptions. Private ALSA
configs select the matching plugins and explicitly preserve software volume.
See [the bundle documentation](bluealsa/README.md) for build inputs, tests, and
the old-commit rollback contract (restart music/openpilot or reboot after checkout).
Only the selected paired phone is captured. The existing trusted pairing policy
still determines which phones may establish Bluetooth connections; this feature
@@ -61,7 +57,7 @@ does not alter trust or erase bonds. `arecord` opens an ALSA plug PCM for that
phone, converting to signed 16-bit 48 kHz stereo. A separate Python worker writes
to the private server using AGNOS's `libpulse-simple.so.0`, with bounded buffers.
The supervisor retries disconnects without repeatedly restarting Bluetooth.
No extra native binaries are bundled or built for this feature.
The versioned BlueALSA bundle and private PulseAudio server are included with the checkout.
## Validation
+3 -1
View File
@@ -22,6 +22,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from openpilot.starpilot.soloist import bluealsa_runtime
from openpilot.starpilot.soloist.paths import Layout, private_dir
from openpilot.starpilot.soloist.preferences import ADDRESS, Preferences
@@ -53,7 +54,8 @@ def alsa_config(address: str, base: Path = SYSTEM_ALSA_CONFIG) -> str:
raise BackendUnavailable('Choose a Bluetooth speaker or car first')
if any(character in str(base) for character in '\n\r<>"'):
raise BackendUnavailable('Invalid system ALSA configuration path')
return (f'<{base}>\n\npcm.{PCM_NAME} {{\n type bluealsa\n device "{address}"\n profile "a2dp"\n' +
return (f'<{base}>\n' + bluealsa_runtime.alsa_plugins() +
f'\npcm.{PCM_NAME} {{\n type bluealsa\n device "{address}"\n profile "a2dp"\n softvol "true"\n' +
' hint { show on description "StarPilot Spotify output" }\n}\n')
@@ -0,0 +1 @@
/build/
@@ -0,0 +1 @@
sha256:8e0457d4150457e582a95dab9d2d13fc35c437610ec03ac8729d7b57d73d59d7
@@ -0,0 +1,245 @@
adduser 3.137ubuntu1
alsa-utils 1.2.9-1ubuntu5
apt 2.8.3
autoconf 2.71-3
automake 1:1.16.5-1.3ubuntu1
autotools-dev 20220109.1
base-files 13ubuntu10.4
base-passwd 3.6.3build1
bash 5.2.21-2ubuntu4
binutils 2.42-4ubuntu2.10
binutils-aarch64-linux-gnu 2.42-4ubuntu2.10
binutils-common:arm64 2.42-4ubuntu2.10
bsdutils 1:2.39.3-9ubuntu6.6
build-essential 12.10ubuntu1
bzip2 1.0.8-5.1ubuntu0.1
ca-certificates 20260816ubuntu0.24.04.1
check:arm64 0.15.2-2build1
coreutils 9.4-3ubuntu6.2
cpp 4:13.2.0-7ubuntu1
cpp-13 13.3.0-6ubuntu2~24.04.1
cpp-13-aarch64-linux-gnu 13.3.0-6ubuntu2~24.04.1
cpp-aarch64-linux-gnu 4:13.2.0-7ubuntu1
dash 0.5.12-6ubuntu5
dbus 1.14.10-4ubuntu4.1
dbus-bin 1.14.10-4ubuntu4.1
dbus-daemon 1.14.10-4ubuntu4.1
dbus-session-bus-common 1.14.10-4ubuntu4.1
dbus-system-bus-common 1.14.10-4ubuntu4.1
debconf 1.5.86ubuntu1
debianutils 5.17build1
diffutils 1:3.10-1build1
dpkg 1.22.6ubuntu6.6
dpkg-dev 1.22.6ubuntu6.6
e2fsprogs 1.47.0-2.4~exp1ubuntu4.1
file 1:5.45-3build1
findutils 4.9.0-5build1
g++ 4:13.2.0-7ubuntu1
g++-13 13.3.0-6ubuntu2~24.04.1
g++-13-aarch64-linux-gnu 13.3.0-6ubuntu2~24.04.1
g++-aarch64-linux-gnu 4:13.2.0-7ubuntu1
gcc 4:13.2.0-7ubuntu1
gcc-13 13.3.0-6ubuntu2~24.04.1
gcc-13-aarch64-linux-gnu 13.3.0-6ubuntu2~24.04.1
gcc-13-base:arm64 13.3.0-6ubuntu2~24.04.1
gcc-14-base:arm64 14.2.0-4ubuntu2~24.04.1
gcc-aarch64-linux-gnu 4:13.2.0-7ubuntu1
git 1:2.43.0-1ubuntu7.4
git-man 1:2.43.0-1ubuntu7.4
gpgv 2.4.4-2ubuntu17.4
grep 3.11-4build1
gzip 1.12-1ubuntu3.2
hostname 3.23+nmu2ubuntu2
init-system-helpers 1.66ubuntu1
kmod 31+20240202-2ubuntu7.2
libacl1:arm64 2.3.2-1build1.1
libapparmor1:arm64 4.0.1really4.0.1-0ubuntu0.24.04.9
libapt-pkg6.0t64:arm64 2.8.3
libasan8:arm64 14.2.0-4ubuntu2~24.04.1
libasound2-data 1.2.11-1ubuntu0.3
libasound2-dev:arm64 1.2.11-1ubuntu0.3
libasound2t64:arm64 1.2.11-1ubuntu0.3
libassuan0:arm64 2.5.6-1build1
libatomic1:arm64 14.2.0-4ubuntu2~24.04.1
libatopology2t64:arm64 1.2.11-1ubuntu0.3
libattr1:arm64 1:2.5.2-1build1.1
libaudit-common 1:3.1.2-2.1build1.1
libaudit1:arm64 1:3.1.2-2.1build1.1
libbinutils:arm64 2.42-4ubuntu2.10
libblkid-dev:arm64 2.39.3-9ubuntu6.6
libblkid1:arm64 2.39.3-9ubuntu6.6
libbluetooth-dev:arm64 5.72-0ubuntu5.8
libbluetooth3:arm64 5.72-0ubuntu5.8
libbrotli1:arm64 1.1.0-2build2
libbsd-dev:arm64 0.12.1-1build1.1
libbsd0:arm64 0.12.1-1build1.1
libbz2-1.0:arm64 1.0.8-5.1ubuntu0.1
libc-bin 2.39-0ubuntu8.9
libc-dev-bin 2.39-0ubuntu8.9
libc6:arm64 2.39-0ubuntu8.9
libc6-dev:arm64 2.39-0ubuntu8.9
libcap-ng0:arm64 0.8.4-2build2
libcap2:arm64 1:2.66-5ubuntu2.4
libcc1-0:arm64 14.2.0-4ubuntu2~24.04.1
libcom-err2:arm64 1.47.0-2.4~exp1ubuntu4.1
libcrypt-dev:arm64 1:4.4.36-4build1
libcrypt1:arm64 1:4.4.36-4build1
libctf-nobfd0:arm64 2.42-4ubuntu2.10
libctf0:arm64 2.42-4ubuntu2.10
libcurl3t64-gnutls:arm64 8.5.0-2ubuntu10.16
libdb5.3t64:arm64 5.3.28+dfsg2-7
libdbus-1-3:arm64 1.14.10-4ubuntu4.1
libdbus-1-dev:arm64 1.14.10-4ubuntu4.1
libdebconfclient0:arm64 0.271ubuntu3
libdpkg-perl 1.22.6ubuntu6.6
libelf1t64:arm64 0.190-1.1ubuntu0.1
liberror-perl 0.17029-2
libexpat1:arm64 2.6.1-2ubuntu0.6
libext2fs2t64:arm64 1.47.0-2.4~exp1ubuntu4.1
libffi-dev:arm64 3.4.6-1build1
libffi8:arm64 3.4.6-1build1
libfftw3-single3:arm64 3.3.10-1ubuntu3
libgcc-13-dev:arm64 13.3.0-6ubuntu2~24.04.1
libgcc-s1:arm64 14.2.0-4ubuntu2~24.04.1
libgcrypt20:arm64 1.10.3-2ubuntu0.1
libgdbm-compat4t64:arm64 1.23-5.1build1
libgdbm6t64:arm64 1.23-5.1build1
libgirepository-2.0-0:arm64 2.80.0-6ubuntu3.10
libglib2.0-0t64:arm64 2.80.0-6ubuntu3.10
libglib2.0-bin 2.80.0-6ubuntu3.10
libglib2.0-data 2.80.0-6ubuntu3.10
libglib2.0-dev:arm64 2.80.0-6ubuntu3.10
libglib2.0-dev-bin 2.80.0-6ubuntu3.10
libgmp10:arm64 2:6.3.0+dfsg-2ubuntu6.1
libgnutls30t64:arm64 3.8.3-1.1ubuntu3.6
libgomp1:arm64 14.2.0-4ubuntu2~24.04.1
libgpg-error0:arm64 1.47-3build2.1
libgprofng0:arm64 2.42-4ubuntu2.10
libgssapi-krb5-2:arm64 1.20.1-6ubuntu2.10
libhogweed6t64:arm64 3.9.1-2.2build1.1
libhwasan0:arm64 14.2.0-4ubuntu2~24.04.1
libidn2-0:arm64 2.3.7-2build1.1
libisl23:arm64 0.26-3build1.1
libitm1:arm64 14.2.0-4ubuntu2~24.04.1
libjansson4:arm64 2.14-2build2
libk5crypto3:arm64 1.20.1-6ubuntu2.10
libkeyutils1:arm64 1.6.3-3build1
libkmod2:arm64 31+20240202-2ubuntu7.2
libkrb5-3:arm64 1.20.1-6ubuntu2.10
libkrb5support0:arm64 1.20.1-6ubuntu2.10
libldacbt-abr-dev:arm64 2.0.2.3+git20200429+ed310a0-4ubuntu2
libldacbt-abr2:arm64 2.0.2.3+git20200429+ed310a0-4ubuntu2
libldacbt-enc-dev:arm64 2.0.2.3+git20200429+ed310a0-4ubuntu2
libldacbt-enc2:arm64 2.0.2.3+git20200429+ed310a0-4ubuntu2
libldap2:arm64 2.6.10+dfsg-0ubuntu0.24.04.1
liblsan0:arm64 14.2.0-4ubuntu2~24.04.1
liblz4-1:arm64 1.9.4-1build1.1
liblzma5:arm64 5.6.1+really5.4.5-1ubuntu0.4
libmagic-mgc 1:5.45-3build1
libmagic1t64:arm64 1:5.45-3build1
libmd-dev:arm64 1.1.0-2build1.1
libmd0:arm64 1.1.0-2build1.1
libmount-dev:arm64 2.39.3-9ubuntu6.6
libmount1:arm64 2.39.3-9ubuntu6.6
libmpc3:arm64 1.3.1-1build1.1
libmpfr6:arm64 4.2.1-1build1.1
libncursesw6:arm64 6.4+20240113-1ubuntu2.1
libnettle8t64:arm64 3.9.1-2.2build1.1
libnghttp2-14:arm64 1.59.0-1ubuntu0.4
libnpth0t64:arm64 1.6-3.1build1
libp11-kit0:arm64 0.25.3-4ubuntu2.1
libpam-modules:arm64 1.5.3-5ubuntu5.6
libpam-modules-bin 1.5.3-5ubuntu5.6
libpam-runtime 1.5.3-5ubuntu5.6
libpam0g:arm64 1.5.3-5ubuntu5.6
libpcre2-16-0:arm64 10.42-4ubuntu2.1
libpcre2-32-0:arm64 10.42-4ubuntu2.1
libpcre2-8-0:arm64 10.42-4ubuntu2.1
libpcre2-dev:arm64 10.42-4ubuntu2.1
libpcre2-posix3:arm64 10.42-4ubuntu2.1
libperl5.38t64:arm64 5.38.2-3.2ubuntu0.6
libpkgconf3:arm64 1.8.1-2build1
libproc2-0:arm64 2:4.0.4-4ubuntu3.2
libpsl5t64:arm64 0.21.2-1.1build1
libpython3-stdlib:arm64 3.12.3-0ubuntu2.1
libpython3.12-minimal:arm64 3.12.3-1ubuntu0.17
libpython3.12-stdlib:arm64 3.12.3-1ubuntu0.17
libreadline8t64:arm64 8.2-4build1
librtmp1:arm64 2.4+20151223.gitfa8646d.1-2build7
libsamplerate0:arm64 0.2.2-4build1
libsamplerate0-dev:arm64 0.2.2-4build1
libsasl2-2:arm64 2.1.28+dfsg1-5ubuntu3.1
libsasl2-modules-db:arm64 2.1.28+dfsg1-5ubuntu3.1
libsbc-dev:arm64 2.0-1build1
libsbc1:arm64 2.0-1build1
libseccomp2:arm64 2.5.5-1ubuntu3.1
libselinux1:arm64 3.5-2ubuntu2.1
libselinux1-dev:arm64 3.5-2ubuntu2.1
libsemanage-common 3.5-1build5
libsemanage2:arm64 3.5-1build5
libsepol-dev:arm64 3.5-2build1
libsepol2:arm64 3.5-2build1
libsframe1:arm64 2.42-4ubuntu2.10
libsmartcols1:arm64 2.39.3-9ubuntu6.6
libsqlite3-0:arm64 3.45.1-1ubuntu2.8
libss2:arm64 1.47.0-2.4~exp1ubuntu4.1
libssh-4:arm64 0.10.6-2ubuntu0.5
libssl3t64:arm64 3.0.13-0ubuntu3.16
libstdc++-13-dev:arm64 13.3.0-6ubuntu2~24.04.1
libstdc++6:arm64 14.2.0-4ubuntu2~24.04.1
libsubunit-dev:arm64 1.4.2-3build1
libsubunit0:arm64 1.4.2-3build1
libsystemd0:arm64 255.4-1ubuntu8.17
libtasn1-6:arm64 4.19.0-3ubuntu0.24.04.2
libtinfo6:arm64 6.4+20240113-1ubuntu2.1
libtool 2.4.7-7build1
libtsan2:arm64 14.2.0-4ubuntu2~24.04.1
libubsan1:arm64 14.2.0-4ubuntu2~24.04.1
libudev1:arm64 255.4-1ubuntu8.17
libunistring5:arm64 1.1-2build1.1
libuuid1:arm64 2.39.3-9ubuntu6.6
libxxhash0:arm64 0.8.2-2build1
libzstd1:arm64 1.5.5+dfsg2-2build1.1
linux-libc-dev:arm64 6.8.0-147.147
login 1:4.13+dfsg1-4ubuntu3.2
logsave 1.47.0-2.4~exp1ubuntu4.1
lto-disabled-list 47
m4 1.4.19-4build1
make 4.3-4.1build2
mawk 1.3.4.20240123-1build1
media-types 10.1.0
mount 2.39.3-9ubuntu6.6
ncurses-base 6.4+20240113-1ubuntu2.1
ncurses-bin 6.4+20240113-1ubuntu2.1
netbase 6.4
openssl 3.0.13-0ubuntu3.16
passwd 1:4.13+dfsg1-4ubuntu3.2
patch 2.7.6-7build3
perl 5.38.2-3.2ubuntu0.6
perl-base 5.38.2-3.2ubuntu0.6
perl-modules-5.38 5.38.2-3.2ubuntu0.6
pkg-config:arm64 1.8.1-2build1
pkgconf:arm64 1.8.1-2build1
pkgconf-bin 1.8.1-2build1
procps 2:4.0.4-4ubuntu3.2
python3 3.12.3-0ubuntu2.1
python3-minimal 3.12.3-0ubuntu2.1
python3-packaging 24.0-1
python3.12 3.12.3-1ubuntu0.17
python3.12-minimal 3.12.3-1ubuntu0.17
readline-common 8.2-4build1
rpcsvc-proto 1.4.2-0ubuntu7
sed 4.9-2ubuntu0.24.04.1
sensible-utils 0.0.22
sgml-base 1.31
sysvinit-utils 3.08-6ubuntu3
tar 1.35+dfsg-3ubuntu0.4
tzdata 2026e-0ubuntu0.24.04.1
ubuntu-keyring 2023.11.28.1
unminimize 0.2.1
util-linux 2.39.3-9ubuntu6.6
uuid-dev:arm64 2.39.3-9ubuntu6.6
xml-core 0.19
xz-utils 5.6.1+really5.4.5-1ubuntu0.4
zlib1g:arm64 1:1.3.dfsg-3.1ubuntu2.2
zlib1g-dev:arm64 1:1.3.dfsg-3.1ubuntu2.2
@@ -0,0 +1,6 @@
FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential autoconf automake libtool pkg-config git ca-certificates \
libasound2-dev libbluetooth-dev libdbus-1-dev libglib2.0-dev libsbc-dev \
libldacbt-enc-dev libldacbt-abr-dev libbsd-dev check dbus alsa-utils libsamplerate0-dev \
&& rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,125 @@
# BlueALSA 5.0.0 for StarPilot music
This is a checkout-local Linux/ARM64 bundle of upstream BlueALSA **5.0.0**, commit
`1935d6dcb8975f2d7a51aaafe61538d157224623`. It replaces the abandoned 4.1.1 SBC
concealment experiment. There are **no custom decoder patches** in this bundle.
`bin/bluealsad`, `bin/bluealsactl`, `bin/bluealsa-aplay`, and both ALSA plugins in
`lib/` must be committed together with `VERSION`, `UPSTREAM_COMMIT`, and
`SHA256SUMS`. The feature validates checksums and ARM64 ELF headers before
activating the daemon. Build scratch files under `build/` are ignored.
## Audio integration
The phone profile helper temporarily selects `bluealsad` through the existing
`bluealsa.service`. Both phone capture and Spotify/Bluetooth output use explicit
paths to the bundled PCM/CTL plugins in their process-local ALSA configuration.
No global plugin search path, ALSA config, D-Bus policy, or system binary changes.
The D-Bus service remains `org.bluealsa`; the bundled plugins and tool use its
5.0 `Rate` property instead of 4.1's `Sampling`. No application code reads that
property directly. Existing diagnostics still query the same systemd unit.
Phone capture enables `hwcompat "silence"` to maintain capture across transport
inactivity and short input interruptions. It inserts silence; it does not
reconstruct missing music or eliminate interference. Playback leaves `hwcompat`
at its default so a disconnected output is not silently consumed. Both paths
explicitly set `softvol "true"` to preserve the previous software-volume mode
instead of adopting 5.0's new native-volume default. Initial device volume state
is separate from 4.1; the user's existing 4.1 state is never rewritten.
The build retains SBC reception, SBC/LDAC transmission, and oFono support. The
launcher explicitly enables SBC and LDAC. AAC, Opus, LHDC, and ASHA are not enabled
by this upgrade. `bluealsa-aplay` is built with adaptive resampling available,
but the `arecord` relay does not use it. The existing `Nice=-5` policy is retained;
no real-time scheduling priority is added over driving processes.
5.0 persistent state is compiled to `/data/starpilot/bluealsa5/lib/bluealsa`.
The launcher creates its parent before starting the daemon. This isolates it
from `/var/lib/bluealsa`, preserving 4.1 volume/mute state for rollback.
## Rollback contract
**Guaranteed rollback: check out the old commit, then reboot.** `/run` is cleared at
boot, so the stock 4.1.1 daemon returns with no further steps. Restarting
music/openpilot instead is best-effort: it works only if the old supervisor's
startup release runs (see below). Checking out files alone cannot replace a daemon
already running in memory.
* `/usr/bin/bluealsa`, system ALSA plugins, system service, and 4.1 persistent
state are never overwritten.
* `90-starpilot-phone.conf` remains **byte-for-byte compatible with the old
helper**. 5.0 selection is a separate `95-starpilot-bluealsa5.conf` in `/run`.
* The launcher is copied into `/run/systemd/system/` before activation. It is
self-contained and remains available after Git removes the new checkout files.
* Checking out a pre-upgrade commit removes the tracked `VERSION` and bundle.
On the old supervisor's normal startup, its existing release code removes
`90-starpilot-phone.conf` and restarts the unit. The retained launcher sees the
absent version marker, removes the 5.0 override and itself, reloads systemd,
then execs the stock 4.1 daemon with the original source profile. Old capture
and playback code generates configs using the system 4.1 plugins again.
* A reboot independently clears all `/run` overrides, including for much older
commits without the phone-profile startup cleanup. No migration command is
required for that rollback path.
* A failed activation removes the new override and restores the prior profile
configuration before trying the system daemon again. Unknown overrides are
refused rather than overwritten.
The integration test performs a real temporary Git checkout back to an older
commit, then runs the frozen pre-upgrade helper from `3434023608`. Service calls
are simulated so the test cannot change the host or a comma. It verifies the
resulting stock daemon command and removal of both temporary overrides/launcher.
This is local lifecycle verification, not an on-device audio acceptance test.
## Build and verification
Read `docs/how-to/laptop-device-build.md` first. This narrowly scoped bundle build
uses Linux/ARM64 Ubuntu 24.04 (AGNOS's userspace generation); it does not invoke
the full openpilot artifact build or deploy anything. The base image is pinned;
`BUILD_IMAGE`, `BUILD_PACKAGES.txt`, and `UPSTREAM_COMMIT` record build inputs.
Package versions are recorded, not guaranteed to remain available from apt.
```sh
git clone --branch v5.0.0 https://github.com/arkq/bluez-alsa.git /tmp/bluealsa-5
openpilot/starpilot/soloist/bluealsa/build.sh --setup
openpilot/starpilot/soloist/bluealsa/build.sh /tmp/bluealsa-5
.venv/bin/python -m pytest openpilot/starpilot/soloist/tests -q
```
The setup command downloads build dependencies. The build itself uses
`--network none --pull never`, exports only the pinned committed source, runs
upstream `make check`, builds matching daemon/plugins/tools, verifies ARM64 ELF
and dynamic dependencies in the build container, and writes checksums. It does
not build inside the mounted Git worktree, avoiding container ownership issues.
`UPSTREAM_TEST_RESULTS.txt` records the upstream suite (19 programs, zero skips).
This includes capture/playback PCM and each `hwcompat` mode. The local integration
suite covers bundle integrity, capture/playback config, profile ownership,
activation failure, Spotify output lifecycle, and old-commit rollback. The actual
packaged plugin was also loaded by `arecord` and `aplay` using generated StarPilot
configs in an isolated D-Bus session: it reached the expected absent-service error
without config or symbol errors. No physical device was used for these checks.
Device facts verified read-only on the comma (2026-10-21): `bluealsa.service` runs as
root with no `ProtectSystem`/`NoNewPrivileges`, so the launcher can write `/data`,
edit `/run/systemd/system` and `daemon-reload`; every bundled artifact resolves all
shared libraries (`ldd`, including LDAC and samplerate); the private v5 PCM plugin is
the one ALSA loads through the generated config (it overrides the system
`/etc/alsa/conf.d/20-bluealsa.conf` type). A v5 plugin talking to the stock 4.1
daemon fails (`Incorrect variant for 'Volume'`), which is why Spotify Bluetooth
output refuses to start (`audio_unavailable`) when v5 activation fails instead of
falling back to a mismatched pair. The v5 daemon itself has not yet been started on
the device.
Behavior changes to be aware of: while Spotify plays to a Bluetooth output the
daemon runs with the same `a2dp-sink` profile as phone reception, so the comma is
advertised as a music receiver; and a player restart releases and re-acquires the
profile, briefly restarting BlueALSA (and so Bluetooth music).
Remaining hardware acceptance after deployment: reconnect a phone, pause/resume,
exercise Android Auto and Bluetooth output, and confirm perceived dropout
behavior. Local tests do not establish audible smoothness or RF improvement.
Upstream references:
- https://github.com/arkq/bluez-alsa/blob/v5.0.0/NEWS.md
- https://github.com/arkq/bluez-alsa/blob/v5.0.0/doc/bluealsa-plugins.7.rst
- https://github.com/arkq/bluez-alsa/blob/v5.0.0/doc/bluealsad.8.rst
@@ -0,0 +1,5 @@
bcb5e1a1a8462a5aaaf1fa198b09c9fb9bd26cb8d658b052462a0336655cf7b7 bin/bluealsa-aplay
e9d30ca988de2856d4bc98e1b1b2b81a461fe021c008fd15ccaf0faa9925c128 bin/bluealsactl
60c91e60d36b55d670e886170539e4ee7223d2f2153fafafe8e570cbf2b18672 bin/bluealsad
d1cf683fe82ff1f3c7268fe2614ada5a05a63e3ceb2603689c08740b5618f118 lib/libasound_module_ctl_bluealsa.so
3372f48574b56b320424ee8e3f9319aa920967d16538c6ff77f3ac94174b19c4 lib/libasound_module_pcm_bluealsa.so
@@ -0,0 +1 @@
1935d6dcb8975f2d7a51aaafe61538d157224623
@@ -0,0 +1,14 @@
==========================================
BlueALSA v5.0.0: test/test-suite.log
==========================================
# TOTAL: 19
# PASS: 19
# SKIP: 0
# XFAIL: 0
# FAIL: 0
# XPASS: 0
# ERROR: 0
.. contents:: :depth: 2
@@ -0,0 +1 @@
5.0.0
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Explicit, isolated ARM64 build. Does not install or restart anything on a comma.
set -euo pipefail
cd "$(dirname "$0")"
image=starpilot-bluealsa-builder:24.04
source_commit=1935d6dcb8975f2d7a51aaafe61538d157224623
if [[ ${1:-} == --setup ]]; then
docker build --platform linux/arm64 -t "$image" .
exit
fi
if [[ $# != 1 || ! -d $1/.git ]]; then
echo 'Usage: build.sh --setup | build.sh /path/to/bluez-alsa-v5.0.0' >&2
exit 2
fi
source_dir=$(cd "$1" && pwd)
[[ $(git -C "$source_dir" rev-parse HEAD) == "$source_commit" ]] || { echo 'Wrong BlueALSA source revision' >&2; exit 1; }
# Export only committed upstream files. Local changes are never silently bundled.
mkdir -p build
archive=$(mktemp)
trap 'rm -f "$archive"' EXIT
git -C "$source_dir" archive "$source_commit" > "$archive"
cp "$archive" build/upstream.tar
docker run --rm --platform linux/arm64 --pull never --network none \
--mount "type=bind,src=$PWD,dst=/package" "$image" bash -euc '
mkdir /tmp/source
cd /tmp/source
tar xf /package/build/upstream.tar
autoreconf --install >/tmp/autoreconf.log 2>&1 || { cat /tmp/autoreconf.log; exit 1; }
./configure --localstatedir=/data/starpilot/bluealsa5 --disable-debug --disable-manpages --enable-ldac --enable-ofono --enable-test --with-libsamplerate > /tmp/configure.log 2>&1 || { cat /tmp/configure.log; exit 1; }
make -j4 >/tmp/make.log 2>&1 || { cat /tmp/make.log; exit 1; }
make -j4 check >/tmp/check.log 2>&1 || { cat test/test-suite.log /tmp/check.log; exit 1; }
mkdir -p /package/bin /package/lib /package/licenses
cp src/bluealsad src/bluealsactl/bluealsactl utils/aplay/bluealsa-aplay /package/bin/
cp src/asound/.libs/libasound_module_{pcm,ctl}_bluealsa.so /package/lib/
strip /package/bin/* /package/lib/*
/package/bin/bluealsad --version
/package/bin/bluealsad --help > /package/build/help.txt
: > /package/build/dependencies.txt
for artifact in /package/bin/* /package/lib/*; do
readelf -h "$artifact" | grep "Machine:.*AArch64"
ldd "$artifact" >> /package/build/dependencies.txt
done
! grep -q "not found" /package/build/dependencies.txt
dpkg-query -W > /package/build/build-packages.txt
cp /tmp/configure.log /tmp/check.log /package/build/
cp test/test-suite.log /package/build/
cp test/test-suite.log /package/UPSTREAM_TEST_RESULTS.txt
cp /package/build/build-packages.txt /package/BUILD_PACKAGES.txt
cp LICENSE /package/licenses/BlueALSA.txt
cp LICENSES/* /package/licenses/
cd /package
sha256sum bin/* lib/* > SHA256SUMS
'
printf '%s\n' "$source_commit" > UPSTREAM_COMMIT
printf '%s\n' '5.0.0' > VERSION
docker image inspect --format '{{.Id}}' "$image" > BUILD_IMAGE
@@ -0,0 +1,37 @@
"""Copied to /run before activation; intentionally has no checkout imports.
An older checkout removes VERSION. Its existing phone_profile.release() restarts
bluealsa, so this retained launcher removes the v5 override and execs stock v4.
A reboot also removes /run overrides. Neither path modifies /usr or v4 state.
"""
from pathlib import Path
import os
import subprocess
import sys
def select(bundle: Path, legacy: Path, override: Path, reload):
marker = bundle / 'VERSION'
if marker.exists():
if marker.read_text().strip() != '5.0.0':
raise RuntimeError('Unsupported bundled BlueALSA version')
binary = bundle / 'bin' / 'bluealsad'
if not binary.is_file():
raise RuntimeError('Incomplete BlueALSA 5 bundle')
# Keep the existing scheduling policy; no new real-time priority over driving.
return [str(binary), '-p', 'a2dp-source', '-p', 'a2dp-sink', '-c', 'SBC', '-c', 'LDAC']
override.unlink(missing_ok=True)
(legacy.parent.parent / 'starpilot-bluealsa-launch.py').unlink(missing_ok=True)
reload()
argv = ['/usr/bin/bluealsa', '-p', 'a2dp-source']
if legacy.exists():
argv += ['-p', 'a2dp-sink']
return argv
if __name__ == '__main__':
argv = select(*(Path(p) for p in sys.argv[1:]),
reload=lambda: subprocess.run(['/usr/bin/systemctl', 'daemon-reload'], check=True, timeout=15))
if argv[0] != '/usr/bin/bluealsa':
Path('/data/starpilot/bluealsa5/lib').mkdir(mode=0o700, parents=True, exist_ok=True)
os.execv(argv[0], argv)
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2016-2025 BlueALSA developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2016-2025 BlueALSA developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,79 @@
"""Checkout-local BlueALSA 5 bundle and reversible /run service selection."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent / 'bluealsa'
ARTIFACTS = ('bin/bluealsad', 'bin/bluealsactl', 'bin/bluealsa-aplay',
'lib/libasound_module_pcm_bluealsa.so', 'lib/libasound_module_ctl_bluealsa.so')
def validate(root: Path | None = None) -> None:
root = root or ROOT
if (root / 'VERSION').read_text().strip() != '5.0.0':
raise RuntimeError('BlueALSA 5.0.0 bundle is missing')
records = dict(line.split(maxsplit=1)[::-1] for line in (root / 'SHA256SUMS').read_text().splitlines())
for name in ARTIFACTS:
data = (root / name).read_bytes()
if records.get(name) != hashlib.sha256(data).hexdigest():
raise RuntimeError(f'BlueALSA artifact checksum failed: {name}')
if data[:6] != b'\x7fELF\x02\x01' or data[18:20] != b'\xb7\x00':
raise RuntimeError(f'BlueALSA artifact is not ARM64: {name}')
if name.startswith('bin/') and not os.access(root / name, os.X_OK):
raise RuntimeError(f'BlueALSA executable is not executable: {name}')
def alsa_plugins(root: Path | None = None) -> str:
"""Explicit library paths affect this ALSA config only, never system defaults."""
root = root or ROOT
result = ''
for kind in ('pcm', 'ctl'):
library = str(root / 'lib' / f'libasound_module_{kind}_bluealsa.so')
if any(c in library for c in '\n\r"\\'):
raise RuntimeError('Invalid BlueALSA plugin path')
result += f'{kind}_type.bluealsa {{ lib "{library}" }}\n'
return result
def paths(legacy: Path) -> tuple[Path, Path]:
return legacy.with_name('95-starpilot-bluealsa5.conf'), legacy.parent.parent / 'starpilot-bluealsa-launch.py'
def contents(legacy: Path, root: Path | None = None) -> str:
root = root or ROOT
override, launcher = paths(legacy)
def quote(path: Path) -> str:
value = str(path)
if any(c in value for c in '\n\r"\\$'):
raise RuntimeError('Invalid BlueALSA service path')
return '"' + value.replace('%', '%%') + '"'
return ('[Service]\nExecStart=\nExecStart=/usr/bin/python3 ' +
' '.join(quote(p) for p in (launcher, root, legacy, override)) + '\n')
def check_owned(legacy: Path, root: Path | None = None) -> None:
override, _ = paths(legacy)
if override.exists() and override.read_text() != contents(legacy, root):
raise RuntimeError('BlueALSA 5 override was changed externally')
def install(legacy: Path, root: Path | None = None) -> None:
root = root or ROOT
validate(root)
check_owned(legacy, root)
override, launcher = paths(legacy)
launcher.parent.mkdir(parents=True, exist_ok=True)
temporary = launcher.with_suffix('.tmp')
temporary.write_bytes((root / 'launcher.py').read_bytes())
temporary.chmod(0o644)
temporary.replace(launcher)
override.write_text(contents(legacy, root))
def remove(legacy: Path, root: Path | None = None) -> None:
check_owned(legacy, root)
override, launcher = paths(legacy)
override.unlink(missing_ok=True)
launcher.unlink(missing_ok=True)
+5 -4
View File
@@ -16,7 +16,7 @@ import sys
import time
from pathlib import Path
from openpilot.starpilot.soloist import backend
from openpilot.starpilot.soloist import backend, bluealsa_runtime
from openpilot.starpilot.soloist import preferences
from openpilot.starpilot.soloist.paths import Layout
from openpilot.starpilot.soloist.preferences import ADDRESS
@@ -155,8 +155,8 @@ class PhoneInput:
def capture_config(address: str) -> str:
if not ADDRESS.fullmatch(address):
raise ValueError('Invalid phone Bluetooth address')
return ('</usr/share/alsa/alsa.conf>\npcm.starpilot_phone {\n type plug\n slave.pcm {\n' +
f' type bluealsa\n device "{address}"\n profile "a2dp"\n' + ' }\n}\n')
return ('</usr/share/alsa/alsa.conf>\n' + bluealsa_runtime.alsa_plugins() + 'pcm.starpilot_phone {\n type plug\n slave.pcm {\n' +
f' type bluealsa\n device "{address}"\n profile "a2dp"\n hwcompat "silence"\n softvol "true"\n' + ' }\n}\n')
class SampleSpec(C.Structure):
@@ -246,7 +246,8 @@ def relay(address: str, server: str, config: Path):
continue
writer.write(data)
if log is not None and now - last_log >= 0.5:
log.write(f'{time.time():.3f} latency_ms={writer.latency_ms():.1f} max_read_ms={max_read * 1000:.0f}\n')
epoch = time.time() # noqa: TID251 - correlate diagnostic logs with btmon/journal timestamps
log.write(f'{epoch:.3f} latency_ms={writer.latency_ms():.1f} max_read_ms={max_read * 1000:.0f}\n')
log.flush()
last_log, max_read = now, 0.0
finally:
+27 -14
View File
@@ -11,6 +11,11 @@ from pathlib import Path
import subprocess
import sys
if __package__:
from . import bluealsa_runtime
else:
import bluealsa_runtime
DROP_IN = Path('/run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf')
# BlueALSA is tiny but must drain the Bluetooth socket on time; at nice 0 it competes with driving
# processes on busy cores and the music chain (nice 10) cannot make up for lost incoming packets.
@@ -25,32 +30,40 @@ def systemctl(*args):
def configure(action: str, path: Path = DROP_IN, run=systemctl) -> None:
if action not in ('acquire', 'release'):
raise ValueError('Unknown profile action')
if path.exists() and path.read_text() not in (CONTENTS, LEGACY_CONTENTS):
previous = path.read_text() if path.exists() else None
if previous is not None and previous not in (CONTENTS, LEGACY_CONTENTS):
raise RuntimeError('Phone audio profile override was changed externally')
bluealsa_runtime.check_owned(path)
override, _ = bluealsa_runtime.paths(path)
if action == 'release':
if path.exists():
path.unlink()
if previous is not None or override.exists():
bluealsa_runtime.remove(path)
path.unlink(missing_ok=True)
run('daemon-reload')
run('try-restart', 'bluealsa.service')
return
if path.exists() and path.read_text() == LEGACY_CONTENTS:
path.write_text(CONTENTS)
run('daemon-reload')
run('restart', 'bluealsa.service')
return
command = run('show', '--property=ExecStart', '--value', 'bluealsa.service')
if 'argv[]=/usr/bin/bluealsa -p a2dp-source -p a2dp-sink ;' in command:
bluealsa_runtime.validate()
if previous == CONTENTS and override.exists():
run('start', 'bluealsa.service')
return
if 'argv[]=/usr/bin/bluealsa -p a2dp-source ;' not in command:
raise RuntimeError('Unsupported BlueALSA service configuration; phone audio was not enabled')
if previous is None:
command = run('show', '--property=ExecStart', '--value', 'bluealsa.service')
if not any(f'argv[]=/usr/bin/bluealsa {profiles} ;' in command for profiles in
('-p a2dp-source', '-p a2dp-source -p a2dp-sink')):
raise RuntimeError('Unsupported BlueALSA service configuration; phone audio was not enabled')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(CONTENTS)
try:
# Keep this file byte-for-byte compatible with old commits recovery code.
path.write_text(CONTENTS)
bluealsa_runtime.install(path)
run('daemon-reload')
run('restart', 'bluealsa.service')
except Exception:
path.unlink(missing_ok=True)
bluealsa_runtime.remove(path)
if previous is None:
path.unlink(missing_ok=True)
else:
path.write_text(previous)
run('daemon-reload')
run('try-restart', 'bluealsa.service')
raise
+12 -3
View File
@@ -20,7 +20,7 @@ import time
from collections.abc import Callable
from pathlib import Path
from openpilot.starpilot.soloist import backend, bugsink_report, credentials, package, preferences, phone_audio
from openpilot.starpilot.soloist import backend, bluealsa_runtime, bugsink_report, credentials, package, preferences, phone_audio
from openpilot.starpilot.soloist import now_playing as now_playing_module
from openpilot.starpilot.soloist.client import PlayerClient
from openpilot.starpilot.soloist.paths import CONTROL_SOCKET, Layout, private_dir
@@ -357,7 +357,7 @@ class Supervisor:
if self.phone_prefs is not None:
self.stop_children()
self.retry_at = 0
elif self.phone_profile and self.clock() >= self.pairing_profile_until:
elif self.phone_profile and (prefs.output != 'bluetooth' or self.launched is None) and self.clock() >= self.pairing_profile_until:
self._release_profile()
state, message, launch_key = self.desired()
now = self.clock()
@@ -382,6 +382,14 @@ class Supervisor:
if not self.network():
self._set('waiting_network', 'Waiting for the comma’s network…')
return
if prefs.output == 'bluetooth' and not self.phone_profile:
try:
phone_audio.profile('acquire')
self.phone_profile = True
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
self.retry_at = now + OUTPUT_RETRY_SECONDS
self._set('audio_unavailable', str(error))
return
self.output_available = prefs.output != 'bluetooth' or self._output_ready(prefs.output_address)
self.output_check_at = now + OUTPUT_RETRY_SECONDS
self.waiting_for = '' if self.output_available else prefs.output_name or 'your Bluetooth speaker'
@@ -579,7 +587,8 @@ def main() -> None:
os.nice(10) # music yields to everything driving-related; children inherit this
supervisor = Supervisor()
reap_orphans(supervisor.layout)
if Path('/run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf').exists():
legacy_profile = Path('/run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf')
if legacy_profile.exists() or bluealsa_runtime.paths(legacy_profile)[0].exists():
phone_audio.profile('release')
stop = threading.Event()
@@ -0,0 +1,63 @@
"""Temporary AGNOS BlueALSA receiving profile. Invoked as root with a fixed action.
Only a feature-owned /run drop-in is changed; Bluetooth power, bonds, bluetoothd,
HFP and the on-disk service definition are untouched. Unknown service commands
are rejected rather than replaced. The override disappears on reboot.
"""
from __future__ import annotations
import fcntl
from pathlib import Path
import subprocess
import sys
DROP_IN = Path('/run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf')
# BlueALSA is tiny but must drain the Bluetooth socket on time; at nice 0 it competes with driving
# processes on busy cores and the music chain (nice 10) cannot make up for lost incoming packets.
LEGACY_CONTENTS = '[Service]\nExecStart=\nExecStart=/usr/bin/bluealsa -p a2dp-source -p a2dp-sink\n'
CONTENTS = LEGACY_CONTENTS + 'Nice=-5\n'
def systemctl(*args):
return subprocess.run(['/usr/bin/systemctl', *args], check=True, capture_output=True, text=True, timeout=15).stdout.strip()
def configure(action: str, path: Path = DROP_IN, run=systemctl) -> None:
if action not in ('acquire', 'release'):
raise ValueError('Unknown profile action')
if path.exists() and path.read_text() not in (CONTENTS, LEGACY_CONTENTS):
raise RuntimeError('Phone audio profile override was changed externally')
if action == 'release':
if path.exists():
path.unlink()
run('daemon-reload')
run('try-restart', 'bluealsa.service')
return
if path.exists() and path.read_text() == LEGACY_CONTENTS:
path.write_text(CONTENTS)
run('daemon-reload')
run('restart', 'bluealsa.service')
return
command = run('show', '--property=ExecStart', '--value', 'bluealsa.service')
if 'argv[]=/usr/bin/bluealsa -p a2dp-source -p a2dp-sink ;' in command:
run('start', 'bluealsa.service')
return
if 'argv[]=/usr/bin/bluealsa -p a2dp-source ;' not in command:
raise RuntimeError('Unsupported BlueALSA service configuration; phone audio was not enabled')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(CONTENTS)
try:
run('daemon-reload')
run('restart', 'bluealsa.service')
except Exception:
path.unlink(missing_ok=True)
run('daemon-reload')
run('try-restart', 'bluealsa.service')
raise
if __name__ == '__main__':
# Serializes recovery and normal shutdown; never takes the adapter's pairing lease.
with open('/run/starpilot-phone-profile.lock', 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
configure(sys.argv[1])
@@ -0,0 +1,145 @@
from pathlib import Path
import runpy
import shutil
import subprocess
import pytest
from openpilot.starpilot.soloist import bluealsa_runtime as runtime, phone_profile, phone_audio, backend
def test_bundle_is_complete_checksummed_arm64():
runtime.validate()
assert (runtime.ROOT / 'UPSTREAM_COMMIT').read_text().strip() == '1935d6dcb8975f2d7a51aaafe61538d157224623'
def test_capture_and_playback_select_matching_private_plugins():
capture = phone_audio.capture_config('11:22:33:44:55:66')
playback = backend.alsa_config('11:22:33:44:55:66')
for config in (capture, playback):
assert runtime.alsa_plugins() in config
assert 'softvol "true"' in config
assert 'hwcompat "silence"' in capture
assert 'hwcompat' not in playback
def test_checksum_failure_rejects_incomplete_upgrade(tmp_path):
shutil.copytree(runtime.ROOT, tmp_path / 'bundle', ignore=shutil.ignore_patterns('build', '__pycache__'))
root = tmp_path / 'bundle'
(root / 'lib/libasound_module_pcm_bluealsa.so').write_bytes(b'broken')
with pytest.raises(RuntimeError, match='checksum'):
runtime.validate(root)
def test_old_commit_restart_restores_stock_daemon_and_removes_upgrade(tmp_path, monkeypatch):
checkout = tmp_path / 'checkout'
checkout.mkdir()
def git(*args):
return subprocess.run(['git', '-C', str(checkout), *args], check=True, capture_output=True, text=True).stdout.strip()
git('init', '-q')
git('config', 'user.name', 'Rollback test')
git('config', 'user.email', 'rollback@example.invalid')
old = Path(__file__).with_name('fixtures') / 'phone_profile_4_1_1.txt'
shutil.copyfile(old, checkout / 'phone_profile.py')
git('add', '.')
git('commit', '-qm', '4.1.1')
previous_commit = git('rev-parse', 'HEAD')
bundle = checkout / 'bluealsa'
shutil.copytree(runtime.ROOT, bundle, ignore=shutil.ignore_patterns('build', '__pycache__'))
git('add', '.')
git('commit', '-qm', '5.0.0')
monkeypatch.setattr(runtime, 'ROOT', bundle)
legacy = tmp_path / 'run/systemd/system/bluealsa.service.d/90-starpilot-phone.conf'
calls = []
def run(*args):
calls.append(args)
if args[0] == 'show':
return '{ argv[]=/usr/bin/bluealsa -p a2dp-source ; }'
return ''
phone_profile.configure('acquire', legacy, run)
override, launcher = runtime.paths(legacy)
launch = runpy.run_path(str(launcher))['select']
assert launch(bundle, legacy, override, lambda: None)[0] == str(bundle / 'bin/bluealsad')
# Actual checkout removes tracked v5 bundle; /run intentionally survives.
git('checkout', '-q', previous_commit)
assert not (bundle / 'VERSION').exists()
old_module = runpy.run_path(str(checkout / 'phone_profile.py'))
selected = []
def old_run(*args):
calls.append(args)
if args[0] == 'try-restart':
selected.append(launch(bundle, legacy, override, lambda: calls.append(('daemon-reload',))))
return ''
old_module['configure']('release', legacy, old_run)
assert selected == [['/usr/bin/bluealsa', '-p', 'a2dp-source']]
assert not legacy.exists() and not override.exists() and not launcher.exists()
old_module['configure']('acquire', legacy, run)
assert legacy.read_text() == old_module['CONTENTS']
assert not override.exists() # old code stays on the system daemon/plugins
def test_failed_start_rolls_back_both_overrides(tmp_path):
legacy = tmp_path / 'system/bluealsa.service.d/90-starpilot-phone.conf'
calls = []
def run(*args):
calls.append(args)
if args[0] == 'show':
return '{ argv[]=/usr/bin/bluealsa -p a2dp-source ; }'
if args[0] == 'restart':
raise RuntimeError('start failed')
return ''
with pytest.raises(RuntimeError, match='start failed'):
phone_profile.configure('acquire', legacy, run)
assert not legacy.exists()
assert all(not p.exists() for p in runtime.paths(legacy))
assert calls[-1] == ('try-restart', 'bluealsa.service')
def test_spotify_bluetooth_owns_upgrade_until_output_stops(tmp_path, monkeypatch):
from openpilot.starpilot.soloist import preferences, supervisor
from openpilot.starpilot.soloist.paths import Layout
from openpilot.starpilot.soloist.tests.test_runtime import make_supervisor, ready_layout
layout = Layout(tmp_path / 'music')
ready_layout(layout, monkeypatch)
preferences.save(preferences.Preferences(output='bluetooth', output_address='11:22:33:44:55:66'), layout)
calls, children = [], []
monkeypatch.setattr(phone_audio, 'profile', calls.append)
monkeypatch.setattr(supervisor, '_kill_group', lambda process: None)
sup, now = make_supervisor(layout, children)
sup.output.ready = lambda address: True
sup.tick()
assert calls == ['acquire'] and sup.phone_profile and len(children) == 2
now[0] += 300
sup.tick()
assert calls == ['acquire'] # pairing timeout must not downgrade a playing output
sup.stop_children()
assert calls == ['acquire', 'release'] and not sup.phone_profile
def test_spotify_never_starts_v5_plugin_against_v4_after_activation_failure(tmp_path, monkeypatch):
from openpilot.starpilot.soloist import preferences
from openpilot.starpilot.soloist.paths import Layout
from openpilot.starpilot.soloist.tests.test_runtime import make_supervisor, ready_layout
layout = Layout(tmp_path / 'music')
ready_layout(layout, monkeypatch)
preferences.save(preferences.Preferences(output='bluetooth', output_address='11:22:33:44:55:66'), layout)
def failed(action):
raise RuntimeError('upgrade unavailable')
monkeypatch.setattr(phone_audio, 'profile', failed)
children = []
sup, now = make_supervisor(layout, children)
sup.tick()
assert not children and not sup.phone_profile
assert sup.state == 'audio_unavailable' and sup.retry_at > now[0]
def test_daemon_uses_separate_persistent_storage():
binary = (runtime.ROOT / 'bin/bluealsad').read_bytes()
assert b'/data/starpilot/bluealsa5/lib/bluealsa\0' in binary
assert b'/var/lib/bluealsa\0' not in binary
def test_bundle_artifacts_are_not_git_ignored():
result = subprocess.run(['git', '-C', str(runtime.ROOT.parents[3]), 'check-ignore',
*(str(runtime.ROOT / name) for name in runtime.ARTIFACTS)], check=False, capture_output=True)
assert result.returncode == 1 # all artifacts can travel with the upgrade commit
@@ -168,6 +168,7 @@ class FakeClient:
def ready_layout(layout, monkeypatch, *, expired=False):
monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda action: None)
monkeypatch.setattr(supervisor.package, 'release_status', lambda _l: {'installed': True, 'expired': expired})
monkeypatch.setattr(supervisor.backend, 'bundle_ready', lambda: True)
layout.root.mkdir(parents=True, exist_ok=True)