DIY Bluetooth receiver
December 7, 2025I’ve owned the same stereo for more than 10 years at this point.
Sadly, it mostly sits around collecting dust, as it doesn’t support Bluetooth and would require me to plug in my devices using the 3.5 mm jack input.
Luckily, I recently found an unused Raspberry Pi 4 in one of my old boxes and figured I could use it as a Bluetooth receiver.
Before you tell me that I’m not the first person to do this: Yes, I know.
But for me, this project is more about the journey,
about learning how set up the system myself without following a tutorial.
Sitting down with the man-pages and the Alpine Wiki.
Honestly, I can only recommend giving it a shot, because it gives you a deeper understanding of how your system works
and actually allows you to fix stuff if it breaks.
If you don’t want to read the wiki yourself, just want to get a brief overview of the system setup, or are me from the future and forgot how to use the Bluetooth bridge, this post is for you.
ℹ️ Note
To make my life a little bit easier, I’m going to assume that your Raspberry Pi is already running Alpine Linux, with one user configured and doas installed.
Pi Audio
I had to do a couple of things to get audio output to work on the Pi.
If you install aplay (using doas apk add aplay-utils), you’ll probably find that no audio devices were detected.
To fix this, you can enable audio output by editing /boot/usercfg.txt:
# enable audio
dtparam=audio=on
audio_pwm_mode=2
And adding snd_bcm2835 to your /etc/modules file.
After rebooting your Pi, aplay -l should show a soundcard.
To ensure the default is actually set, you can add it to /etc/asound.conf.
(This is especially useful if you have other sound devices connected to the Pi.)
defaults.pcm.card 0
defaults.ctl.card 0
To validate audio output is working, you can grab a sample file and play it.
wget https://download.samplelib.com/wav/sample-3s.wav
aplay sample-3s.wav
User Services & PAM
As opposed to when we attempted AirPlaying from Android,
we are actually using OpenRC as the devices’ init system.
This allows us to integrate user-service startup directly into the main init system.
Because the OpenRC user services require the XDG_RUNTIME_DIR to be set,
we’ll be making use of PAM to set it up on boot without requiring us to log in.
Setting up PAM to manage OpenRC user services is as easy as installing the required packages
and rebooting the system.
doas apk add openrc-user-pam pam-rundir
Before you reboot your system,
we can use the chance to set up autostart of user services for your account.
Luckily, OpenRC already has a service file, which we can reuse.
# create service files
mkdir -p ${XDG_CONFIG_HOME:-~/.config}/rc/runlevels/sysinit
# enable your user service
doas ln -s /etc/init.d/user /etc/init.d/user.<your-username>
doas rc-update add user.<your-username>
After rebooting your system (doas reboot),
you should find that your user system is running:
doas rc-service user.<your-username> status
Additionally, you’ll find some session management files in the XDG_RUNTIME_DIR:
ls /run/user/$(id -u)
To export the XDG_RUNTIME_DIR you can use the following command:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
I would recommend adding the command to your ~/.profile so it is loaded whenever you log in.
Otherwise, you would have to run it every time you log in, as it is required for most of the upcoming commands to work.
PipeWire
With your user services managed by OpenRC, setting up PipeWire is pretty easy; you just install the packages and enable the services.
# install packages
doas apk add pipewire wireplumber dbus
# enable services on "boot"
rc-update -U add dbus
rc-update -U add pipewire
rc-update -U add wireplumber
To quickly start all of them, simply restart your system-wide user service registration:
doas rc-service user.<your-username> restart
If everything works properly, you should be able to call wpctl status without any errors.
Getting PipeWire to talk to ALSA
On Alpine Linux, the ALSA support for PipeWire is available in a separate package:
doas apk add pipewire-alsa
To my surprise, this wasn’t enough for the devices to show up;
instead, I had to make sure I was using eudev instead of mdev, which is the default.
# This is recommended by the Alpine Wiki,
# and does all the proper udev setup for you
doas setup-devd udev
# reboot to properly load everything
doas reboot
You can validate if everything was loaded properly using wpctl status.
Normally, PipeWire should automatically set the default sink,
but if it didn’t (it is not marked with an asterisk in the status output),
you can set it by passing the node ID (the number in the status output) to wpctl set-default <id>.
Bluetooth
The go-to Bluetooth solution for Linux desktop is BlueZ, and as you might expect, there is a PipeWire integration for it:
doas apk add bluez pipewire-spa-bluez
Bluez is configured using the /etc/bluetooth/main.conf file.
While it allows for all sorts of setups, we are only interested in Class,
which you should set to 0x200428.
You might also want to set Name to your Bluetooth device name of choice.
Now you can start the Bluetooth service and enable it on boot.
doas rc-service bluetooth start
doas rc-update add bluetooth
To get PipeWire to pick up the BlueZ service, you simply restart WirePlumber:
doas rc-service -U wireplumber restart
I personally prefer doing the initial pairing using the bluetoothctl CLI.
Launch a new prompt and execute the following commands: discoverable on, agent on, power on.
Afterwards, you should be able to detect the Pi with your phone and pair it.
Once paired, you can disable discoverability:
bluetoothctl discoverable off
Agent
You might have noticed that you can only connect your phone if you have the bluetoothctl prompt open
and type yes to accept the service request.
This is because it serves as a basic agent, which manages device authentication.
Luckily, BlueZ provides a simple Python script, which can handle the agent job for us.
You can find it in the BlueZ repo, but Alpine ships it alongside the bluez package we installed earlier.
Oddly enough, the script doesn’t work out of the box because it is missing dependencies:
doas apk add python py3-dbus py3-gobject3 py3-bluez
As we want to make a couple of modifications to the script itself,
we’ll copy it from /usr/bin/bluez-simple-agent to a local folder (i.e., ~/.local/opt/).
Inside the file, you’ll have to change two lines.
First of all, remove the import bluezutils line, as the file is only present in the GitHub repo but not in the Alpine package.
Second of all, you’ll want to add a return call before the ask function call in the AuthorizeService method.
This will automatically accept service requests from connected devices.
If you wanted to, you could also auto accept-pairing requests,
but I would advise against doing so for security purposes.
To validate that the script is working, you can run it using python ./bluez-simple-agent and attempt connecting to the Pi with your phone.
Autostart Agent
Because you don’t want to run the agent manually, we’ll integrate it into OpenRC.
As the script uses dbus for communication, we are going to add it as a user service.
Start by making the bluez-simple-agent script we just created executable.
This allows running it using ./bluez-simple-agent, as the Shebang identifies it as a python file,
making it easier to execute in the service file.
Next, open /etc/user/init.d/bluez-simple-agent and insert the service definition,
making sure to adapt the file location:
#!/sbin/openrc-run
description="Simple bluez agent"
command="/home/<your-username>/<folder-path>/bluez-simple-agent"
supervisor=supervise-daemon
error_logger="logger -t '${RC_SVCNAME}' -p daemon.error"
depend() {
need dbus
}
Finally, you can test the service and enable it on boot if all went well.
# test service
rc-service -U bluez-simple-agent start
# enable on boot
rc-update -U add bluez-simple-agent
To make sure everything is actually working without human interaction on the Pi,
quickly reboot the system to ensure all services are properly started.
After connecting your phone and pressing play on your favourite album, you can just sit back and enjoy the music.
Taking it further: AirPlay Bridge
If you’ve read my recent AirPlay from Android post, which I’ve mentioned earlier,
you might remember how we used PipeWire to stream to an AirPlay receiver.
As you might be able to guess, we can do the exact same thing again.
But, whereas on Android, we had to manually configure our AirPlay receiver, it is even easier on real hardware, as we can use zeroconf autodiscovery on the Pi to automatically detect output devices on the network.
On Linux, these discovery services are provided by Avahi, a local network mDNS discovery suite, which is compatible with Apple’s Bonjour/Zeroconf system.
# install avahi
doas apk add avahi
# start avahi & enable it on boot
doas rc-service avahi-daemon start
doas rc-update add avahi-daemon
For PipeWire to be able to integrate with the Avahi-daemon, you have to install a separate package.
doas apk add pipewire-zeroconf
This allows you to load the module from your PipeWire config.
The module will scan for RAOP-compatible sinks and register them locally.
To do so, you can simply create a new file in ~/.config/pipewire/pipewire.conf.d/raop-discover.conf and add the following content:
context.modules = [
{ name = libpipewire-module-raop-discover }]
Afterwards, simply restart WirePlumber and PipeWire:
rc-service -U wireplumber restart
If you run wpctl status after waiting a bit, you should find that your AirPlay receiver shows up in the Sinks section.
It is normally displayed with its display name and the local node ID, i.e.,
Sinks: 83. Laptop [vol: 1.00]
If the line contains an asterisk, the device is the default output node. If it doesn’t, you can set the default output node using wpctl set-default
# list nodes
wpctl status
# set default
wpctl set-default 83
Lastly, simply connect your phone to the Bluetooth receiver, like you did above, play some Music
and your AirPlay receiver should start playing it.
Now, sit back and relax.
If you are wondering how this compares to running everything on Android, I think the delay is probably comparable, but there are significantly fewer audio artifacts. I’d still advise against the bridge mode, as you are probably better off using Bluetooth or streaming from a computer.