|
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'%5D%5D, |
|
'KEY_KP8' : ['Jazz',False,['mplayer','-playlist','http://stream2138.init7.net/listen.pls'%5D%5D, |
|
'KEY_KP9' : ['WMHT',False,['mplayer','http://wmht.streamguys1.com/wmht1'%5D%5D, |
|
'KEY_KP4' : ['Classic 1k',True,['mplayer','-playlist','http://listen.radionomy.com/1000classicalhits.m3u'%5D%5D, |
|
'KEY_KP5' : ['Love',True,['mplayer','-playlist','/home/pi/Playlists/LoveRadio.m3u']], |
|
'KEY_KP6' : ['WAMC',False,['mplayer','-playlist','http://playerservices.streamtheworld.com/pls/WAMCFM.pls'%5D%5D, |
|
'KEY_KP1' : ['60s',True,['mplayer','-playlist','http://listen.radionomy.com/all60sallthetime-keepfreemusiccom.m3u'%5D%5D, |
|
'KEY_KP2' : ['50-70s',True,['mplayer','-playlist','http://listen.radionomy.com/golden-50-70s-hits.m3u'%5D%5D, |
|
'KEY_KP3' : ['Soft Rock',True,['mplayer','-playlist','http://listen.radionomy.com/softrockradio.m3u'%5D%5D, |
|
'KEY_KP0' : ['Zen',False,['mplayer','http://iradio.iceca.st:80/zenradio'%5D%5D, |
|
'KEY_KPDOT' : ['Ambient',False,['mplayer','http://185.32.125.42:7331/maschinengeist.org.mp3'%5D%5D, |
|
'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','','') |