10 Apr 2017 : Push Button, Receive Hamilton

Four Amazon Dash buttons aligned vertically on a wooden wall with musician names written on them on sticky labels.

Amazon Dash is a neat idea. When you’re running low on teabags or detergent or whatever, you push a little button and Amazon sends you more. 

Sonos speakers are also a neat idea. They’re speakers that sit on your wifi network, and you can stream music to them from your phone or Spotify or whatever. They have a nice, low-drama interface and mostly just work. We’ve got two.

A while back, my excellent friend and crossword buddy Rob showed me how he’d set up a Dash button to start and stop his wife’s favourite radio station on their Sonos. This was extremely relevant to my interests! My kid has very strong feelings about the Moana soundtrack and it plays in our house almost constantly. It occurred to me that if she had a way of starting it herself, without needing an adult, then that would be the four year old equivalent of getting car keys or internet access for the first time. Who can imagine having such independence and power! So I made her a Moana button.

Here’s how it works: whenever you push the button, it generates ARP traffic on your wifi network. You can set up a server on the network – perhaps a Raspberry Pi that’s been looking for a purpose! – and use the scapy library to listen for your button’s MAC address. Then you can take some action whenever you see that it’s been pushed. For example:

import scapy.all as scapy

class Sniffer(Thread):
  def __init__(self):
    super(Sniffer, self).__init__()

  def run_forever(self):
    """Sniff network traffic. Call arp_cb() against any arp packets."""
    scapy.sniff(prn=self.arp_cb, filter="arp", store=0, count=0)

  def arp_cb(self, packet):
    """Check whether this is a button we know and handle it."""
    if not packet.haslayer(scapy.ARP):
      return
    if packet[scapy.ARP].op != 1:
      return
    mac_address = packet[scapy.ARP].hwsrc

    # An arp happened! Check if this is one of the MAC addresses you care about
    # and do whatever you've configured that button to do.
    do_some_exciting_thing(mac_address)

In this case, the exciting thing we want the button to do is play the Moana Soundtrack!

I’d planned to stream the album to the Sonos from Spotify, but I soon found out that that wasn’t going to work: Sonos recently changed its encryption, and the soco library doesn’t have access to music services any more. They’re working on it. In the meantime, I decided we cared enough to buy a copy of the album in MP3 format. Even then, there’s a difficulty: you can’t upload files directly to the Sonos; it needs to stream them over http. So I added a little webserver to serve the directories of MP3s.

Python’s SimpleHTTPRequestHandler is perfect for serving a directory on a separate thread.

from threading import Thread
from SimpleHTTPServer import SimpleHTTPRequestHandler
from SocketServer import TCPServer

class HttpServer(Thread):
  """Tiny webserver."""

  def __init__(self, port):
    super(HttpServer, self).__init__()
    self.daemon = True
    handler = SimpleHTTPRequestHandler
    self.httpd = TCPServer(("", port), handler)

  def run(self):
    """Start the webserver."""
    self.httpd.serve_forever()

The soco library can discover all of the Sonos devices on the network and modify their queues. Whenever the button’s pressed, the code makes a list of all the MP3s in the directory and figures out what their URLs will be when the local webserver serves them. Then it adds each of them to the Sonos’s queue.

# Look at all the files in the specified directory and add their URIs.
mp3s = []
files = os.listdir(music_dir)
for filename in files:
  if filename.endswith(".mp3"):
    mp3s.append(os.path.join(webserver_host_port, music_dir,
                             urllib.pathname2url(filename)))

self.player.play(sorted(mp3s), sonos_zone)
from threading import Thread
import soco

class Player(Thread):
  def __init__(self):
    self.zones = {}
    for zone in soco.discover():
      self.zones[zone.player_name] = zone

  def play(self, urls, zone_name):
    zone = self.zones[zone_name]
    zone.clear_queue()
    for url in urls:
      zone.add_uri_to_queue(url)
    zone.play_from_queue(0)

Finally, I added some logic to pause and unpause if the button is pressed multiple times, rather than starting the album from scratch each time.

  def toggle(self, zone_name):
    """Pause or unpause the zone."""
    zone = self.zones[zone_name]
    info = zone.get_current_transport_info()
    state = info['current_transport_state']
    if state == "PLAYING":
      self.pause(zone_name)
    else:
      self.unpause(zone_name)

  def pause(self, zone_name):
    zone = self.zones[zone_name]
    zone.pause()

  def unpause(self, zone_name):
    zone = self.zones[zone_name]
    zone.play()

Ms 4 loved it. I thought she’d just hammer on the button constantly, but she’s very respectful of this new power she’s been granted, and she uses it at every opportunity. Like “I’m going upstairs to get my bear. I better stop the music.” presses pause with a small smile.

I had some other buttons lying around, so I added them to our home-made jukebox. Everyone needs a Hamilton button in their living room :-)

The full code and some documentation, including how to find the MAC addresses for your Amazon Dash buttons is at http://github.com/whereistanya/sonos-buttons

Thank you Rob for the idea and for letting me steal your scapy code to start off with!

Comments