diff --git a/.gitignore b/.gitignore index af96cafb86..9e70f57ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/openpilot/starpilot/soloist/README.md b/openpilot/starpilot/soloist/README.md index 70c18e9939..5c7733f1c0 100644 --- a/openpilot/starpilot/soloist/README.md +++ b/openpilot/starpilot/soloist/README.md @@ -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 diff --git a/openpilot/starpilot/soloist/backend.py b/openpilot/starpilot/soloist/backend.py index f1bb7253f4..0cf6009a86 100644 --- a/openpilot/starpilot/soloist/backend.py +++ b/openpilot/starpilot/soloist/backend.py @@ -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') diff --git a/openpilot/starpilot/soloist/bluealsa/.gitignore b/openpilot/starpilot/soloist/bluealsa/.gitignore new file mode 100644 index 0000000000..84c048a73c --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/openpilot/starpilot/soloist/bluealsa/BUILD_IMAGE b/openpilot/starpilot/soloist/bluealsa/BUILD_IMAGE new file mode 100644 index 0000000000..16023ec8a3 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/BUILD_IMAGE @@ -0,0 +1 @@ +sha256:8e0457d4150457e582a95dab9d2d13fc35c437610ec03ac8729d7b57d73d59d7 diff --git a/openpilot/starpilot/soloist/bluealsa/BUILD_PACKAGES.txt b/openpilot/starpilot/soloist/bluealsa/BUILD_PACKAGES.txt new file mode 100644 index 0000000000..561c3417d5 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/BUILD_PACKAGES.txt @@ -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 diff --git a/openpilot/starpilot/soloist/bluealsa/Dockerfile b/openpilot/starpilot/soloist/bluealsa/Dockerfile new file mode 100644 index 0000000000..62a7f4a330 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/Dockerfile @@ -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/* diff --git a/openpilot/starpilot/soloist/bluealsa/README.md b/openpilot/starpilot/soloist/bluealsa/README.md new file mode 100644 index 0000000000..da355e9261 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/README.md @@ -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 diff --git a/openpilot/starpilot/soloist/bluealsa/SHA256SUMS b/openpilot/starpilot/soloist/bluealsa/SHA256SUMS new file mode 100644 index 0000000000..f95775079f --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/SHA256SUMS @@ -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 diff --git a/openpilot/starpilot/soloist/bluealsa/UPSTREAM_COMMIT b/openpilot/starpilot/soloist/bluealsa/UPSTREAM_COMMIT new file mode 100644 index 0000000000..a1ab0cbf47 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/UPSTREAM_COMMIT @@ -0,0 +1 @@ +1935d6dcb8975f2d7a51aaafe61538d157224623 diff --git a/openpilot/starpilot/soloist/bluealsa/UPSTREAM_TEST_RESULTS.txt b/openpilot/starpilot/soloist/bluealsa/UPSTREAM_TEST_RESULTS.txt new file mode 100644 index 0000000000..d8873f2f4f --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/UPSTREAM_TEST_RESULTS.txt @@ -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 + diff --git a/openpilot/starpilot/soloist/bluealsa/VERSION b/openpilot/starpilot/soloist/bluealsa/VERSION new file mode 100644 index 0000000000..0062ac9718 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/VERSION @@ -0,0 +1 @@ +5.0.0 diff --git a/openpilot/starpilot/soloist/bluealsa/bin/bluealsa-aplay b/openpilot/starpilot/soloist/bluealsa/bin/bluealsa-aplay new file mode 100755 index 0000000000..2c2e00c77d Binary files /dev/null and b/openpilot/starpilot/soloist/bluealsa/bin/bluealsa-aplay differ diff --git a/openpilot/starpilot/soloist/bluealsa/bin/bluealsactl b/openpilot/starpilot/soloist/bluealsa/bin/bluealsactl new file mode 100755 index 0000000000..dde9f90c90 Binary files /dev/null and b/openpilot/starpilot/soloist/bluealsa/bin/bluealsactl differ diff --git a/openpilot/starpilot/soloist/bluealsa/bin/bluealsad b/openpilot/starpilot/soloist/bluealsa/bin/bluealsad new file mode 100755 index 0000000000..83d530230a Binary files /dev/null and b/openpilot/starpilot/soloist/bluealsa/bin/bluealsad differ diff --git a/openpilot/starpilot/soloist/bluealsa/build.sh b/openpilot/starpilot/soloist/bluealsa/build.sh new file mode 100755 index 0000000000..922ab04f49 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/build.sh @@ -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 diff --git a/openpilot/starpilot/soloist/bluealsa/launcher.py b/openpilot/starpilot/soloist/bluealsa/launcher.py new file mode 100644 index 0000000000..c095aa3c48 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/launcher.py @@ -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) diff --git a/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_ctl_bluealsa.so b/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_ctl_bluealsa.so new file mode 100755 index 0000000000..125736ba42 Binary files /dev/null and b/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_ctl_bluealsa.so differ diff --git a/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_pcm_bluealsa.so b/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_pcm_bluealsa.so new file mode 100755 index 0000000000..816c9dd8f3 Binary files /dev/null and b/openpilot/starpilot/soloist/bluealsa/lib/libasound_module_pcm_bluealsa.so differ diff --git a/openpilot/starpilot/soloist/bluealsa/licenses/BlueALSA.txt b/openpilot/starpilot/soloist/bluealsa/licenses/BlueALSA.txt new file mode 100644 index 0000000000..d260e72dae --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/licenses/BlueALSA.txt @@ -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. diff --git a/openpilot/starpilot/soloist/bluealsa/licenses/CC0-1.0.txt b/openpilot/starpilot/soloist/bluealsa/licenses/CC0-1.0.txt new file mode 100644 index 0000000000..0e259d42c9 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/licenses/CC0-1.0.txt @@ -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. diff --git a/openpilot/starpilot/soloist/bluealsa/licenses/MIT.txt b/openpilot/starpilot/soloist/bluealsa/licenses/MIT.txt new file mode 100644 index 0000000000..d260e72dae --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa/licenses/MIT.txt @@ -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. diff --git a/openpilot/starpilot/soloist/bluealsa_runtime.py b/openpilot/starpilot/soloist/bluealsa_runtime.py new file mode 100644 index 0000000000..2e209c6a70 --- /dev/null +++ b/openpilot/starpilot/soloist/bluealsa_runtime.py @@ -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) diff --git a/openpilot/starpilot/soloist/phone_audio.py b/openpilot/starpilot/soloist/phone_audio.py index cfe1e66fbd..b908617845 100644 --- a/openpilot/starpilot/soloist/phone_audio.py +++ b/openpilot/starpilot/soloist/phone_audio.py @@ -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 ('\npcm.starpilot_phone {\n type plug\n slave.pcm {\n' + - f' type bluealsa\n device "{address}"\n profile "a2dp"\n' + ' }\n}\n') + return ('\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: diff --git a/openpilot/starpilot/soloist/phone_profile.py b/openpilot/starpilot/soloist/phone_profile.py index 65d797143c..b695567bf2 100644 --- a/openpilot/starpilot/soloist/phone_profile.py +++ b/openpilot/starpilot/soloist/phone_profile.py @@ -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 diff --git a/openpilot/starpilot/soloist/supervisor.py b/openpilot/starpilot/soloist/supervisor.py index da2080474d..8326ee9320 100644 --- a/openpilot/starpilot/soloist/supervisor.py +++ b/openpilot/starpilot/soloist/supervisor.py @@ -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() diff --git a/openpilot/starpilot/soloist/tests/fixtures/phone_profile_4_1_1.txt b/openpilot/starpilot/soloist/tests/fixtures/phone_profile_4_1_1.txt new file mode 100644 index 0000000000..65d797143c --- /dev/null +++ b/openpilot/starpilot/soloist/tests/fixtures/phone_profile_4_1_1.txt @@ -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]) diff --git a/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py b/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py new file mode 100644 index 0000000000..326eeee78b --- /dev/null +++ b/openpilot/starpilot/soloist/tests/test_bluealsa_upgrade.py @@ -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 diff --git a/openpilot/starpilot/soloist/tests/test_runtime.py b/openpilot/starpilot/soloist/tests/test_runtime.py index e495b1f8af..e23b7e30bc 100644 --- a/openpilot/starpilot/soloist/tests/test_runtime.py +++ b/openpilot/starpilot/soloist/tests/test_runtime.py @@ -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)