Bluetooth Speaker Radio

Starting an internet radio stream on my phone and having it stream audio to a Bluetooth speaker is madness. Way to many interactions, where I mostly want the behavior of an old FM radio: switch on, done.

Replicating the FM radio UX was simpler than expected. The Linux Bluetooth and audio subsystems already send all the DBus events needed to tie a media player to a Bluetooth speaker connection. Full script: bluetooth_radio.py

Implementation

I use Raspberry Pi 3 with Raspbian 10.3 and blue-alsa. I assume that the device is already paired, connected and working. Once connected, it will automatically reconnect when turning if off and on again. pulseaudio-bluetooth should work as well, but since my pi runs headless, it felt simpler without pulseaudio.

Configuration and State

Boring stuff first. All state and configuration is stored in global variables to keep it simple. To adapt the tool to other devices, simply change DEVICE_MAC.

# Get from `bluetoothctl devices`
DEVICE_MAC = "00_08_E0_71_7E_95"
# Add anything mpv plays
FILES = [
    "https://st01.sslstream.dlf.de/dlf/01/high/opus/stream.opus",  # deutschlandfunk
    "http://streams.fluxfm.de/live/aac-64/audio/play.pls",         # fluxfm
    "http://forbes.streams.bassdrive.com:8132/listen.pls",         # bassdrive
]
MPV_IPC_SOCKET = "/tmp/mpv-radio.socket"
# Process if media player is running, None otherwise
PLAYER_PROCESS = None
# Index into FILES
CURRENT_FILE = 0
# DBus
DBUS_LOOP = DBusGMainLoop(set_as_default=True)
BUS = dbus.SystemBus()

Handling Events

The Linux Bluetooth stack creates DBus events that the script can listen for. There is a PropertiesChanged event from bluez that fires on connect and disconnect and the PCMAdded event from blue-alsa. I found the events by running dbus-monitor --system and tuning the speaker off and on again. Bus bus names can be queried by dbus-send --system --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames

PCMAdded will start the player, PropertiesChanged starts a background thread that listens for media key events and stops the player on disconnect.

def handle_connection_event(*args, **kwargs):
    obj = args[0]
    args = args[1]

    if obj == "org.bluez.MediaControl1":
        if args["Connected"]:
            print("connected")
            background(handle_input_events)
        else:
            print("disconnected")
            stop_player()


def handle_bluealsa_sink(*args, **kwargs):
    path = args[0]
    if path == "/org/bluealsa/hci0/dev_" + DEVICE_MAC + "/a2dpsrc/sink":
        print("a2dp sink connected")
        start_player()


BUS.add_signal_receiver(handle_connection_event,
                        bus_name="org.bluez",
                        dbus_interface="org.freedesktop.DBus.Properties",
                        path="/org/bluez/hci0/dev_" + DEVICE_MAC,
                        signal_name="PropertiesChanged")

BUS.add_signal_receiver(handle_bluealsa_sink,
                        bus_name="org.bluealsa",
                        dbus_interface="org.bluealsa.Manager1",
                        signal_name="PCMAdded")

There is just one button on my speaker that emits an event, just enough to cycle through stations. For some reason the evdev library does not enumerate my Bluetooth device, but when the script runs it is likely to be the last one added and there are less than 10 event devices on that host. So, we can get away find_input_device.

def find_input_device():
    return max(glob.glob("/dev/input/event*"))

def handle_input_events():
    global CURRENT_FILE
    global FILES
    time.sleep(5)
    dev = InputDevice(find_input_device())
    for event in dev.read_loop():
        if event.type == ecodes.EV_KEY:
            print("event:", categorize(event))
            kev = KeyEvent(event)
            if kev.keystate == KeyEvent.key_up:
                print("next")
                CURRENT_FILE = (CURRENT_FILE + 1) % len(FILES)
                print(player_command("loadfile", FILES[CURRENT_FILE]))

Player Interaction

Starting and stopping is pretty much standard Popen and process.terminate. I chose mpv, because it works nicely on the command line and has an RPC interface. To change the station the script sends a JSON RPC message to mpc. Restarting mpv would also be an option, but did not feel "right" to me :)

player_command takes another shortcut by using socat to interact with mpv over the IPC socket. That socat call saves a 100 lines of python easy.

def player_command(*args):
    global PLAYER_PROCESS
    command=json.dumps({
        "command": args
    })
    command += "\n"
    command = command.encode("utf-8")
    print("player_command:", args, command)
    return subprocess.check_output(["socat", "-", "/tmp/mpv-radio.socket"],
                                   stderr=subprocess.STDOUT,
                                   input=command)

def start_player():
    global PLAYER_PROCESS
    global FILES
    assert PLAYER_PROCESS == None

    PLAYER_PROCESS = subprocess.Popen(["mpv", "--audio-device", "alsa/bluealsa",
                                       "--quiet",
                                       "--input-ipc-server=" + MPV_IPC_SOCKET,
                                       FILES[0]],
                                      stdout=open("/tmp/radio.log.stdout", "w+"),
                                      stderr=open("/tmp/radio.log.stderr", "w+"),
                                      stdin=subprocess.PIPE)

def stop_player():
    global PLAYER_PROCESS
    assert PLAYER_PROCESS != None
    try:
        PLAYER_PROCESS.terminate()
        print(PLAYER_PROCESS.communicate(timeout=5))
    except subprocess.TimeoutExpired:
        PLAYER_PROCESS.kill()
        print(PLAYER_PROCESS.communicate())
    PLAYER_PROCESS = None

Boilerplate

Imports, a small helper that emulates the shell '&' and the entry point:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import dbus
import glob
import json
import os
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor

from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
from evdev import InputDevice, categorize, ecodes, KeyEvent

<<configuration>>

<<state>>

THREADPOOL = ThreadPoolExecutor(max_workers=10)


def background(func):
    def swallow():
        try:
            func()
        except Exception as e:
            print("Func threw:", func.__name__, e)
    THREADPOOL.submit(swallow)


<<player>>

<<evdev>>

<<dbus>>


try:
    loop = GLib.MainLoop()
    loop.run()
except KeyboardInterrupt:
    stop_player()

Footnotes:

1

Full script: bluetooth_radio.py

2

I assume that the device is already paired, connected and working. Once connected, it will automatically reconnect when turning if off and on again.

3

I found the events by running dbus-monitor --system and tuning the speaker off and on again.

Bus bus names can be queried by dbus-send --system --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames

4

Restarting mpv would also be an option, but did not feel "right" to me :)