How I used Go to make my radio auto-switch to AUX-IN when a Raspi plays music
Ok, so your radio lacks AirPlay support but has an auxiliary input and can be remote-controlled via the Frontier Silicon API. Fetch a Raspberry Pi, put Shairport-sync and Raspotify on it, plug it into the AUX port, and glue everything together with some Go code. Et voilà - home automation in the small.
The problem
A smart radio is quite nice, internet radio, spotify client, and other bells and whistles. We have a 3sixty radio in our kitchen and we love it, but there is a problem. It has no AirPlay support. And the spotify connect client has some occasional hiccups, but to be honest, this could be caused by the Wifi network. But anyway, some time ago I decided to fix the missing AirPlay and add an alternate Spotify client.
The radio has an AUX-IN port, but…
So the idea I had was to grab a Raspberry Pi Zero, soldier an Audio-Out pHAT on top, install shairport-sync
for AirPort support and raspotify
for Spotify Connect support, and plug this all into the 3sixty's AUX-IN port.
So far so good, I did this, and everything worked well, except…
…except that I would have had to stand up from the kitchen table and walk two steps towards the radio to manually switch it to AUX-IN. The lazy nerd inside me immediately started thinking about automating that task. But how can I bring an off-the-shelf radio – smart or not – to activate its auxiliary port automatically?
A pleasant surprise
And then I discovered that the radio DOES have an API over HTTP. What a pleasant surprise! It turned out that the radio has a “Silicon Frontier” chipset. This chipset supports an API for remote-controlling the radio. The API is not officially documented as far as I am aware, but the flammy/fsapi repository has a good part of the API documented.
So my plan slowly took shape. A service on the Raspi can remote-control the radio when either the AirPlay service or the Spotitfy connect service starts playing music.
Ok but how to detect music output on a Raspi?
A few Web searches later I had a solution. Regardless of which particular service delivers music, they all send their output to the system's sound card. The sound card is accessible as a virtual file in the /proc/
filesystem, which allows to use standard file access methods, no matter what particular service delivers the sound.
Using Go to connect the dangling wires
This would not be a Go blog if I did not use Go as the virtual soldering iron for wiring up all the pieces. Here we go.
The FS API
The radio's Frontier Silicon API (FS API) is quite simple and straightforward. It is a REST API, for which standard net/http
seems just fine. However, the return values are encoded in XML. The older among you may remember this markup format that is as ugly as it was overhyped in 2000/2001 just before the dot-com bubble burst. This is an example of an XML return value:
<fsapiResponse>
<status>
STATUS
</status>
VALUE
</fsapiResponse>
JSON would be much more readable, besides being supported by the Go standard library:
{
"fsapiResponse": {
"status": "STATUS",
"text": "VALUE"
}
}
The FS API does not allow to opt for JSON as response format, so I had to find a decent XML package. After a short search, I decided to use basgys/goxml2json
. As the name says, this package turns XML into JSON, so one problem has been solved.
For the remaining task of quickly getting particular fields out of a JSON struct, I chose tomwright/dasel
, a package that provides an easy query language for JSON.
From this, I created an internal package named xml
with a single exported function:
func Get(xml []byte, daselQuery string) (string, error)
This way, I can throw any response I get from the FS API over to Get()
, along with a query string, and get the desired attribute value back. An example:
status, err xml.Get(body, ".fsapiResponse.status")
where .fsapiResponse.status"
is the query string that fetches the response status property from the XML document shown above.
Then I implemented another internal package named fsapi
. Based on a simple struct and a very basic HTTP client, the package implements every call I need to make to the API.
type Fsapi struct {
url string // The radio's API URL
pin string // The PIN can be set in the radio's UI
sid string // The session ID, returned by the CREATE_SESSION request
client *http.Client
}
func New(url, pin string) *Fsapi {
return &Fsapi{
url: url,
pin: pin,
// I use a custom client because the default one has no timeout
client: &http.Client{
Timeout: 5 * time.Second,
},
}
}
Then I packed all of the boilerplate code needed for calling the API and fetching a particular value from the result into a function.
func eventLoop(a *app, fs *fsapi.Fsapi) error {
for {
status := soundStatus(a)
switch status {
case sndStatSwitchedOn:
power, err := fs.GetPowerStatus()
if err != nil {
return fmt.Errorf("eventLoop: cannot get power status: %w", err)
}
if power == fsapi.PowerOff {
fs.SetPowerStatus(fsapi.PowerOn)
}
mode, err := fs.GetMode()
if err != nil {
return fmt.Errorf("eventLoop: cannot get mode: %w", err)
}
a.previousMode = mode
fs.SetMode(fsapi.AuxIn)
case sndStatSwitchedOff:
current, err := fs.GetMode()
if err != nil {
return fmt.Errorf("eventLoop: cannot get mode: %w", err)
}
if current != fsapi.AuxIn {
// Someone switched to another input while the Raspi player was playing
// Leave the radio alone
break
}
if a.previousMode != fsapi.AuxIn {
err := fs.SetMode(a.previousMode)
if err != nil {
return fmt.Errorf("eventLoop: cannot set mode: %w", err)
}
}
err = fs.SetPowerStatus(fsapi.PowerOff)
if err != nil {
return fmt.Errorf("eventLoop: cannot switch radio off: %w", err)
}
}
time.Sleep(1 * time.Second)
}
}
This function makes writing any of the calls to FS API a snap. See the GetMode
function as an example. GetMode()
detects the current mode the radio is in, which is basically the input that the radio is listening to: the DAB receiver, the AUX-IN socket, Bluetooth, etc.
func (f *Fsapi) GetMode() (mode string, err error) {
query := fmt.Sprintf("GET/netRemote.sys.mode?pin=%s&sid=%s", f.pin, f.sid)
mode, err = f.call(query, "value.u32")
if err != nil {
return "", fmt.Errorf("GetMode: cannot get mode: %w", err)
}
return mode, nil
}
The final code contains a number of methods with the same structure as GetMode
:
- build a URL query string
- invoke
call()
with the URL query and an XML result query - get the result back
You may have noticed the uncommon HTTP query string format. The FS API does not use HTTP verbs (GET, POST, etc). Rather, the actions (GET, SET, CREATE_SESSION) are part of the API path. Not quite mainstream, but works.
Similar to GetMode
, I implemented SetMode
, GetPowerStatus
, and SetPowerStatus
. With this set of function, the program is able to query both the power status and the selected input of the radio, and change the input to Aux-in when playback starts, and back to the previous input when playback stops. It is even possible to get the radio out of standby and put it back to standby as needed.
Hear the music playing
Now that the app is able to remote-control the radio, it needs a way of detecting when music starts or stops playing. As I mentioned earlier, I planned to use a Spotify client and an AirPlay client, both of which provide no way of detecting their status.
Ok, then how about querying the sound card itself? It turned out that there is no system call available, and no system event to subscribe to, in order to observe the sound card starting or stopping playback.
But remember, Raspbian OS is a Linux OS, and Linux is a Unix variant, and Unix has a tremendous passion for treating everything as a file. Yes, it is even possible to access the sound card like a file. Reading a sound card's status is as easy as reading a status
file at this path:
/proc/asound/card<N>/pcm0p/sub0/status
Small problem: the <N>
in card<N>
is a number, but which one? After some more research, I found out that the sound card I use identifies itself as “hifiberry”. Reading the contents of file
/proc/asound/cards
returns a list of available sound cards:
$ cat /proc/asound/cards
0 [sndrpihifiberry]: RPi-simple - snd_rpi_hifiberry_dac
snd_rpi_hifiberry_dac
1 [vc4hdmi ]: vc4-hdmi - vc4-hdmi
vc4-hdmi
The digit right before the sound card name is the number of the sound card. So the app only needs to look for a string that matches the regexp (\d).*hifiberry
. With the sound card number at hand, querying the status of the sound card is a cakewalk:
cat /proc/asound/card0/pcm0p/sub0/status
state: RUNNING
owner_pid : 10404
trigger_time: 625908.006374175
tstamp : 637258.409885386
delay : 8750
avail : 56786
avail_max : 57062
-----
hw_ptr : 500550352
appl_ptr : 500559102
Phew. Problem solved.
This is the code of my third internal package, hifiberry
, with a single exported function IsPlaying
that returns the sound card status. Again, I removed all error handling here for brevity.
const (
pac = "/proc/asound/cards"
stat = "/proc/asound/card%d/pcm0p/sub0/status"
berryRe = `(\d).*hifiberry`
)
// getCardNumber determines the card number of the hifiberry sound card.
func getCardNumber() (int, error) {
cards, _ := ioutil.ReadFile(pac)
re := regexp.MustCompile(berryRe)
matches := re.FindStringSubmatch(string(cards))
card, _ := strconv.Atoi(matches[1])
return card, nil
}
// IsPlaying reads the status of the hifiberry sound card.
// 0 = idle, 1 = playing
func IsPlaying() (bool, error) {
num, _ := getCardNumber()
status, _ := ioutil.ReadFile(fmt.Sprintf(stat, num))
idx := strings.Index(string(status), "RUNNING")
return idx > -1, nil
}
This is not much of an effort, or is it? The only downside is that the status has to be polled regularly. It is not possible to watch files in /proc
for change, because they are not real files. The data “inside” such a pseudo-file is generated only upon request; that is, when reading the file. However, the interval for polling the sound card can easily be a whole second or longer, and the radio would still switch to aux-in “almost immediately” from a human brain's perspective.
A mini state machine
The remaining task is to bring radio statuses and sound card statuses together, to have the radio switch to aux-in when the Raspi starts streaming some sound, and switch back when the sound stops.
If you have to deal with statuses and status changes and the actions resulting from those changes, do not jump right into coding if-else cascades, or you quickly get lost. Instead, consider setting up a state machine model first.
In its simplest form, a state machine model is a table with these columns:
- The current status of the system
- An event that comes in
- The action resulting from the combination of current status and incoming event
- The new status of the system after the action
The 3sixty radio has only a few statuses that are relevant for this app:
- Powered off
- On, input is aux-in
- On, input is anything else but aux-in
And there are even fewer incoming events:
- Music starts
- Music stops
Based on this, we can now create a state transition table:
Event | Current status | Action | New status |
---|---|---|---|
Music starts | Off | switch on & to aux-in | on, aux-in |
Music starts | On, anything but aux-in | switch to aux-in | on, aux-in |
Music starts | On, aux-in | do nothing | on, aux-in-continued |
Music stops | On, aux-in-continued | power off | off |
Music stops | On, aux-in | switch back to previous input | on, anything but aux-in |
Music stops | On, switched to a different input | leave radio alone | unchanged |
Read this as: “When Event and Current status apply, then perform Action, and this results in New status.”
The last row is probably worth a quick note: here, someone switched the radio to a different input while the Raspi was streaming music to the radio. In this case, the app should stop playback on the raspberry. Unfortunately, the app cannot tell the soundcard to stop. It would need to stop the AirPlay or Spotify service, whichever is playing at that moment. However, so far I found no way of sending those services a stop signal. Restarting the services would be a brute-force solution that I would consider only as a last resort. For now, I leave this requirement open.
The table is simple enough to turn it into a switch block of low complexity. It is not necessary to implement the exact mechanics of a state machine in code. And because the sound card needs to be regularly polled anyway, there is even no need for goroutines. All that's needed is a loop that queries the sound card once every second, then checks the radio status and runs the appropriate action. A one-second interval is enough, for the reasons mentioned earlier. The KISS principle at work - “keep it simple stupid”.
func eventLoop(a *app, fs *fsapi.Fsapi) error {
for {
status := soundStatus(a)
switch status {
case sndStatSwitchedOn:
power, err := fs.GetPowerStatus()
if err != nil {
return fmt.Errorf("eventLoop: cannot get power status: %w", err)
}
if power == fsapi.PowerOff {
fs.SetPowerStatus(fsapi.PowerOn)
}
mode, err := fs.GetMode()
if err != nil {
return fmt.Errorf("eventLoop: cannot get mode: %w", err)
}
a.previousMode = mode
fs.SetMode(fsapi.AuxIn)
case sndStatSwitchedOff:
current, err := fs.GetMode()
if err != nil {
return fmt.Errorf("eventLoop: cannot get mode: %w", err)
}
if current != fsapi.AuxIn {
// Someone switched to another input while the Raspi player was playing
// Leave the radio alone
break
}
if a.previousMode != fsapi.AuxIn {
err := fs.SetMode(a.previousMode)
if err != nil {
return fmt.Errorf("eventLoop: cannot set mode: %w", err)
}
}
err = fs.SetPowerStatus(fsapi.PowerOff)
if err != nil {
return fmt.Errorf("eventLoop: cannot switch radio off: %w", err)
}
}
time.Sleep(1 * time.Second)
}
}
Cross-compiling
How to get all this up and running on the Raspi? I used a Mac to write the code, so I needed a way to cross-compile the code to Linux and move the binary over to the Raspi without too much hassle. Well, cross-compiling with Go is easy as pie, and since the result is a single, self-contained binary, a simple scp
command is sufficient to move the binary over. This can be done with a one-liner:
GOOS=linux GOARCH=arm go build && scp 3sixty [email protected]:/home/pi/
On the Raspi, I only had to move the binary over to /usr/local/bin
(sudo required) and set up systemd
to run the app as a service.
The steps of setting up systemd
are out of scope of this article, but you can read the steps in the readme of the project on GitHub:
christophberger/auxin-switcher-for-3sixty (v0.1.0)
Happy coding!