Archive for March 17th, 2017

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

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)

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((127-(4*font1.getsize('M')[0] + 2),StatLine),'Mute' if Muted else ' ',

    screen.text((1,DataLine + 1*LineSpace),L2,
    screen.text((1,DataLine + 2*LineSpace),L3,

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)
             info[1] if len(info) > 1 else '',
             info[2] if len(info) > 2 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: