Side Project: Entrance Song
21 Mar 2019I’ve been working on a side project inspired by a professional wrestling and a blog post I read about a year ago.
I can’t remember the blog’s name, but it described how the local system administrator setup their Sonos speakers to play a theme song whenever an employee entered the office. They used DHCP to detect when an employee’s mobile phone connected to the corporate WiFi and then matched the MAC address to an employee and their chosen entrance song.
The whole idea was pretty amazing and it made me think of my favorite thing about professional wrestling: getting a chance to pick an entrance song for when you enter the ring. It’s something I give a lot of thought to.
This is essentially how I wanted to feel walking in my front door every day after work and also let my friends set their own entrance theme songs.
The implementation used in the blog used tcpdump
and some bash
scripts, but I wanted
to write a version in Python.
Here’s the link to the GitHub repo.
Sniffing the DHCP Transactions
Detecting when someone enters my house is done using DHCP, which is a weird protocol. It’s based on UDP (and implicitly, IP) but it’s used for getting an IP address lease so the device likely doesn’t have an IP address. The solution for this is to just broadcast the DHCP request and response to everyone on the network. The end result is a very noisy transaction, but that makes it easy to sniff.
- A client sends a DHCPDISCOVER packet broadcast to
255.255.255.255
- A nearby DHCP server detects and responds with a DHCPOFFER to
255.255.255.255
containing an IP address - The client device then sends back a DHCPREQUEST packet (again, to
255.255.255.255
) requesting this offered IP address. - Finally, the DHCP server sends an acknowledgement DHCPACK message, this time just as a unicast packet.
The whole thing is like walking into a hotel lobby and shouting at everyone present until the conceirge hears you and gives you a room key.
For our entrance song to detect a device, we just need to listen for that DHCPREQUEST packet, since it’s being broadcast everywhere.
I’m using Scapy, a very awesome Python library, to sniff my network for DHCP traffic. Scapy is pretty well known for doing cyber security stuff like crafting packets and building sniffers. It’s very easy to write a basic sniffer with a callback.
from scapy.all import Ether, DHCP, sniff
sniff(prn=self.dhcp_monitor_callback, filter='udp and (port 67 or 68)', store=0)
def dhcp_monitor_callback(pkt):
"""Callback for DHCP requests"""
if not pkt.haslayer(DHCP):
return
if pkt[DHCP].options[0][1] != 3:
return
mac_addr = pkt[Ether].src
logging.info('DHCP request from %s', mac_addr)
Here, we’re sniffing for UDP on port 67 and 68 and passing the packet to a callback
function. In the callback, we’re making sure it’s actually a DHCP packet and that it’s
a DHCPREQUEST packet (the options
are a list of tuples of DHCP key-value options. The first
tuple is the type of DHCP message, here we’re using type 3 for DHCP requests).
After verifying that we have a DHCP request, we can easily pull the MAC address out of the Ethernet layer. We’ll use this later to compare to my friend’s names in a SQLite database and figure out what song to play.
device = data.get_device_by_mac_addr(mac_addr)
if not device:
logging.info('This is not a device I know about... Adding it to the database')
data.insert_device(mac_addr)
return
if device.owner.song:
song = device.owner.song
logging.info('%s is about to enter (%s)! playing %s by %s', \
device.owner.name,
device.friendly_name,
song.title,
song.artist)
else:
logging.info('Device owner %s does not have a song. Doing nothing.', device.owner.name)
return
Meanwhile the SQLAlchemy models are pretty standard classes that represent a Device
,
an Owner
, and a Song
and the foreign keys between them. The data.get_device_by_mac_addr
and data.insert_device
are just shortcut functions for querying and inserting data into
my database.
Playing Music
Once we’ve determined who’s entered my front door, we need to play their theme song. I have a Spotify premium account, so I can literally play anything. Spotify provides a pretty good web-based REST API and there’s a Python wrapper for it called Spotipy.
Spotipy hasn’t been updated in a while and the original author doesn’t seem to be merging pull requests. There are a few issues with the library so I forked it and patched them up myself.
Authenticating my premium Spotify account using Spotipy is a little weird. You have to register for their developer program and create a new project. After that, you need to allow the project to access your Spotify account with the appropriate level of access.
For playing the music, we want to do the following:
- Look up who entered my front door and see if they have an entrance song.
- If so, save the current Spotify playback so we can resume it later.
- Fade out the current song.
- Set the volume high and blast that entrance song for the next 45 seconds or so
- Fade out the entrance song
- Fade in the previously played music prior to the entrance and return everything back to normal
Sounds easy enough. There are a few interesting problems to solve though. Spotify doesn’t have a way to play just a section of music and then stop. We can fake this out by starting the music in a new thread, sleeping in that thread for the entrance duration (usually about 45 seconds to a minute) and then stop the music right before ending the thread.
class MusicThread(Thread):
"""A thread to start music and sleep. This is a cheap way to implement playing
a duration of a song since the Spotify API doesn't include that.
"""
def __init__(self, sp_context, mp_context, uri, position_ms, duration=45,
device_id=None):
"""Constructor
Args
sp_context - The spotify context
mp_context - music player context
uri - The URI to play
position_ms - The position in ms to start from
duration - The duration to play, in seconds
device_id - The device to play on, or None to play on the default device
"""
super().__init__()
self.sp = sp_context
self.mp = mp_context
self.uri = uri
self.position_ms = position_ms
self.duration = duration
self.device_id = device_id
def run(self):
"""Runs the thread.
Starts playback on the track, sleeps, and then fades out the track.
"""
logging.info('Starting playback in new thread')
try:
self.sp.pause_playback()
except SpotifyException as e:
# This often happens if there is no current active device. We'll assume there's
# device_id being used. The next try/catch block will handle it if not.
pass
try:
self.sp.start_playback(device_id=self.device_id, uris=[self.uri],
position_ms=self.position_ms)
except SpotifyException as e:
logging.error(e)
return
self.mp.set_volume(self.mp.default_volume)
logging.info('Putting the thread to sleep for %s seconds', self.duration)
sleep(self.duration)
logging.info('Stopping playback')
# Get the currently playing tack to be sure we're stopping this track and not
# someone else's.
current_track = self.sp.currently_playing()
try:
uri = current_track['item']['uri']
if uri == self.uri:
self.mp.fade_out()
self.sp.pause_playback()
else:
logging.info('Attempted to stop song %s but it\'s not playing', self.uri)
finally:
return
Another problem is figuring out how to handle if someone shows up while another person’s entrance song is already playing. Should the previous person’s music be cut off? That’s disrespectful. Also the threads might start fighting over the music controls and we’d get into a weird state.
I thought about writing some state machine code to keep the threads in sync but there’s
a much more elegant solution: using Python’s Queue
class. These things deserve way
more credit than they get. They allow you to have a thread safe way to manipulate a
collection. In my case I want to add things without blocking (i.e. someone walks in the door
so I need to queue up their song) while removing things with blocking (i.e. making
Spotify play the song but block until the song is complete before starting the next one).
No awful state machine required!
Here’s the main loop for the music player:
def player_main(self):
logging.info('Starting music player')
while True:
uri, start_minute, start_second, duration = self.song_queue.get(True)
logging.info('Found a song on the queue!')
logging.info('Playing %s at %d:%d duration %d', uri, start_minute, start_second,
duration)
self.save_current_playback()
t = self._play_song(uri, start_minute, start_second, duration)
logging.info('Waiting for song to end...')
t.join()
self.restore_playback()
logging.info('Song over... waiting for the next song on the queue')
The get
method will block the thread until it sees something on the queue. The
join
method on the MusicThread
will also block the main thread until the song
completes. The MusicPlayer
itself is a Thread
so the whole thing doesn’t block
the rest of my application while calling these methods.
My Scapy DHCP sniffing code just needs to drop songs on the queue and they will get picked up by the music player thread.
Results and Conclusion
It’s honestly pretty awesome to walk into your own house and have an entrance song play. I now get pumped up as I enter my door and start waving my hands to an imaginary crowd. I’m not sure what my neighbors think of this.
Other Things
- I use some NetGear wifi extenders in my house. It turns out, they change a device’s
MAC address by swapping the first 3 bytes to
02:0f:b5
. To get around this, I added a--virtualmac
option that attempts to fall back to looking in the database for just the last 3 bytes of a MAC address. - I recommend using a static IP on the device hosting the Python code. I put this code on a Pine64 board I had sitting around and plugged it directly into my wifi router’s wired ethernet switch. There’s no reason it couldn’t have just run on a regular desktop or laptop, but I needed an excuse to use the Pine64.
- It works but I’ve noticed a few bugs, especially when switching playback between output devices. I might get around to fixing them.
Future Work
- I also own some Philips Hue lights and they are a lot of fun. I want to bring those into the entrance by having them dim as the music fades and then bringing them up at a certain point in the music.
Appendix: What my friends picked as their entrance theme songs
- Me - Still Fly (cover) by The Devil Wears Prada
- Alex - So Fresh, So Clean by OutKast
- Yaro (@hokietron) - Bumble Bee Tuna Song by Mephiskapheles
- Samantha - Hide and Seek by Imogen Heap (but at the 2’52” mark where all the memes happen)
- Rob - Imperial March by John Williams
- Suzie (@California_Suz) - Cotton Eye Joe by Rednex
- Smitty - Mah Nà Mah Nà by Piero Umilian
- Patrick - Holy Diver by Dio
- Default song for unknown people - Frolic by Luciano Michelini (better known as the “Curb Your Enthusiasm” theme)