-
Raspberry Pi Streaming Radio Player: OLED Display
With the OLED wired up to the Raspberry Pi, the LUMA.OLED driver makes it surprisingly easy to slap text on the screen, at least after some obligatory fumbling around:

RPi OLED Display – Plenitude Connect the hardware, install the driver, then the setup goes like this:
import textwrap from luma.oled.device import sh1106 from luma.core.serial import spi from luma.core.render import canvas from PIL import ImageFont … snippage … 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 for weird ascenders and accent marks LineSpace = 16 Contrast = 255 # OLED brightness setting serial = spi(device=0,port=0) device = sh1106(serial) device.contrast(Contrast)The Python Imaging Library below the LUMA driver supports Truetype fonts that look much better than the default fonts. For these tiny displays, DejaVu Sans comes close enough to being a single-stroke (“stick”) font and, being proportional, packs more text into a fixed horizontal space.
The
textwraplibrary chops a string into lines of a specified length, which works great with a fixed-width font and not so well with a proportional font. I set the line length based on the width of a mid-size lowercase letter and hope for the best. In round numbers, each 128 pixel line can hold 20-ish characters of the size-11 (which might be the height in pixels) font.It also understands hyphens and similar line-ending punctuation:
Felix Mendelssohn- Bartholdy - Piano Concerto No.01 in
It turns out whatever library routine blits characters into the bitmap has an off-by-one error that overwrites the leftmost column with the pixel columns that should be just off-screen on the right; it may also overwrite the topmost row with the bottommost row+1. I poked around a bit, couldn’t find the actual code amid the layers of inherited classes and methods and suchlike, and gave up: each line starts in pixel column 1, not 0. With
textwrapgenerally leaving the rightmost character in each line blank, the picket-fence error (almost) always overwrites the first column with dark pixels.Display coordinates start at (0,0) in the upper left corner, but apparently the character origin corresponds to the box around an uppercase letter, with ascenders and diacritical marks extending (at least) one pixel above that. The blue area in these displays starts at (0,16), but having the ascenders poke into the yellow section is really, really conspicuous, so
DataLineFinagles the text down by one pixel. The value ofLinespaceavoids collisions between descenders and ascenders in successive lines that you (well, I) wouldn’t expect with a spacing equal to the font height.The display has a variable brightness setting, called “contrast” by the datasheet and driver, that determines the overall LED current (perhaps according to an exponential relationship, because an α appears in the tables). I tweak the value in
Contrastbased on where the streamer lives, with 1 being perfectly suitable for a dark room and 255 for ordinary lighting.The LUMA package includes a scrolling terminal emulator. With maybe four lines, tops, on that display (in a reasonable font, anyhow), what’s the point?
Instead, I homebrewed a panel with manual positioning:
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')Yeah, those are global variables in the first line; feel free to object-orient it as you like.
The LUMA driver hands you a blank screen inside the
with … as …:context, whereupon you may draw as you see fit and the driver squirts the bitmap to the display at the end of the context. There’s apparently a way to set up a permanent canvas and update it at will, but this works well enough for now.That means you (well, I) must mange those three lines by hand:
ShowStatus('Startup in ' + Location, 'Mixer: ' + MixerChannel + ' = ' + MixerVol, 'Contrast: ' + str(Contrast))Chopping the track info string into lines goes like this:
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','','')Something along the way ruins Unicode characters from the track info, converting them into unrelated (and generally accented) characters. They work fine when shipped through the logging interface, so it may be due to a font incompatibility or, more likely, my not bothering to work around Python 2’s string vs. byte stream weirdness. Using Python 3 would be a Good Idea, but I’m unsure all the various & sundry libraries are compatible and unwilling to find out using programming as an experimental science.
The Python source code as a GitHub Gist:
This file contains hidden or 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 charactersfrom 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','','')