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:

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 textwrap
library 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 textwrap
generally 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 DataLine
Finagles the text down by one pixel. The value of Linespace
avoids 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 Contrast
based 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:
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','','') |
Yeah, my C programming brain had a fun time wrapping itself around the python code for the Elitech RC-5 datalogger. (The Fahrenheit code is incomplete; the datalogger knows it’s in F and outputs a C/F flag, but the dumped data is in Celsius. Conversion is left to the datareader code. I’ll see about fixing it Real Soon Now, and submit a patch.)
AFAICT, the inheritances all say something like: “Yes, this is me, I’m over there!”