A “classic” problem

5 minute read

My initial problem was fairly simple: I like to listen to the “Radio Classique” radio (it’s a French radio), and with my Google Nest Hub set to accept commands both in English and French, all I had to do is “OK Google, joue Radio Classique sur Living Room”, in French, with a very French accent for “living room”, which is the name of my Chromecast Audio, et voilà.

To perform this, Google has partnered with a service called TuneIn.
But starting a few months ago, the Google Assistant stopped accepting to play Radio Classique. It still plays “TSF Jazz”, another French station I like, but Radio Classique is now broken for good apparently.

That’s fine, since

  • a. TuneIn has an iPhone app that supports Chromecast
  • b. Apple music also has TuneIn, and we have an old Airport Express hooked up the amplifier

However, solution a is long, since TuneIn (a) has up to two 30s non-skip ads that you have to listen to on the phone because you can’t cast the ads, and (b) now tries to insert ads approximately every 5 minutes, but usually just inserts the “TuneIn” jingle.

To be clear, I have nothing against ads, they provide a service and if I want it for free, it’s ok to serve me ads, or pay the premium subscription. Additionally, the jingle every 5 minutes probably was a bug, but that’s also a great opportunity for a little project.

So here’s the plan:

  • Google Assistant supports IFTTT
  • IFTTT can call a webhook
  • A webhook on my server could talk to one of the Raspberry Pis in my apartment (for example, the gateway one for my AC project)
  • VLC can run on a Raspberry Pi, and supports Chromecast output

IFTTT

The setup is really easy, you can associate your account to you Google Assistant account from the Google Home app, and create up to 2 triggers with a free IFTTT account.

I chose to use POST, with a X-AUTH header to make sure IFTTT was the only one playing music in my home through this hook :-)

The IFFT scene name

The IFFT details

The receiving end

Exposed to the internet

On my internet-facing server, a quick PHP will simple check the Header, the URL, and make a quick call to the tunneled Raspberry pi.

The actual full code is far too ugly to share, but once the X-Auth header verified, it all comes down to

// For example http://10.0.0.12/radio/radio_classique
// Where 10.0.0.12 is the tunneled raspberry pi
$url = "http://" . $this->config["inside_ip"] . self::INSIDE_RADIO_PLAY_FRAGMENT . $name;

// Todo, check error
$ch = curl_init($url);

// Post
curl_setopt($ch, CURLOPT_POST, true);

// Return response instead of printing.
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$result = curl_exec($ch);

$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status != 200) {
    curl_close($ch);
    http_response_code($status);
    throw new Exception("error, url was \"". $url ."\" status=" . $status . "\nResult=" . $result);
}

curl_close($ch);

At home

On the Raspberry Pi, I like to use Go, and implementing an HTTP service is really easy.

Essentially, I need to keep the current state

  • Playing
  • Not Playing
  • In error state (though I don’t really know what that means)

Launching the media

First I looked into pulseaudio and Mkchromecast, but in the end, the easiest way was to give the Chromecast’s IP address to VLC.

In order to do that, either you could assign it a fixed IP address on your modem/router/DHCP server, or you could use the output of avahi-resolve.

First, find your Chromecast’s or Chromecast Audio’s hostname, with avahi-browse; you will need to go fish in the output, there may be quite a few things advertising on mdns out there.

$ avahi-browse -ar
[...]
=  wlan0 IPv4 Chromecast-Audio-7xxxxxxx _googlecast._tcp     local
   hostname = [7xxx-xxxx-xxxx-xxxx.local]
   address = [192.168.86.24]
   port = [8009]
   txt = ["rs=Casting: radioclassique-high.mp3" "nf=1" "bs=XXXXXXXX" "st=1" "ca=XXXXX" "fn=Living Room" "ic=/setup/icon.png" "md=Chromecast Audio" "ve=05" "rm=XXXXXXX" "cd=XXXXXXXX" "id=XXXXXXXXXXX"]
[...]

The 7xxx-xxxx-xxxx-xxxx.local is the interesting part here.
All we have to do now is create a script like play_on_chromecast.sh

#! /bin/sh

CHROMECAST_NAME="7xxx-xxxx-xxxx-xxxx.local"
LOG_FILE="$HOME/log/vlc.log"

/usr/bin/cvlc "$1" --network-caching 2000 --sout "#chromecast" --sout-chromecast-ip=$(avahi-resolve-host-name $CHROMECAST_NAME | awk '{ print $2 }') --demux-filter=demux_chromecast --play-and-exit --gain=0.8 2>&1 | while read i; do echo "[$(date '+%Y-%m-%d %H:%M:%S') - $$] $i"; done  >> $LOG_FILE

And now we can call play_on_chromecast.sh http://address.of.the.stream. Should the Chromecast be offline, it will pass an empty argument to for --sout-chromecast-ip= and VLC will exit with an error, which we can catch fairly easily.

Killing it softly

In order to launch that process, I’m using Go’s cmd.Start(), and if I need to stop the process I use OS.Process.Kill().

This is when I discovered (and really, I should have known this) that killing a shell script does not kill its children. And VLC kept running, which prevents from switching to a different radio station. Not cool.

Here are the alternatives I considered:

  • Launching VLC directly
    • Getting the Chromecast’s IP address from another independent shell script
    • Resolving the Chromecast’s address with a library, and maintaining the status (eg. “Chromecast not found”)
  • Using the “ptrap” solution that Googling “bash script kill children” leads to

I went with the first one.

mdns and golang

I easily found a couple of libraries to do this

  • https://github.com/hashicorp/mdns
  • https://github.com/pion/mdns

I tried the first one and didn’t make it work immediately (that was probably me though), so tried the second one, and that worked very well.

I built a “controller” package, again the code not really good enough to share, but here’s the relevant bit to the mdns part. It notifies the controller in case of a change (change of IP, or found/not found state).

package controller

import (
	"context"
	"net"
	"time"

	"golang.org/x/net/ipv4"

	"github.com/golang/glog"
	"github.com/pion/mdns"
)

const (
	tickerPeriod = 10 * time.Second
	queryTimeout = 3 * time.Second
)

func (c *Controler) mDNSStart() error {
	addr, err := net.ResolveUDPAddr("udp", mdns.DefaultAddress)
	if err != nil {
		return err
	}

	l, err := net.ListenUDP("udp4", addr)
	if err != nil {
		return err
	}

	c.mdnsConn, err = mdns.Server(ipv4.NewPacketConn(l), &mdns.Config{})
	if err != nil {
		return err
	}

	go func() {
		defer func() {
			c.mdnsStopped <- struct{}{}
		}()
		t := time.NewTicker(tickerPeriod)
		foundString := "notAnIp"
		glog.Info("starting mDns loop")
		for {
			select {
			case <-t.C:
				ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
				_, src, err := c.mdnsConn.Query(ctx, c.chromecastName)
				cancel()
				if err != nil {
					if ctx.Err() == context.DeadlineExceeded {
						c.c <- command{
							op: chromecastNotFound,
						}
						if foundString != "" {
							glog.Info("chromecast not found")
							foundString = ""
						}
						break
					}
					glog.Errorf("resolving mdns name= %v", err)
					break
				}
				if src.String() != foundString {
					foundString = src.String()
					glog.Infof("found chromecast at ip %s", foundString)
				}
				c.c <- command{
					op:     chromecastFound,
					ipAddr: src.String(),
				}
			case <-c.mdnsStop:
				t.Stop()
				glog.Info("exiting mDns loop")
				return
			}
		}
	}()
	return nil
}

func (c *Controler) mDNSStop() error {
	c.mdnsStop <- struct{}{}
	<-c.mdnsStopped
	return c.mdnsConn.Close()
}

More to come a little later.