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.

Image: Raspi connected to Teufel 3sixty

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.

Image: 3sixty

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:

EventCurrent statusActionNew status
Music startsOffswitch on & to aux-inon, aux-in
Music startsOn, anything but aux-inswitch to aux-inon, aux-in
Music startsOn, aux-indo nothingon, aux-in-continued
Music stopsOn, aux-in-continuedpower offoff
Music stopsOn, aux-inswitch back to previous inputon, anything but aux-in
Music stopsOn, switched to a different inputleave radio aloneunchanged

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!

Get the Applied Go Weekly Newsletter

Stay up to date in the world of Go. Get a hand-curated, commented list of articles and projects, and more, every week right in your inbox.

By subscribing, you agree to the privacy policy.

Powered by Buttondown.

Share
Like this article? Tell your friends!