Created
March 16, 2017 11:14
Star
You must be signed in to star a gist
Python source code: Raspberry Pi streaming radio player with OLED display
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from evdev import InputDevice,ecodes,KeyEvent | |
import subprocess32 as subp | |
import select | |
import re | |
import sys | |
import time | |
import logging | |
import logging.handlers | |
import os.path | |
import argparse as args | |
import textwrap | |
from luma.oled.device import sh1106 | |
from luma.core.serial import spi | |
from luma.core.render import canvas | |
from PIL import ImageFont | |
# URL must be last entry in command line list | |
Media = {'KEY_KP7' : ['Classical',False,['mplayer','-playlist','http://stream2137.init7.net/listen.pls']], | |
'KEY_KP8' : ['Jazz',False,['mplayer','-playlist','http://stream2138.init7.net/listen.pls']], | |
'KEY_KP9' : ['WMHT',False,['mplayer','http://wmht.streamguys1.com/wmht1']], | |
'KEY_KP4' : ['Classic 1k',True,['mplayer','-playlist','http://listen.radionomy.com/1000classicalhits.m3u']], | |
'KEY_KP5' : ['Love',True,['mplayer','-playlist','/home/pi/Playlists/LoveRadio.m3u']], | |
'KEY_KP6' : ['WAMC',False,['mplayer','-playlist','http://playerservices.streamtheworld.com/pls/WAMCFM.pls']], | |
'KEY_KP1' : ['60s',True,['mplayer','-playlist','http://listen.radionomy.com/all60sallthetime-keepfreemusiccom.m3u']], | |
'KEY_KP2' : ['50-70s',True,['mplayer','-playlist','http://listen.radionomy.com/golden-50-70s-hits.m3u']], | |
'KEY_KP3' : ['Soft Rock',True,['mplayer','-playlist','http://listen.radionomy.com/softrockradio.m3u']], | |
'KEY_KP0' : ['Zen',False,['mplayer','http://iradio.iceca.st:80/zenradio']], | |
'KEY_KPDOT' : ['Ambient',False,['mplayer','http://185.32.125.42:7331/maschinengeist.org.mp3']], | |
'KEY_KPMINUS' : ['Relaxation',True,['mplayer','-playlist','/home/pi/Playlists/Frequences-relaxation.m3u']], | |
'KEY_KPPLUS' : ['Plenitude',True,['mplayer','-playlist','/home/pi/Playlists/Radio-PLENITUDE.m3u']] | |
} | |
# these keycode will be fed directly into mplayer | |
Controls = {'KEY_KPSLASH' : '//////', | |
'KEY_KPASTERISK' : '******', | |
'KEY_VOLUMEUP' : '*', | |
'KEY_VOLUMEDOWN' : '/' | |
} | |
# stream title keywords that trigger muting | |
MuteStrings = ['TargetSpot', # common Radionomy insert | |
'Intro of','Jingle','*bumper*', # Radio-PLENITUDE | |
'[Unknown]','Advert:','+++','---','SRR','Srr', # softrockradio | |
'PEACE LK1','PEACE J1'] # Frequences-relaxation | |
# Set up default configuration | |
CurrentKC = 'KEY_KP7' # default stream source | |
MuteDelay = 6.5 # delay before non-music track; varies with buffering | |
UnMuteDelay = 9.0 # delay after non-music track | |
MixerChannel = 'PCM' # default amixer output control | |
MixerVol = '30' # mixer gain | |
RestartDelay = 10 # delay after stream failure | |
Contrast = 255 # OLED brightness setting | |
# Set up command line parsing | |
cmdline = args.ArgumentParser(description='Streaming Radio Player',epilog='KE4ZNU - http://softsolder.com') | |
cmdline.add_argument('Loc',help='Location: BR1 BR2 ...',default='any',nargs='?') | |
args = cmdline.parse_args() | |
# Set up logging | |
LogFN = '/home/pi/Streamer.log' | |
LogFmt = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') | |
LogHandler = logging.handlers.RotatingFileHandler(LogFN,backupCount=9) | |
LogHandler.setFormatter(LogFmt) | |
logger = logging.getLogger('StreamLog') | |
logger.addHandler(LogHandler) | |
logger.setLevel(logging.INFO) | |
# Tweak config based on where we are | |
Location = vars(args)['Loc'].upper() | |
logger.info('Player setup for: ' + Location) | |
if Location == 'BR1': | |
CurrentKC = 'KEY_KPDOT' | |
MixerVol = '5' | |
Contrast = 1 | |
elif Location == 'BR2': | |
MuteDelay = 4.5 | |
UnMuteDelay = 8.5 | |
MixerVol = '5' | |
Contrast = 1 | |
elif Location == 'LR': | |
MixerVol = '40' | |
CurrentKC = 'KEY_KPPLUS' | |
# set up event inputs and polling objects | |
# This requires some udev magic to create the symlinks | |
k = InputDevice('/dev/input/keypad') | |
k.grab() | |
kp = select.poll() | |
kp.register(k.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
# if volume control knob exists, then set up its events | |
VolumeDevice = '/dev/input/volume' | |
vp = select.poll() | |
if os.path.exists(VolumeDevice): | |
logger.info('Volume control device: %s',VolumeDevice) | |
v = InputDevice(VolumeDevice) | |
v.grab() | |
vp.register(v.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
# set up file for mplayer output tracing | |
lw = open('/home/pi/mp.log','w') # mplayer piped output | |
# set the mixer output low enough that the initial audio won't wake the dead | |
subp.call(['amixer','-q','sset',MixerChannel,MixerVol]) | |
if Media[CurrentKC][1]: | |
subp.call(['amixer','-q','sset',MixerChannel,'mute']) | |
Muted = True # squelch anything before valid track name | |
logger.info('Audio muted at startup') | |
else: | |
subp.call(['amixer','-q','sset',MixerChannel,'unmute']) | |
Muted = False # allow early audio | |
logger.info('Audio unmuted at startup') | |
# Set up OLED display | |
font1 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',14) | |
font2 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',11) | |
wrapper = textwrap.TextWrapper(width=128//font2.getsize('n')[0]) | |
StatLine = 0 | |
DataLine = 17 # allow one line for weird ascenders and accent marks | |
LineSpace = 16 | |
serial = spi(device=0,port=0) | |
device = sh1106(serial) | |
device.contrast(Contrast) | |
def ShowStatus(L1=None,L2=None,L3='None'): | |
with canvas(device) as screen: | |
screen.text((1,StatLine),Media[CurrentKC][0][0:11], | |
font=font1,fill='white') | |
screen.text((127-(4*font1.getsize('M')[0] + 2),StatLine),'Mute' if Muted else ' ', | |
font=font1,fill='white') | |
screen.text((1,DataLine),L1, | |
font=font2,fill='white') | |
screen.text((1,DataLine + 1*LineSpace),L2, | |
font=font2,fill='white') | |
screen.text((1,DataLine + 2*LineSpace),L3, | |
font=font2,fill='white') | |
ShowStatus('Startup in ' + Location, | |
'Mixer: ' + MixerChannel + ' = ' + MixerVol, | |
'Contrast: ' + str(Contrast)) | |
# Start the player with the default stream, set up for polling | |
logger.info('Starting mplayer on %s -> %s',Media[CurrentKC][0],Media[CurrentKC][-1][-1]) | |
p = subp.Popen(Media[CurrentKC][-1], | |
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT) | |
pp = select.poll() # this may be valid for other invocations, but is not pretty | |
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
#-------------------- | |
#--- Play the streams | |
while True: | |
# pluck next line from mplayer and decode it | |
if [] != pp.poll(10): | |
text = p.stdout.readline() | |
if 'Error: ' in text: # something horrible went wrong | |
lw.write(text) | |
lw.flush() | |
logger.info('Unsolvable problem! ' + text) | |
logger.info('Exiting') | |
LogHandler.doRollover() | |
logging.shutdown() | |
sys.exit('Exit streamer on mplayer error --' + text) | |
if 'ICY Info: ' in text: # these lines may contain track names | |
lw.write(text) | |
lw.flush() | |
trkinfo = text.split(';') # also splits at semicolon embedded in track name | |
# logger.info('Raw split line: %s', trkinfo) | |
for ln in trkinfo: | |
if 'StreamTitle' in ln: # this part probably contains the track name | |
NeedMute = False # assume a listenable track | |
trkhit = re.search(r"StreamTitle='(.*)'",ln) # extract title if possible | |
if trkhit: # regex returned valid result? | |
TrackName = trkhit.group(1) # get string between two quotes | |
else: | |
logger.info('Regex failed for line: [' + ln + ']') | |
TrackName = 'Invalid StreamTitle format!' | |
logger.info('Track name: [%s]', TrackName) | |
if Media[CurrentKC][1] and ( (len(TrackName) == 0) or any(m in TrackName for m in MuteStrings) ) : | |
NeedMute = True | |
if NeedMute: | |
if Media[CurrentKC][1] and not Muted: | |
time.sleep(MuteDelay) # brute-force assumption about buffer leadtime | |
subp.call(['amixer','-q','sset',MixerChannel,'mute']) | |
Muted = True | |
logger.info('Track muted') | |
else: | |
if Muted: | |
if Media[CurrentKC][1]: | |
time.sleep(UnMuteDelay) # another brute-force timing assumption | |
subp.call(['amixer','-q','sset',MixerChannel,'unmute']) | |
Muted = False | |
logger.info('Track unmuted') | |
if TrackName: | |
info = wrapper.wrap(TrackName) | |
ShowStatus(info[0], | |
info[1] if len(info) > 1 else '', | |
info[2] if len(info) > 2 else '') | |
else: | |
ShowStatus('No track info','','') | |
elif 'Exiting.' in text: # mplayer just imploded | |
lw.write(text) | |
lw.flush() | |
logger.info('EOF or stream cutoff: [' + text + ']') | |
ShowStatus('Killing dead Mplayer','','') | |
pp.unregister(p.stdout.fileno()) | |
p.terminate() # p.kill() | |
p.wait() | |
logger.info('Discarding keys') | |
while [] != kp.poll(0): | |
kev = k.read | |
time.sleep(RestartDelay) | |
logger.info('Restarting mplayer') | |
ShowStatus('Restarting Mplayer','','') | |
p = subp.Popen(Media[CurrentKC][-1], | |
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT) | |
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
logger.info(' ... running') | |
ShowStatus('Mplayer running','','') | |
# accept pending events from volume control knob | |
if [] != vp.poll(10): | |
vev = v.read() | |
lw.write('Volume') | |
lw.flush() | |
for e in vev: | |
if e.type == ecodes.EV_KEY: | |
kc = KeyEvent(e).keycode | |
if kc in Controls: | |
try: | |
p.stdin.write(Controls[kc]) | |
except Exception as e: | |
logger.info('Error sending volume, restarting player: ' + str(e)) | |
try: | |
pp.unregister(p.stdout.fileno()) | |
except Exception as e: | |
logger.info('Cannot unregister stdout: ' + str(e)) | |
ShowStatus('Vol error','Restarting',' Mplayer') | |
time.sleep(RestartDelay) | |
p = subp.Popen(Media[CurrentKC][-1], | |
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT) | |
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
logger.info(' ... running') | |
# accept pending events from keypad | |
if [] != kp.poll(10): | |
kev = k.read() | |
lw.write('Keypad') | |
lw.flush() | |
for e in kev: | |
if e.type == ecodes.EV_KEY: | |
kc = KeyEvent(e).keycode | |
if kc == 'KEY_NUMLOCK': # discard these, as we don't care | |
continue | |
if (kc == 'KEY_BACKSPACE') and (KeyEvent(e).keystate == KeyEvent.key_hold): | |
if True: | |
logger.info('Shutting down') | |
LogHandler.doRollover() | |
logging.shutdown() | |
p.kill() | |
q = subp.call(['sudo','shutdown','-P','now']) | |
q.wait() | |
time.sleep(5) | |
else: | |
logger.info('Exiting from main') | |
LogHandler.doRollover() | |
logging.shutdown() | |
sys.exit('Exit on command') | |
break | |
if KeyEvent(e).keystate != KeyEvent.key_down: # now OK to discard key up & hold | |
continue | |
if kc == 'KEY_KPENTER': # toggle muted state | |
if Muted: | |
logger.info('Forcing unmute') | |
subp.call(['amixer','-q','sset',MixerChannel,'unmute']) | |
Muted = False | |
else: | |
logger.info('Forcing mute') | |
subp.call(['amixer','-q','sset',MixerChannel,'mute']) | |
Muted = True | |
continue | |
if kc in Controls: | |
logger.info('Control: ' + kc) | |
try: | |
p.stdin.write(Controls[kc]) | |
except Exception as e: | |
logger.info('Error sending controls, restarting player: ' + str(e)) | |
ShowStatus('Ctl error','Restarting',' Mplayer') | |
try: | |
pp.unregister(p.stdout.fileno()) | |
except Exception as e: | |
logger.info('Cannot unregister stdout: ' + str(e)) | |
p.terminate() # p.kill() | |
p.wait() | |
time.sleep(RestartDelay) | |
p = subp.Popen(Media[CurrentKC][-1], | |
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT) | |
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
logger.info(' ... running') | |
ShowStatus('Mplayer',' running','') | |
if kc in Media: | |
logger.info('Switching stream: ' + Media[kc][0] + ' -> ' + Media[kc][-1][-1]) | |
oldname = Media[CurrentKC][0] | |
CurrentKC = kc | |
ShowStatus('Switching from',oldname,'Halt Mplayer') | |
try: | |
pp.unregister(p.stdout.fileno()) | |
except Exception as e: | |
logger.info('Cannot unregister stdout: ' + str(e)) | |
try: | |
p.communicate(input='q') | |
except Exception as e: | |
logger.info('Mplayer already dead? ' + str(e)) | |
try: | |
p.terminate() # p.kill() | |
p.wait() | |
except Exception as e: | |
logger.info('Trouble with terminate or wait: ' + str(e)) | |
if Media[CurrentKC][1]: | |
subp.call(['amixer','-q','sset',MixerChannel,'mute']) | |
Muted = True | |
logger.info('Audio muted for restart') | |
else: | |
subp.call(['amixer','-q','sset',MixerChannel,'unmute']) | |
Muted = False | |
logger.info('Audio unmuted for restart') | |
time.sleep(RestartDelay) | |
logger.info('Restarting Mplayer') | |
p = subp.Popen(Media[CurrentKC][-1], | |
stdin=subp.PIPE,stdout=subp.PIPE,stderr=subp.STDOUT) | |
pp.register(p.stdout.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR) | |
logger.info(' ... running') | |
ShowStatus('Started Mplayer','','') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
More details on my blog at http://wp.me/poZKh-6Au